tembro 4.0.1 → 4.1.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 (61) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/components/display/chat.cjs +1 -0
  3. package/dist/components/display/chat.d.ts +94 -0
  4. package/dist/components/display/chat.js +1 -0
  5. package/dist/components/display/index.d.ts +1 -0
  6. package/dist/components/display/kanban.d.ts +33 -1
  7. package/dist/components/layout/sidebar.d.ts +24 -2
  8. package/dist/components/modern/rich-text-editor.d.ts +13 -1
  9. package/dist/components/theme-provider.d.ts +19 -6
  10. package/dist/components/ui/badge/index.d.ts +8 -2
  11. package/dist/src/components/display/activity-feed.cjs +1 -1
  12. package/dist/src/components/display/activity-feed.js +4 -1
  13. package/dist/src/components/display/chat.cjs +1 -0
  14. package/dist/src/components/display/chat.js +372 -0
  15. package/dist/src/components/display/index.cjs +1 -1
  16. package/dist/src/components/display/index.js +17 -16
  17. package/dist/src/components/display/kanban.cjs +1 -1
  18. package/dist/src/components/display/kanban.js +321 -176
  19. package/dist/src/components/display/timeline.cjs +1 -1
  20. package/dist/src/components/display/timeline.js +5 -5
  21. package/dist/src/components/inputs/tag-input.cjs +1 -1
  22. package/dist/src/components/inputs/tag-input.js +6 -2
  23. package/dist/src/components/layout/index.cjs +1 -1
  24. package/dist/src/components/layout/index.js +4 -4
  25. package/dist/src/components/layout/public.cjs +1 -1
  26. package/dist/src/components/layout/public.js +4 -4
  27. package/dist/src/components/layout/sidebar.cjs +1 -1
  28. package/dist/src/components/layout/sidebar.js +326 -228
  29. package/dist/src/components/modern/rich-text-editor.cjs +1 -1
  30. package/dist/src/components/modern/rich-text-editor.js +216 -89
  31. package/dist/src/components/theme-provider.cjs +1 -1
  32. package/dist/src/components/theme-provider.js +58 -36
  33. package/dist/src/components/ui/badge/index.cjs +1 -1
  34. package/dist/src/components/ui/badge/index.js +59 -47
  35. package/dist/src/components/ui/input/clearable.cjs +1 -1
  36. package/dist/src/components/ui/input/clearable.js +5 -2
  37. package/dist/src/components/ui/input/primitive.cjs +1 -1
  38. package/dist/src/components/ui/input/primitive.js +1 -1
  39. package/dist/src/index.cjs +1 -1
  40. package/dist/src/index.js +87 -86
  41. package/dist/src/public-component-surface.cjs +1 -1
  42. package/dist/src/public-component-surface.js +5 -0
  43. package/package.json +1 -1
  44. package/packages/cli/dist/index.cjs +3 -3
  45. package/packages/cli/vendor/src/components/display/activity-feed.tsx +1 -1
  46. package/packages/cli/vendor/src/components/display/chat.tsx +237 -0
  47. package/packages/cli/vendor/src/components/display/index.ts +1 -0
  48. package/packages/cli/vendor/src/components/display/kanban.tsx +219 -72
  49. package/packages/cli/vendor/src/components/display/timeline.tsx +10 -10
  50. package/packages/cli/vendor/src/components/inputs/tag-input.tsx +3 -2
  51. package/packages/cli/vendor/src/components/layout/sidebar.tsx +212 -51
  52. package/packages/cli/vendor/src/components/modern/rich-text-editor.tsx +115 -32
  53. package/packages/cli/vendor/src/components/theme-provider.tsx +82 -36
  54. package/packages/cli/vendor/src/components/ui/badge/index.tsx +29 -8
  55. package/packages/cli/vendor/src/components/ui/input/clearable.tsx +2 -2
  56. package/packages/cli/vendor/src/components/ui/input/primitive.tsx +1 -1
  57. package/packages/cli/vendor/src/public-component-surface.ts +1 -0
  58. package/packages/cli/vendor/templates/showcase/src/showcase/layout/HeroSection.tsx +2 -2
  59. package/packages/cli/vendor/templates/showcase/src/showcase/layout/WorkbenchSidebar.tsx +1 -1
  60. package/packages/cli/vendor/templates/styles/globals.css +91 -10
  61. package/registry.json +3 -1
@@ -7,8 +7,13 @@ import Link from "@tiptap/extension-link"
7
7
  import Placeholder from "@tiptap/extension-placeholder"
8
8
  import {
9
9
  BoldIcon,
10
- CodeIcon,
10
+ Code2Icon,
11
+ EraserIcon,
12
+ Heading1Icon,
11
13
  Heading2Icon,
14
+ Heading3Icon,
15
+ MinusIcon,
16
+ PilcrowIcon,
12
17
  ItalicIcon,
13
18
  LinkIcon,
14
19
  ListIcon,
@@ -31,6 +36,14 @@ export type RichTextEditorProps = Omit<React.ComponentProps<"div">, "defaultValu
31
36
  editable?: boolean
32
37
  autoFocus?: boolean
33
38
  minHeight?: number
39
+ maxHeight?: number
40
+ maxLength?: number
41
+ toolbar?: boolean
42
+ stickyToolbar?: boolean
43
+ toolbarSize?: "compact" | "default"
44
+ features?: RichTextFeature[]
45
+ showCharacterCount?: boolean
46
+ showWordCount?: boolean
34
47
  onLinkRequest?: (currentHref?: string) => string | null | undefined
35
48
  labels?: {
36
49
  editor?: string
@@ -38,7 +51,32 @@ export type RichTextEditorProps = Omit<React.ComponentProps<"div">, "defaultValu
38
51
  }
39
52
  }
