@withmata/blueprints 0.3.5 → 0.5.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 (62) hide show
  1. package/.claude/skills/audit/SKILL.md +4 -4
  2. package/.claude/skills/blueprint-catalog/SKILL.md +17 -7
  3. package/.claude/skills/copywrite/SKILL.md +187 -0
  4. package/.claude/skills/copywrite-landing/SKILL.md +489 -0
  5. package/.claude/skills/design-system/SKILL.md +970 -0
  6. package/.claude/skills/new-project/SKILL.md +168 -112
  7. package/.claude/skills/scaffold-auth/SKILL.md +9 -9
  8. package/.claude/skills/scaffold-db/SKILL.md +14 -14
  9. package/.claude/skills/scaffold-env/SKILL.md +4 -4
  10. package/.claude/skills/scaffold-foundation/SKILL.md +15 -15
  11. package/.claude/skills/scaffold-tailwind/SKILL.md +17 -3
  12. package/.claude/skills/scaffold-ui/SKILL.md +155 -36
  13. package/ENGINEERING.md +2 -2
  14. package/blueprints/discovery/design-system/BLUEPRINT.md +1479 -0
  15. package/blueprints/discovery/marketing-copywriting/BLUEPRINT.md +664 -0
  16. package/blueprints/features/auth-better-auth/BLUEPRINT.md +20 -22
  17. package/blueprints/features/db-drizzle-postgres/BLUEPRINT.md +12 -12
  18. package/blueprints/features/db-drizzle-postgres/files/db/src/example-entity.ts +1 -1
  19. package/blueprints/features/db-drizzle-postgres/files/db/src/scripts/seed.ts +1 -1
  20. package/blueprints/features/env-t3/BLUEPRINT.md +1 -1
  21. package/blueprints/features/tailwind-v4/BLUEPRINT.md +9 -2
  22. package/blueprints/features/tailwind-v4/files/tailwind-config/shared-styles.css +80 -1
  23. package/blueprints/features/ui-shared-components/BLUEPRINT.md +411 -78
  24. package/blueprints/features/ui-shared-components/files/ui/components/ui/alert-dialog.tsx +192 -0
  25. package/blueprints/features/ui-shared-components/files/ui/components/ui/avatar.tsx +71 -0
  26. package/blueprints/features/ui-shared-components/files/ui/components/ui/badge.tsx +52 -0
  27. package/blueprints/features/ui-shared-components/files/ui/components/ui/breadcrumb.tsx +122 -0
  28. package/blueprints/features/ui-shared-components/files/ui/components/ui/button.tsx +56 -0
  29. package/blueprints/features/ui-shared-components/files/ui/components/ui/card-select.tsx +72 -0
  30. package/blueprints/features/ui-shared-components/files/ui/components/ui/card.tsx +100 -0
  31. package/blueprints/features/ui-shared-components/files/ui/components/ui/collapsible.tsx +34 -0
  32. package/blueprints/features/ui-shared-components/files/ui/components/ui/combobox.tsx +301 -0
  33. package/blueprints/features/ui-shared-components/files/ui/components/ui/dropdown-menu.tsx +264 -0
  34. package/blueprints/features/ui-shared-components/files/ui/components/ui/empty-state.tsx +43 -0
  35. package/blueprints/features/ui-shared-components/files/ui/components/ui/entity-select.tsx +110 -0
  36. package/blueprints/features/ui-shared-components/files/ui/components/ui/field.tsx +237 -0
  37. package/blueprints/features/ui-shared-components/files/ui/components/ui/form-field.tsx +217 -0
  38. package/blueprints/features/ui-shared-components/files/ui/components/ui/input-group.tsx +161 -0
  39. package/blueprints/features/ui-shared-components/files/ui/components/ui/input.tsx +20 -0
  40. package/blueprints/features/ui-shared-components/files/ui/components/ui/label.tsx +20 -0
  41. package/blueprints/features/ui-shared-components/files/ui/components/ui/org-switcher.tsx +114 -0
  42. package/blueprints/features/ui-shared-components/files/ui/components/ui/page-header.tsx +45 -0
  43. package/blueprints/features/ui-shared-components/files/ui/components/ui/pagination.tsx +52 -0
  44. package/blueprints/features/ui-shared-components/files/ui/components/ui/pill-select.tsx +151 -0
  45. package/blueprints/features/ui-shared-components/files/ui/components/ui/popover.tsx +41 -0
  46. package/blueprints/features/ui-shared-components/files/ui/components/ui/search-input.tsx +49 -0
  47. package/blueprints/features/ui-shared-components/files/ui/components/ui/select.tsx +205 -0
  48. package/blueprints/features/ui-shared-components/files/ui/components/ui/selected-entity-card.tsx +47 -0
  49. package/blueprints/features/ui-shared-components/files/ui/components/ui/separator.tsx +25 -0
  50. package/blueprints/features/ui-shared-components/files/ui/components/ui/sidebar.tsx +389 -0
  51. package/blueprints/features/ui-shared-components/files/ui/components/ui/status-filter.tsx +43 -0
  52. package/blueprints/features/ui-shared-components/files/ui/components/ui/tag-input.tsx +131 -0
  53. package/blueprints/features/ui-shared-components/files/ui/components/ui/textarea.tsx +18 -0
  54. package/blueprints/features/ui-shared-components/files/ui/components/ui/user-menu.tsx +149 -0
  55. package/blueprints/features/ui-shared-components/files/ui/components.json +11 -8
  56. package/blueprints/features/ui-shared-components/files/ui/package.json +20 -11
  57. package/blueprints/foundation/monorepo-turbo/BLUEPRINT.md +19 -20
  58. package/blueprints/foundation/monorepo-turbo/files/root/package.json +1 -1
  59. package/dist/index.js +27 -10
  60. package/package.json +1 -1
  61. package/blueprints/features/tailwind-v4/files/tailwind-config/package.json +0 -20
  62. package/blueprints/foundation/monorepo-turbo/files/root/pnpm-workspace.yaml +0 -5
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import {
4
+ Combobox,
5
+ ComboboxContent,
6
+ ComboboxInput,
7
+ ComboboxItem,
8
+ ComboboxList,
9
+ ComboboxSeparator,
10
+ } from "#components/ui/combobox";
11
+ import { PlusIcon } from "@phosphor-icons/react/Plus";
12
+ import { type ReactNode, useState } from "react";
13
+
14
+ export interface EntitySelectProps<T> {
15
+ value: string;
16
+ onValueChange: (value: string) => void;
17
+ options: T[];
18
+ getOptionId: (option: T) => string;
19
+ getOptionName: (option: T) => string;
20
+ renderOption: (option: T) => ReactNode;
21
+ renderSelected: (option: T, onRemove: () => void) => ReactNode;
22
+ placeholder?: string;
23
+ disabled?: boolean;
24
+ isLoading?: boolean;
25
+ createLabel?: string;
26
+ onCreateNew?: () => void;
27
+ }
28
+
29
+ export function EntitySelect<T>({
30
+ value,
31
+ onValueChange,
32
+ options,
33
+ getOptionId,
34
+ getOptionName,
35
+ renderOption,
36
+ renderSelected,
37
+ placeholder = "Select...",
38
+ disabled = false,
39
+ isLoading = false,
40
+ createLabel = "Create new",
41
+ onCreateNew,
42
+ }: EntitySelectProps<T>) {
43
+ const [inputValue, setInputValue] = useState("");
44
+
45
+ const selectedOption = options.find((o) => getOptionId(o) === value);
46
+
47
+ // If a value is selected, show the selected card instead of the combobox
48
+ if (selectedOption) {
49
+ return renderSelected(selectedOption, () => {
50
+ onValueChange("");
51
+ setInputValue("");
52
+ });
53
+ }
54
+
55
+ const filteredOptions = inputValue
56
+ ? options.filter((o) =>
57
+ getOptionName(o).toLowerCase().includes(inputValue.toLowerCase()),
58
+ )
59
+ : options;
60
+
61
+ return (
62
+ <Combobox
63
+ value={value}
64
+ onValueChange={(val) => {
65
+ if (val === "__create_new__") {
66
+ onCreateNew?.();
67
+ return;
68
+ }
69
+ onValueChange(val as string);
70
+ setInputValue("");
71
+ }}
72
+ >
73
+ <ComboboxInput
74
+ placeholder={placeholder}
75
+ disabled={disabled}
76
+ value={inputValue}
77
+ onChange={(e) => setInputValue(e.target.value)}
78
+ />
79
+ <ComboboxContent>
80
+ <ComboboxList>
81
+ {isLoading && filteredOptions.length === 0 && (
82
+ <p className="text-muted-foreground flex items-center justify-center gap-2 py-2 text-center text-sm">
83
+ <span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
84
+ Loading...
85
+ </p>
86
+ )}
87
+ {!isLoading && filteredOptions.length === 0 && options.length > 0 && (
88
+ <p className="text-muted-foreground py-2 text-center text-sm">
89
+ No results found
90
+ </p>
91
+ )}
92
+ {filteredOptions.map((option) => (
93
+ <ComboboxItem key={getOptionId(option)} value={getOptionId(option)}>
94
+ {renderOption(option)}
95
+ </ComboboxItem>
96
+ ))}
97
+ {onCreateNew && (
98
+ <>
99
+ {(isLoading || options.length > 0) && <ComboboxSeparator />}
100
+ <ComboboxItem value="__create_new__" className="py-2.5">
101
+ <PlusIcon className="size-4" />
102
+ {createLabel}
103
+ </ComboboxItem>
104
+ </>
105
+ )}
106
+ </ComboboxList>
107
+ </ComboboxContent>
108
+ </Combobox>
109
+ );
110
+ }
@@ -0,0 +1,237 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { useMemo } from "react";
5
+ import { Label } from "#components/ui/label";
6
+ import { Separator } from "#components/ui/separator";
7
+ import { cn } from "#utils/cn";
8
+
9
+ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
10
+ return (
11
+ <fieldset
12
+ data-slot="field-set"
13
+ className={cn(
14
+ "gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
15
+ className,
16
+ )}
17
+ {...props}
18
+ />
19
+ );
20
+ }
21
+
22
+ function FieldLegend({
23
+ className,
24
+ variant = "legend",
25
+ ...props
26
+ }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
27
+ return (
28
+ <legend
29
+ data-slot="field-legend"
30
+ data-variant={variant}
31
+ className={cn(
32
+ "mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
33
+ className,
34
+ )}
35
+ {...props}
36
+ />
37
+ );
38
+ }
39
+
40
+ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
41
+ return (
42
+ <div
43
+ data-slot="field-group"
44
+ className={cn(
45
+ "gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4 group/field-group @container/field-group flex w-full flex-col",
46
+ className,
47
+ )}
48
+ {...props}
49
+ />
50
+ );
51
+ }
52
+
53
+ const fieldVariants = cva(
54
+ "data-[invalid=true]:text-destructive gap-1.5 group/field flex w-full",
55
+ {
56
+ variants: {
57
+ orientation: {
58
+ vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto",
59
+ horizontal:
60
+ "flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
61
+ responsive:
62
+ "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
63
+ },
64
+ },
65
+ defaultVariants: {
66
+ orientation: "vertical",
67
+ },
68
+ },
69
+ );
70
+
71
+ function Field({
72
+ className,
73
+ orientation = "vertical",
74
+ ...props
75
+ }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
76
+ return (
77
+ <div
78
+ role="group"
79
+ data-slot="field"
80
+ data-orientation={orientation}
81
+ className={cn(fieldVariants({ orientation }), className)}
82
+ {...props}
83
+ />
84
+ );
85
+ }
86
+
87
+ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
88
+ return (
89
+ <div
90
+ data-slot="field-content"
91
+ className={cn(
92
+ "gap-1 group/field-content flex flex-1 flex-col leading-snug",
93
+ className,
94
+ )}
95
+ {...props}
96
+ />
97
+ );
98
+ }
99
+
100
+ function FieldLabel({
101
+ className,
102
+ ...props
103
+ }: React.ComponentProps<typeof Label>) {
104
+ return (
105
+ <Label
106
+ data-slot="field-label"
107
+ className={cn(
108
+ "has-data-checked:bg-primary/5 has-data-checked:border-primary/50 dark:has-data-checked:bg-primary/10 gap-0.5 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
109
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
110
+ className,
111
+ )}
112
+ {...props}
113
+ />
114
+ );
115
+ }
116
+
117
+ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
118
+ return (
119
+ <div
120
+ data-slot="field-label"
121
+ className={cn(
122
+ "gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
123
+ className,
124
+ )}
125
+ {...props}
126
+ />
127
+ );
128
+ }
129
+
130
+ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
131
+ return (
132
+ <p
133
+ data-slot="field-description"
134
+ className={cn(
135
+ "text-muted-foreground text-left text-xs [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
136
+ "last:mt-0 nth-last-2:-mt-1",
137
+ "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
138
+ className,
139
+ )}
140
+ {...props}
141
+ />
142
+ );
143
+ }
144
+
145
+ function FieldSeparator({
146
+ children,
147
+ className,
148
+ ...props
149
+ }: React.ComponentProps<"div"> & {
150
+ children?: React.ReactNode;
151
+ }) {
152
+ return (
153
+ <div
154
+ data-slot="field-separator"
155
+ data-content={!!children}
156
+ className={cn(
157
+ "-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative",
158
+ className,
159
+ )}
160
+ {...props}
161
+ >
162
+ <Separator className="absolute inset-0 top-1/2" />
163
+ {children && (
164
+ <span
165
+ className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
166
+ data-slot="field-separator-content"
167
+ >
168
+ {children}
169
+ </span>
170
+ )}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ function FieldError({
176
+ className,
177
+ children,
178
+ errors,
179
+ ...props
180
+ }: React.ComponentProps<"div"> & {
181
+ errors?: Array<{ message?: string } | undefined>;
182
+ }) {
183
+ const content = useMemo(() => {
184
+ if (children) {
185
+ return children;
186
+ }
187
+
188
+ if (!errors?.length) {
189
+ return null;
190
+ }
191
+
192
+ const uniqueErrors = [
193
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
194
+ ];
195
+
196
+ if (uniqueErrors?.length === 1) {
197
+ return uniqueErrors[0]?.message;
198
+ }
199
+
200
+ return (
201
+ <ul className="ml-4 flex list-disc flex-col gap-1">
202
+ {uniqueErrors.map(
203
+ (error, index) =>
204
+ error?.message && <li key={index}>{error.message}</li>,
205
+ )}
206
+ </ul>
207
+ );
208
+ }, [children, errors]);
209
+
210
+ if (!content) {
211
+ return null;
212
+ }
213
+
214
+ return (
215
+ <div
216
+ role="alert"
217
+ data-slot="field-error"
218
+ className={cn("text-destructive text-sm font-normal", className)}
219
+ {...props}
220
+ >
221
+ {content}
222
+ </div>
223
+ );
224
+ }
225
+
226
+ export {
227
+ Field,
228
+ FieldLabel,
229
+ FieldDescription,
230
+ FieldError,
231
+ FieldGroup,
232
+ FieldLegend,
233
+ FieldSeparator,
234
+ FieldSet,
235
+ FieldContent,
236
+ FieldTitle,
237
+ };
@@ -0,0 +1,217 @@
1
+ "use client";
2
+
3
+ import { Info, X } from "@phosphor-icons/react";
4
+ import { useEffect, useRef } from "react";
5
+ import type * as React from "react";
6
+ import { Field, FieldDescription, FieldLabel } from "#components/ui/field";
7
+ import {
8
+ Popover,
9
+ PopoverAnchor,
10
+ PopoverClose,
11
+ PopoverContent,
12
+ } from "#components/ui/popover";
13
+
14
+ export interface FieldHint {
15
+ directive: string;
16
+ example: string;
17
+ label?: string;
18
+ }
19
+
20
+ export interface FormFieldProps {
21
+ label: string;
22
+ name: string;
23
+ description?: string | React.ReactNode;
24
+ counter?: React.ReactNode;
25
+ required?: boolean;
26
+ error?: string;
27
+ onFocus?: (element: HTMLElement) => void;
28
+ hint?: FieldHint;
29
+ isHintOpen?: boolean;
30
+ onHintOpen?: (fieldName: string) => void;
31
+ onHintClose?: () => void;
32
+ showHints?: boolean;
33
+ autoShowOnFocus?: boolean;
34
+ onToggleHints?: () => void;
35
+ children: React.ReactNode;
36
+ }
37
+
38
+ export function FormField({
39
+ label,
40
+ name,
41
+ description,
42
+ counter,
43
+ required = false,
44
+ error,
45
+ onFocus,
46
+ hint,
47
+ isHintOpen = false,
48
+ onHintOpen,
49
+ onHintClose,
50
+ showHints = true,
51
+ autoShowOnFocus = true,
52
+ onToggleHints,
53
+ children,
54
+ }: FormFieldProps) {
55
+ const anchorRef = useRef<HTMLDivElement>(null);
56
+
57
+ // Hide popover when anchor scrolls out of its scroll container
58
+ useEffect(() => {
59
+ if (!isHintOpen || !hint || !anchorRef.current) return;
60
+
61
+ const el = anchorRef.current;
62
+ // Find the nearest scrollable ancestor
63
+ const scrollParent = el.closest("[data-slot='scroll-area'], .overflow-y-auto, [style*='overflow']") || el.parentElement;
64
+ if (!scrollParent) return;
65
+
66
+ const observer = new IntersectionObserver(
67
+ ([entry]) => {
68
+ if (!entry?.isIntersecting) {
69
+ onHintClose?.();
70
+ }
71
+ },
72
+ { root: scrollParent, threshold: 0.1 },
73
+ );
74
+
75
+ observer.observe(el);
76
+ return () => observer.disconnect();
77
+ }, [isHintOpen, hint, onHintClose]);
78
+
79
+ const handleFocusCapture = (e: React.FocusEvent<HTMLDivElement>) => {
80
+ if (onFocus && e.currentTarget) {
81
+ onFocus(e.currentTarget);
82
+ }
83
+
84
+ if (hint && showHints && autoShowOnFocus && onHintOpen) {
85
+ onHintOpen(name);
86
+ }
87
+ };
88
+
89
+ const handleInfoClick = () => {
90
+ if (isHintOpen) {
91
+ onHintClose?.();
92
+ } else {
93
+ onHintOpen?.(name);
94
+ }
95
+ };
96
+
97
+ // The directive from the hint replaces the description when available
98
+ const displayDescription = hint ? hint.directive : description;
99
+
100
+ return (
101
+ <Field className="!gap-0">
102
+ {/* Label + description group — tight spacing */}
103
+ <div className="space-y-1.5">
104
+ <div className="flex items-center gap-1.5">
105
+ <FieldLabel htmlFor={name} className="mb-0 text-sm">
106
+ {label}
107
+ {required && <span className="text-destructive">*</span>}
108
+ </FieldLabel>
109
+ {hint && (
110
+ <button
111
+ type="button"
112
+ className="text-muted-foreground hover:text-foreground transition-colors"
113
+ onClick={handleInfoClick}
114
+ aria-label={`Show hint for ${label}`}
115
+ >
116
+ <Info className="size-3.5" />
117
+ </button>
118
+ )}
119
+ </div>
120
+ {(displayDescription || counter) && (
121
+ <div className="flex items-center justify-between gap-2">
122
+ {displayDescription && (
123
+ <FieldDescription>{displayDescription}</FieldDescription>
124
+ )}
125
+ {counter && (
126
+ <span className="text-xs text-muted-foreground shrink-0">
127
+ {counter}
128
+ </span>
129
+ )}
130
+ </div>
131
+ )}
132
+ </div>
133
+
134
+ {/* Field input — more space above, tight to error below */}
135
+ <div className="mt-3">
136
+ <Popover
137
+ open={isHintOpen && !!hint}
138
+ onOpenChange={(open) => {
139
+ if (!open) onHintClose?.();
140
+ }}
141
+ >
142
+ <PopoverAnchor asChild>
143
+ <div ref={anchorRef} onFocusCapture={handleFocusCapture}>
144
+ {children}
145
+ </div>
146
+ </PopoverAnchor>
147
+ <PopoverContent
148
+ side="right"
149
+ align="start"
150
+ sideOffset={20}
151
+ collisionPadding={16}
152
+ className="w-80 p-0 overflow-hidden"
153
+ onOpenAutoFocus={(e) => e.preventDefault()}
154
+ onCloseAutoFocus={(e) => e.preventDefault()}
155
+ onFocusOutside={(e) => e.preventDefault()}
156
+ hideWhenDetached
157
+ >
158
+ {/* Arrow */}
159
+ <div className="absolute left-0 top-4 -translate-x-full">
160
+ <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-border" />
161
+ <div className="w-0 h-0 border-t-[7px] border-t-transparent border-b-[7px] border-b-transparent border-r-[7px] border-r-popover absolute top-[1px] left-[1px]" />
162
+ </div>
163
+
164
+ {/* Header: info icon + "Info" label + close */}
165
+ <div className="px-3 pt-2.5 pb-2 flex items-center gap-1.5">
166
+ <div className="flex items-center gap-1.5">
167
+ <Info className="size-4 text-primary shrink-0" />
168
+ <span className="text-xs text-primary leading-none translate-y-px">
169
+ Info
170
+ </span>
171
+ </div>
172
+ <div className="flex-1" />
173
+ <PopoverClose asChild>
174
+ <button
175
+ type="button"
176
+ className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
177
+ aria-label="Close hint"
178
+ >
179
+ <X className="size-3.5" weight="bold" />
180
+ </button>
181
+ </PopoverClose>
182
+ </div>
183
+
184
+ {/* Example content */}
185
+ <div className="bg-muted px-3 py-2.5 border-t border-border/50">
186
+ <p className="text-[11px] font-medium text-muted-foreground mb-1">
187
+ {hint?.label ?? "Good example"}
188
+ </p>
189
+ <p className="text-sm leading-relaxed">{hint?.example}</p>
190
+ </div>
191
+
192
+ {/* Footer: toggle hints */}
193
+ {onToggleHints && (
194
+ <div className="px-3 pt-1.5 pb-2.5 border-t border-border/50">
195
+ <button
196
+ type="button"
197
+ className="text-[11px] text-muted-foreground hover:text-foreground hover:underline transition-colors"
198
+ onClick={() => {
199
+ if (showHints) {
200
+ onHintClose?.();
201
+ }
202
+ onToggleHints();
203
+ }}
204
+ >
205
+ {showHints
206
+ ? "Don\u0027t show hints"
207
+ : "Always show hints"}
208
+ </button>
209
+ </div>
210
+ )}
211
+ </PopoverContent>
212
+ </Popover>
213
+ </div>
214
+ {error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
215
+ </Field>
216
+ );
217
+ }