40
53
 
54
+ export type RichTextFeature =
55
+ | "history"
56
+ | "bold"
57
+ | "italic"
58
+ | "strike"
59
+ | "code"
60
+ | "heading1"
61
+ | "heading2"
62
+ | "heading3"
63
+ | "paragraph"
64
+ | "bulletList"
65
+ | "orderedList"
66
+ | "blockquote"
67
+ | "codeBlock"
68
+ | "horizontalRule"
69
+ | "link"
70
+ | "clearFormatting"
71
+
72
+ const defaultFeatures: RichTextFeature[] = [
73
+ "history", "bold", "italic", "strike", "code", "heading1", "heading2", "heading3", "paragraph",
74
+ "bulletList", "orderedList", "blockquote", "codeBlock", "horizontalRule", "link", "clearFormatting",
75
+ ]
76
+
41
77
  type ToolbarAction = {
78
+ feature: RichTextFeature
79
+ group: string
42
80
  label: string
43
81
  icon: React.ComponentType<{ className?: string }>
44
82
  active?: boolean
@@ -54,23 +92,42 @@ function contentForEditor(value: string, output: "text" | "html") {
54
92
  return output === "html" ? value : `<p>${escapeHtml(value)}</p>`
55
93
  }
56
94
 
57
- function EditorToolbar({ editor, onLinkRequest, label }: { editor: Editor; onLinkRequest?: RichTextEditorProps["onLinkRequest"]; label: string }) {
95
+ function EditorToolbar({ editor, onLinkRequest, label, features = defaultFeatures, sticky = false, size = "default" }: { editor: Editor; onLinkRequest?: RichTextEditorProps["onLinkRequest"]; label: string; features?: RichTextFeature[]; sticky?: boolean; size?: "compact" | "default" }) {
96
+ const [, refresh] = React.useReducer((value) => value + 1, 0)
97
+ React.useEffect(() => {
98
+ const update = () => refresh()
99
+ editor.on("transaction", update)
100
+ editor.on("selectionUpdate", update)
101
+ return () => {
102
+ editor.off("transaction", update)
103
+ editor.off("selectionUpdate", update)
104
+ }
105
+ }, [editor])
106
+
58
107
  const linkHref = editor.getAttributes("link").href as string | undefined
59
108
  const actions: ToolbarAction[] = [
60
- { label: "Undo", icon: Undo2Icon, disabled: !editor.can().undo(), run: () => editor.chain().focus().undo().run() },
61
- { label: "Redo", icon: Redo2Icon, disabled: !editor.can().redo(), run: () => editor.chain().focus().redo().run() },
62
- { label: "Bold", icon: BoldIcon, active: editor.isActive("bold"), run: () => editor.chain().focus().toggleBold().run() },
63
- { label: "Italic", icon: ItalicIcon, active: editor.isActive("italic"), run: () => editor.chain().focus().toggleItalic().run() },
64
- { label: "Strike", icon: StrikethroughIcon, active: editor.isActive("strike"), run: () => editor.chain().focus().toggleStrike().run() },
65
- { label: "Inline code", icon: CodeIcon, active: editor.isActive("code"), run: () => editor.chain().focus().toggleCode().run() },
66
- { label: "Heading", icon: Heading2Icon, active: editor.isActive("heading", { level: 2 }), run: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
67
- { label: "Bullet list", icon: ListIcon, active: editor.isActive("bulletList"), run: () => editor.chain().focus().toggleBulletList().run() },
68
- { label: "Ordered list", icon: ListOrderedIcon, active: editor.isActive("orderedList"), run: () => editor.chain().focus().toggleOrderedList().run() },
69
- { label: "Blockquote", icon: QuoteIcon, active: editor.isActive("blockquote"), run: () => editor.chain().focus().toggleBlockquote().run() },
109
+ { feature: "history", group: "history", label: "Undo", icon: Undo2Icon, disabled: !editor.can().undo(), run: () => editor.chain().focus().undo().run() },
110
+ { feature: "history", group: "history", label: "Redo", icon: Redo2Icon, disabled: !editor.can().redo(), run: () => editor.chain().focus().redo().run() },
111
+ { feature: "bold", group: "marks", label: "Bold", icon: BoldIcon, active: editor.isActive("bold"), run: () => editor.chain().focus().toggleBold().run() },
112
+ { feature: "italic", group: "marks", label: "Italic", icon: ItalicIcon, active: editor.isActive("italic"), run: () => editor.chain().focus().toggleItalic().run() },
113
+ { feature: "strike", group: "marks", label: "Strike", icon: StrikethroughIcon, active: editor.isActive("strike"), run: () => editor.chain().focus().toggleStrike().run() },
114
+ { feature: "code", group: "marks", label: "Inline code", icon: Code2Icon, active: editor.isActive("code"), run: () => editor.chain().focus().toggleCode().run() },
115
+ { feature: "paragraph", group: "blocks", label: "Paragraph", icon: PilcrowIcon, active: editor.isActive("paragraph"), run: () => editor.chain().focus().setParagraph().run() },
116
+ { feature: "heading1", group: "blocks", label: "Heading 1", icon: Heading1Icon, active: editor.isActive("heading", { level: 1 }), run: () => editor.chain().focus().toggleHeading({ level: 1 }).run() },
117
+ { feature: "heading2", group: "blocks", label: "Heading 2", icon: Heading2Icon, active: editor.isActive("heading", { level: 2 }), run: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
118
+ { feature: "heading3", group: "blocks", label: "Heading 3", icon: Heading3Icon, active: editor.isActive("heading", { level: 3 }), run: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
119
+ { feature: "bulletList", group: "lists", label: "Bullet list", icon: ListIcon, active: editor.isActive("bulletList"), run: () => editor.chain().focus().toggleBulletList().run() },
120
+ { feature: "orderedList", group: "lists", label: "Ordered list", icon: ListOrderedIcon, active: editor.isActive("orderedList"), run: () => editor.chain().focus().toggleOrderedList().run() },
121
+ { feature: "blockquote", group: "lists", label: "Blockquote", icon: QuoteIcon, active: editor.isActive("blockquote"), run: () => editor.chain().focus().toggleBlockquote().run() },
122
+ { feature: "codeBlock", group: "insert", label: "Code block", icon: Code2Icon, active: editor.isActive("codeBlock"), run: () => editor.chain().focus().toggleCodeBlock().run() },
123
+ { feature: "horizontalRule", group: "insert", label: "Horizontal rule", icon: MinusIcon, run: () => editor.chain().focus().setHorizontalRule().run() },
124
+ { feature: "clearFormatting", group: "clear", label: "Clear formatting", icon: EraserIcon, run: () => editor.chain().focus().unsetAllMarks().clearNodes().run() },
70
125
  ]
71
126
 
72
- if (onLinkRequest) {
127
+ if (onLinkRequest && features.includes("link")) {
73
128
  actions.push({
129
+ feature: "link",
130
+ group: "link",
74
131
  label: "Set link",
75
132
  icon: LinkIcon,
76
133
  active: editor.isActive("link"),
@@ -84,26 +141,31 @@ function EditorToolbar({ editor, onLinkRequest, label }: { editor: Editor; onLin
84
141
  }
85
142
 
86
143
  if (editor.isActive("link")) {
87
- actions.push({ label: "Remove link", icon: UnlinkIcon, run: () => editor.chain().focus().unsetLink().run() })
144
+ actions.push({ feature: "link", group: "link", label: "Remove link", icon: UnlinkIcon, run: () => editor.chain().focus().unsetLink().run() })
88
145
  }
89
146
 
147
+ const visibleActions = actions.filter((action) => features.includes(action.feature))
148
+
90
149
  return (
91
- <div role="toolbar" aria-label={label} className="flex flex-wrap gap-1 border-b p-1.5">
92
- {actions.map((action) => {
150
+ <div role="toolbar" aria-label={label} data-size={size} data-sticky={sticky || undefined} className={cn("flex flex-wrap items-center gap-1 border-b bg-background/95 p-1.5", sticky && "sticky top-0 z-10 backdrop-blur", size === "compact" && "flex-nowrap overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden")}>
151
+ {visibleActions.map((action, index) => {
93
152
  const Icon = action.icon
153
+ const showSeparator = index > 0 && visibleActions[index - 1]?.group !== action.group
94
154
  return (
95
- <button
96
- key={action.label}
97
- type="button"
98
- title={action.label}
99
- aria-label={action.label}
100
- aria-pressed={action.active || undefined}
101
- disabled={action.disabled}
102
- className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-accent aria-pressed:text-foreground disabled:pointer-events-none disabled:opacity-40"
103
- onClick={action.run}
104
- >
105
- <Icon className="size-4" />
106
- </button>
155
+ <React.Fragment key={action.label}>
156
+ {showSeparator ? <span role="separator" aria-orientation="vertical" className="mx-0.5 h-5 w-px shrink-0 bg-border" /> : null}
157
+ <button
158
+ type="button"
159
+ title={action.label}
160
+ aria-label={action.label}
161
+ aria-pressed={action.active || undefined}
162
+ disabled={action.disabled}
163
+ className={cn("grid size-8 shrink-0 place-items-center rounded-md p-0 text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-accent aria-pressed:text-foreground disabled:pointer-events-none disabled:opacity-40", size === "compact" && "size-7")}
164
+ onClick={action.run}
165
+ >
166
+ <Icon className="size-4" />
167
+ </button>
168
+ </React.Fragment>
107
169
  )
108
170
  })}
109
171
  </div>
@@ -119,6 +181,14 @@ function MountedRichTextEditor({
119
181
  editable = true,
120
182
  autoFocus = false,
121
183
  minHeight = 144,
184
+ maxHeight,
185
+ maxLength,
186
+ toolbar = true,
187
+ stickyToolbar = false,
188
+ toolbarSize = "default",
189
+ features = defaultFeatures,
190
+ showCharacterCount = false,
191
+ showWordCount = false,
122
192
  onLinkRequest,
123
193
  labels,
124
194
  className,
@@ -145,6 +215,14 @@ function MountedRichTextEditor({
145
215
  onValueChange?.(output === "html" ? nextEditor.getHTML() : nextEditor.getText())
146
216
  },
147
217
  })
218
+ const [, refreshCount] = React.useReducer((value) => value + 1, 0)
219
+
220
+ React.useEffect(() => {
221
+ if (!editor) return
222
+ const update = () => refreshCount()
223
+ editor.on("update", update)
224
+ return () => { editor.off("update", update) }
225
+ }, [editor])
148
226
 
149
227
  React.useEffect(() => {
150
228
  if (!editor || value === undefined) return
@@ -160,16 +238,21 @@ function MountedRichTextEditor({
160
238
  <div
161
239
  data-slot="rich-text-editor"
162
240
  data-disabled={!editable || undefined}
163
- className={cn("overflow-hidden rounded-md border bg-background shadow-sm focus-within:ring-2 focus-within:ring-ring data-[disabled]:opacity-60", className)}
241
+ data-over-limit={editor && maxLength !== undefined && editor.getText().length > maxLength || undefined}
242
+ className={cn("overflow-hidden rounded-md border bg-background shadow-sm transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/25 data-[disabled]:opacity-60 data-[over-limit=true]:border-destructive", className)}
164
243
  {...props}
165
244
  >
166
- {editor && editable ? <EditorToolbar editor={editor} onLinkRequest={onLinkRequest} label={labels?.toolbar ?? "Text formatting"} /> : null}
245
+ {editor && editable && toolbar ? <EditorToolbar editor={editor} onLinkRequest={onLinkRequest} label={labels?.toolbar ?? "Text formatting"} features={features} sticky={stickyToolbar} size={toolbarSize} /> : null}
167
246
  <EditorContent
168
247
  editor={editor}
169
248
  aria-label={labels?.editor ?? "Rich text editor"}
170
- className="[&_.tiptap]:px-3 [&_.tiptap]:py-2.5 [&_.tiptap]:outline-none [&_.tiptap_a]:text-primary [&_.tiptap_a]:underline [&_.tiptap_blockquote]:border-l-2 [&_.tiptap_blockquote]:pl-3 [&_.tiptap_h2]:text-lg [&_.tiptap_h2]:font-semibold [&_.tiptap_ol]:list-decimal [&_.tiptap_ol]:pl-6 [&_.tiptap_p.is-editor-empty:first-child:before]:pointer-events-none [&_.tiptap_p.is-editor-empty:first-child:before]:float-left [&_.tiptap_p.is-editor-empty:first-child:before]:h-0 [&_.tiptap_p.is-editor-empty:first-child:before]:text-muted-foreground [&_.tiptap_p.is-editor-empty:first-child:before]:content-[attr(data-placeholder)] [&_.tiptap_ul]:list-disc [&_.tiptap_ul]:pl-6"
171
- style={{ minHeight }}
249
+ className="overflow-y-auto [&_.tiptap]:min-h-[var(--editor-min-height)] [&_.tiptap]:px-3.5 [&_.tiptap]:py-3 [&_.tiptap]:text-sm [&_.tiptap]:leading-6 [&_.tiptap]:outline-none [&_.tiptap>*+*]:mt-2 [&_.tiptap_a]:text-primary [&_.tiptap_a]:underline [&_.tiptap_blockquote]:border-l-2 [&_.tiptap_blockquote]:border-primary/50 [&_.tiptap_blockquote]:pl-3 [&_.tiptap_code]:rounded [&_.tiptap_code]:bg-muted [&_.tiptap_code]:px-1 [&_.tiptap_code]:py-0.5 [&_.tiptap_h1]:text-2xl [&_.tiptap_h1]:font-semibold [&_.tiptap_h2]:text-xl [&_.tiptap_h2]:font-semibold [&_.tiptap_h3]:text-lg [&_.tiptap_h3]:font-semibold [&_.tiptap_hr]:my-4 [&_.tiptap_hr]:border-border [&_.tiptap_ol]:list-decimal [&_.tiptap_ol]:pl-6 [&_.tiptap_p.is-editor-empty:first-child:before]:pointer-events-none [&_.tiptap_p.is-editor-empty:first-child:before]:float-left [&_.tiptap_p.is-editor-empty:first-child:before]:h-0 [&_.tiptap_p.is-editor-empty:first-child:before]:text-muted-foreground [&_.tiptap_p.is-editor-empty:first-child:before]:content-[attr(data-placeholder)] [&_.tiptap_pre]:overflow-x-auto [&_.tiptap_pre]:rounded-md [&_.tiptap_pre]:bg-muted [&_.tiptap_pre]:p-3 [&_.tiptap_ul]:list-disc [&_.tiptap_ul]:pl-6"
250
+ style={{ "--editor-min-height": `${minHeight}px`, maxHeight } as React.CSSProperties}
172
251
  />
252
+ {editor && (showCharacterCount || showWordCount || maxLength !== undefined) ? <div data-slot="rich-text-editor-footer" className="flex items-center justify-end gap-3 border-t bg-muted/20 px-3 py-1.5 text-[11px] text-muted-foreground">
253
+ {showWordCount ? <span>{editor.getText().trim() ? editor.getText().trim().split(/\s+/).length : 0} words</span> : null}
254
+ {showCharacterCount || maxLength !== undefined ? <span className={cn(maxLength !== undefined && editor.getText().length > maxLength && "font-medium text-destructive")}>{editor.getText().length}{maxLength !== undefined ? `/${maxLength}` : " characters"}</span> : null}
255
+ </div> : null}
173
256
  </div>
174
257
  )
175
258
  }
@@ -1,74 +1,120 @@
1
- "use client"
2
-
1
+ "use client"
2
+
3
3
  import * as React from "react"
4
4
 
5
- type ThemeMode = "light" | "dark" | "system"
6
- type ResolvedThemeMode = "light" | "dark"
5
+ export type ThemeName = string
6
+ export type ThemeMode = ThemeName | "system"
7
+ export type ThemeColorScheme = "light" | "dark"
8
+
9
+ export type ThemeProviderProps = {
10
+ children: React.ReactNode
11
+ themes?: ThemeName[]
12
+ defaultTheme?: ThemeMode
13
+ forcedTheme?: ThemeName
14
+ storageKey?: string
15
+ enableSystem?: boolean
16
+ attribute?: "class" | "data-theme" | "both"
17
+ colorSchemes?: Record<ThemeName, ThemeColorScheme>
18
+ disableTransitionOnChange?: boolean
19
+ onThemeChange?: (theme: ThemeMode, resolvedTheme: ThemeName) => void
20
+ }
7
21
 
8
22
  type ThemeContextValue = {
9
23
  theme: ThemeMode
10
- resolvedTheme: ResolvedThemeMode
24
+ resolvedTheme: ThemeName
25
+ systemTheme: ThemeColorScheme
26
+ themes: ThemeName[]
11
27
  mounted: boolean
12
28
  setTheme: (theme: ThemeMode) => void
13
29
  toggleTheme: () => void
14
30
  }
15
31
 
16
32
  const THEME_STORAGE_KEY = "tembro-theme"
33
+ const defaultThemes = ["light", "dark"]
34
+ const defaultColorSchemes: Record<string, ThemeColorScheme> = { light: "light", dark: "dark", dim: "dark" }
17
35
 
18
36
  const ThemeContext = React.createContext<ThemeContextValue | null>(null)
19
37
 
20
- function getSystemTheme(): ResolvedThemeMode {
38
+ function getSystemTheme(): ThemeColorScheme {
21
39
  if (typeof window === "undefined") return "light"
22
40
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
23
41
  }
24
42
 
25
- function resolveInitialTheme(): ThemeMode {
26
- if (typeof window === "undefined") return "system"
27
-
28
- const stored = window.localStorage.getItem(THEME_STORAGE_KEY)
29
- if (stored === "light" || stored === "dark" || stored === "system") return stored
30
-
31
- return "system"
32
- }
33
-
34
- export function ThemeProvider({ children }: { children: React.ReactNode }) {
35
- const [theme, setThemeState] = React.useState<ThemeMode>(resolveInitialTheme)
43
+ export function ThemeProvider({
44
+ children,
45
+ themes = defaultThemes,
46
+ defaultTheme = "system",
47
+ forcedTheme,
48
+ storageKey = THEME_STORAGE_KEY,
49
+ enableSystem = true,
50
+ attribute = "both",
51
+ colorSchemes = defaultColorSchemes,
52
+ disableTransitionOnChange = true,
53
+ onThemeChange,
54
+ }: ThemeProviderProps) {
55
+ const availableThemes = React.useMemo(() => Array.from(new Set(themes.length ? themes : defaultThemes)), [themes])
56
+ const [theme, setThemeState] = React.useState<ThemeMode>(() => {
57
+ if (typeof window === "undefined") return defaultTheme
58
+ const stored = window.localStorage.getItem(storageKey)
59
+ if (stored === "system" && enableSystem) return stored
60
+ if (stored && availableThemes.includes(stored)) return stored
61
+ return defaultTheme
62
+ })
36
63
  const [mounted, setMounted] = React.useState(false)
37
- const [systemTheme, setSystemTheme] = React.useState<ResolvedThemeMode>(getSystemTheme)
38
- const resolvedTheme = theme === "system" ? systemTheme : theme
64
+ const [systemTheme, setSystemTheme] = React.useState<ThemeColorScheme>(getSystemTheme)
65
+ const appliedClassRef = React.useRef<string | undefined>(undefined)
66
+ const activeTheme = forcedTheme ?? theme
67
+ const resolvedTheme = activeTheme === "system"
68
+ ? (availableThemes.includes(systemTheme) ? systemTheme : availableThemes[0] ?? "light")
69
+ : activeTheme
70
+ const colorScheme = colorSchemes[resolvedTheme] ?? defaultColorSchemes[resolvedTheme] ?? (resolvedTheme === "dark" ? "dark" : "light")
39
71
 
40
- React.useEffect(() => {
41
- setMounted(true)
42
- }, [])
72
+ React.useEffect(() => { setMounted(true) }, [])
43
73
 
44
74
  React.useEffect(() => {
75
+ if (!enableSystem) return
45
76
  const media = window.matchMedia("(prefers-color-scheme: dark)")
46
77
  const updateSystemTheme = () => setSystemTheme(media.matches ? "dark" : "light")
47
-
48
78
  updateSystemTheme()
49
79
  media.addEventListener("change", updateSystemTheme)
50
80
  return () => media.removeEventListener("change", updateSystemTheme)
51
- }, [])
81
+ }, [enableSystem])
52
82
 
53
83
  React.useEffect(() => {
54
84
  const root = window.document.documentElement
55
- root.classList.toggle("dark", resolvedTheme === "dark")
56
- root.style.colorScheme = resolvedTheme
57
- window.localStorage.setItem(THEME_STORAGE_KEY, theme)
58
- }, [resolvedTheme, theme])
85
+ let restoreTransitions: (() => void) | undefined
86
+ if (disableTransitionOnChange) {
87
+ const style = document.createElement("style")
88
+ style.textContent = "*,*::before,*::after{transition:none!important}"
89
+ document.head.append(style)
90
+ void window.getComputedStyle(root).opacity
91
+ restoreTransitions = () => window.setTimeout(() => style.remove(), 0)
92
+ }
93
+
94
+ if (attribute === "class" || attribute === "both") {
95
+ if (appliedClassRef.current) root.classList.remove(appliedClassRef.current)
96
+ root.classList.remove("light", "dark")
97
+ root.classList.add(resolvedTheme)
98
+ if (colorScheme === "dark" && resolvedTheme !== "dark") root.classList.add("dark")
99
+ appliedClassRef.current = resolvedTheme
100
+ }
101
+ if (attribute === "data-theme" || attribute === "both") root.dataset.theme = resolvedTheme
102
+ root.style.colorScheme = colorScheme
103
+ if (!forcedTheme) window.localStorage.setItem(storageKey, theme)
104
+ onThemeChange?.(theme, resolvedTheme)
105
+ restoreTransitions?.()
106
+ }, [attribute, colorScheme, disableTransitionOnChange, forcedTheme, onThemeChange, resolvedTheme, storageKey, theme])
59
107
 
60
108
  const setTheme = React.useCallback((nextTheme: ThemeMode) => {
61
- setThemeState(nextTheme)
62
- }, [])
109
+ if (nextTheme === "system" ? enableSystem : availableThemes.includes(nextTheme)) setThemeState(nextTheme)
110
+ }, [availableThemes, enableSystem])
63
111
 
64
112
  const toggleTheme = React.useCallback(() => {
65
- setThemeState((current) => (current === "system" ? "dark" : current === "dark" ? "light" : "system"))
66
- }, [])
113
+ const cycle: ThemeMode[] = [...availableThemes, ...(enableSystem ? ["system" as const] : [])]
114
+ setThemeState((current) => cycle[(cycle.indexOf(current) + 1) % cycle.length] ?? availableThemes[0] ?? "light")
115
+ }, [availableThemes, enableSystem])
67
116
 
68
- const value = React.useMemo(
69
- () => ({ theme, resolvedTheme, mounted, setTheme, toggleTheme }),
70
- [mounted, resolvedTheme, setTheme, theme, toggleTheme]
71
- )
117
+ const value = React.useMemo<ThemeContextValue>(() => ({ theme, resolvedTheme, systemTheme, themes: availableThemes, mounted, setTheme, toggleTheme }), [availableThemes, mounted, resolvedTheme, setTheme, systemTheme, theme, toggleTheme])
72
118
 
73
119
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
74
120
  }
@@ -47,7 +47,7 @@ const badgeVariants = cva(
47
47
  )
48
48
 
49
49
  type BadgeProps = useRender.ComponentProps<"span"> &
50
- VariantProps<typeof badgeVariants> & {
50
+ Omit<VariantProps<typeof badgeVariants>, "dot"> & {
51
51
  label?: React.ReactNode
52
52
  count?: React.ReactNode
53
53
  status?: "neutral" | "info" | "success" | "warning" | "danger" | "muted"
@@ -57,6 +57,12 @@ type BadgeProps = useRender.ComponentProps<"span"> &
57
57
  removeLabel?: string
58
58
  leftIcon?: React.ReactNode
59
59
  rightIcon?: React.ReactNode
60
+ avatar?: React.ReactNode
61
+ dot?: boolean | React.ReactNode
62
+ showDot?: boolean
63
+ dotPosition?: "start" | "end"
64
+ pulse?: boolean
65
+ interactive?: boolean
60
66
  }
61
67
 
62
68
  function Badge({
@@ -74,6 +80,11 @@ function Badge({
74
80
  removeLabel = "Remove badge",
75
81
  leftIcon,
76
82
  rightIcon,
83
+ avatar,
84
+ showDot,
85
+ dotPosition = "start",
86
+ pulse = false,
87
+ interactive = false,
77
88
  children,
78
89
  render,
79
90
  onKeyDown,
@@ -81,18 +92,24 @@ function Badge({
81
92
  }: BadgeProps) {
82
93
  const resolvedTone = status ?? tone
83
94
  const resolvedLabel = label ?? children
84
- const showDot = dot || status === "success" || status === "warning" || status === "danger" || status === "info"
95
+ const resolvedShowDot = showDot ?? Boolean(dot || status === "success" || status === "warning" || status === "danger" || status === "info")
96
+ const dotNode = resolvedShowDot ? (
97
+ <span data-slot="badge-dot" data-pulse={pulse || undefined}>
98
+ {React.isValidElement(dot) ? dot : null}
99
+ </span>
100
+ ) : null
85
101
 
86
102
  return useRender({
87
103
  defaultTagName: "span",
88
104
  props: mergeProps<"span">(
89
105
  {
90
106
  className: cn(
91
- badgeVariants({ variant, tone: resolvedTone, size, dot: showDot }),
107
+ badgeVariants({ variant, tone: resolvedTone, size, dot: resolvedShowDot }),
92
108
  selected && "ring-2 ring-ring/45 ring-offset-1",
109
+ interactive && "cursor-pointer",
93
110
  className
94
111
  ),
95
- tabIndex: removable ? 0 : undefined,
112
+ tabIndex: removable || interactive ? 0 : undefined,
96
113
  onKeyDown: (event: React.KeyboardEvent<HTMLSpanElement>) => {
97
114
  if (removable && (event.key === "Backspace" || event.key === "Delete")) {
98
115
  event.preventDefault()
@@ -102,8 +119,9 @@ function Badge({
102
119
  },
103
120
  children: (
104
121
  <>
105
- {showDot ? <span data-slot="badge-dot" /> : null}
106
- {leftIcon ? <span data-icon="inline-start" data-slot="badge-icon" className="inline-flex shrink-0 items-center">{leftIcon}</span> : null}
122
+ {dotPosition === "start" ? dotNode : null}
123
+ {avatar ? <span data-slot="badge-avatar" className="grid shrink-0 place-items-center overflow-hidden rounded-full">{avatar}</span> : null}
124
+ {leftIcon ? <span data-icon="inline-start" data-slot="badge-icon" className="grid shrink-0 place-items-center">{leftIcon}</span> : null}
107
125
  {resolvedLabel ? <span data-slot="badge-label">{resolvedLabel}</span> : null}
108
126
  {count != null ? (
109
127
  <span
@@ -112,7 +130,8 @@ function Badge({
112
130
  {count}
113
131
  </span>
114
132
  ) : null}
115
- {rightIcon ? <span data-icon="inline-end" data-slot="badge-icon" className="inline-flex shrink-0 items-center">{rightIcon}</span> : null}
133
+ {rightIcon ? <span data-icon="inline-end" data-slot="badge-icon" className="grid shrink-0 place-items-center">{rightIcon}</span> : null}
134
+ {dotPosition === "end" ? dotNode : null}
116
135
  {removable ? (
117
136
  <button
118
137
  type="button"
@@ -136,6 +155,8 @@ function Badge({
136
155
  "data-size": size ?? "default",
137
156
  "data-removable": removable || undefined,
138
157
  "data-selected": selected || undefined,
158
+ "data-interactive": interactive || undefined,
159
+ "data-pulse": pulse || undefined,
139
160
  } as React.HTMLAttributes<HTMLSpanElement>,
140
161
  props
141
162
  ),
@@ -145,7 +166,7 @@ function Badge({
145
166
  variant,
146
167
  tone: resolvedTone,
147
168
  size,
148
- dot: showDot,
169
+ dot: resolvedShowDot,
149
170
  },
150
171
  })
151
172
  }
@@ -98,12 +98,12 @@ const ClearableInput = React.forwardRef<HTMLInputElement, ClearableInputProps>(
98
98
  type="button"
99
99
  data-slot="clearable-input-clear"
100
100
  aria-label={clearLabel}
101
- className="inline-flex size-7 items-center justify-center rounded-[var(--radius-sm)] border border-transparent bg-transparent text-muted-foreground/74 transition hover:border-border/60 hover:bg-muted/58 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
101
+ className="grid size-7 shrink-0 place-items-center rounded-[var(--radius-sm)] border border-transparent bg-transparent p-0 leading-none text-muted-foreground/74 transition hover:border-border/60 hover:bg-muted/58 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
102
102
  onClick={handleClearClick}
103
103
  onMouseDown={handleClearMouseDown}
104
104
  onDoubleClick={handleClearMouseDown}
105
105
  >
106
- <XIcon className="size-4" />
106
+ <XIcon className="size-3.5" strokeWidth={2} />
107
107
  </button>
108
108
  )}
109
109
  </>
@@ -13,7 +13,7 @@ function InputPrimitive({ className, type, ...props }: InputPrimitiveProps) {
13
13
  type={type}
14
14
  data-slot="input"
15
15
  className={cn(
16
- "h-11 w-full min-w-0 rounded-[var(--aui-control-radius,var(--radius-md))] border border-[color:color-mix(in_oklch,var(--aui-control-border-strong,var(--input)),var(--foreground)_6%)] bg-[color:var(--aui-control-surface,var(--background))] px-3.5 py-2.5 text-sm font-normal text-foreground shadow-[var(--aui-shadow-xs,0_1px_2px_rgba(15,23,42,0.04))] transition-[background-color,border-color,box-shadow,color] outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground/74 hover:border-[color:color-mix(in_oklch,var(--aui-control-hover-border,var(--ring)),var(--foreground)_10%)] hover:bg-[color:var(--aui-control-surface-hover,var(--background))] focus-visible:border-[color:var(--ring)] focus-visible:ring-0 focus-visible:shadow-[var(--aui-shadow-xs,0_1px_2px_rgba(15,23,42,0.04)),0_0_0_1px_var(--aui-focus-ring,var(--ring)),0_0_0_4px_var(--aui-focus-ring-soft,transparent)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:border-[color:color-mix(in_oklch,var(--border),transparent_18%)] disabled:bg-[color:var(--aui-control-surface-disabled,var(--muted))] disabled:text-muted-foreground disabled:shadow-none disabled:opacity-100 read-only:bg-[color:var(--aui-control-surface-readonly,var(--muted))]",
16
+ "h-11 w-full min-w-0 rounded-[var(--aui-control-radius,var(--radius-md))] border border-[color:color-mix(in_oklch,var(--aui-control-border-strong,var(--input)),var(--foreground)_6%)] bg-[color:var(--aui-control-surface,var(--background))] px-3.5 py-2.5 text-sm font-normal text-foreground shadow-[var(--aui-shadow-xs,0_1px_2px_rgba(15,23,42,0.04))] transition-[background-color,border-color,box-shadow,color] outline-none [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground/74 hover:border-[color:color-mix(in_oklch,var(--aui-control-hover-border,var(--ring)),var(--foreground)_10%)] hover:bg-[color:var(--aui-control-surface-hover,var(--background))] focus-visible:border-[color:var(--ring)] focus-visible:ring-0 focus-visible:shadow-[var(--aui-shadow-xs,0_1px_2px_rgba(15,23,42,0.04)),0_0_0_1px_var(--aui-focus-ring,var(--ring)),0_0_0_4px_var(--aui-focus-ring-soft,transparent)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:border-[color:color-mix(in_oklch,var(--border),transparent_18%)] disabled:bg-[color:var(--aui-control-surface-disabled,var(--muted))] disabled:text-muted-foreground disabled:shadow-none disabled:opacity-100 read-only:bg-[color:var(--aui-control-surface-readonly,var(--muted))]",
17
17
  className
18
18
  )}
19
19
  {...props}
@@ -34,6 +34,7 @@ export const documentedPublicComponentSurfaces: readonly PublicComponentSurfaceE
34
34
  { slug: "code-block", registryName: "code-block", surface: "documented" },
35
35
  { slug: "data-state", registryName: "data-state", surface: "documented" },
36
36
  { slug: "kanban", registryName: "kanban", surface: "documented" },
37
+ { slug: "chat", registryName: "chat", surface: "documented" },
37
38
  { slug: "list", registryName: "list", surface: "documented" },
38
39
  { slug: "description-list", registryName: "description-list", surface: "documented" },
39
40
  { slug: "statistic", registryName: "statistic", surface: "documented" },
@@ -25,7 +25,7 @@ export function HeroSection() {
25
25
  <div className="flex flex-wrap gap-2">
26
26
  <Button leftIcon={<PlusIcon className="size-4" />}>Create</Button>
27
27
  <Button variant="outline" leftIcon={<SettingsIcon className="size-4" />}>Settings</Button>
28
- <CopyButton value="npx tembro@4.0.1 list --json">Copy list command</CopyButton>
28
+ <CopyButton value="npx tembro@4.1.0 list --json">Copy list command</CopyButton>
29
29
  </div>
30
30
  </div>
31
31
 
@@ -33,7 +33,7 @@ export function HeroSection() {
33
33
  <StatisticCard label="Registry components" value={moduleCount} change="visible below" trend="up" description="tembro add <name>" />
34
34
  <StatisticCard label="Local source files" value="152" change="with hooks/lib" trend="up" description="installed by CLI" />
35
35
  <StatisticCard label="Categories" value={registryGroups.length} change="all shown" trend="up" description="actions to wizard" />
36
- <StatisticCard label="Build" value="Pass" change="doctor pass" trend="up" description="tembro@4.0.1" />
36
+ <StatisticCard label="Build" value="Pass" change="doctor pass" trend="up" description="tembro@4.1.0" />
37
37
  </StatisticGrid>
38
38
  </div>
39
39
  </section>
@@ -72,7 +72,7 @@ export function WorkbenchSidebar({ selectedKey, onSelect }: WorkbenchSidebarProp
72
72
  }),
73
73
  ]}
74
74
  footerAccount={{
75
- label: "tembro@4.0.1",
75
+ label: "tembro@4.1.0",
76
76
  description: `${moduleCount} registry components`,
77
77
  }}
78
78
  activeIndicator="bar"