diff --git a/.claude/skills/click-ui.md b/.claude/skills/click-ui.md new file mode 100644 index 0000000..3073c60 --- /dev/null +++ b/.claude/skills/click-ui.md @@ -0,0 +1,741 @@ +--- +name: click-ui +description: Build interfaces using the Click UI design system (@clickhouse/click-ui). Use this skill when working with ClickHouse frontend projects, React components using Click UI, or when the user mentions Click UI components like Button, TextField, Dialog, Table, etc. +globs: ['**/*.tsx', '**/*.jsx', '**/*.ts'] +version: 0.1.0 +alwaysApply: false +--- + +# Click UI Design System - AI Assistant Skill + +## Overview + +Click UI is the official design system and component library for ClickHouse, built with React, TypeScript, and styled-components. This skill provides guidance for effectively using Click UI in development workflows. + +**Official Documentation**: https://clickhouse.design/click-ui +**GitHub Repository**: https://github.com/ClickHouse/click-ui +**NPM Package**: `@clickhouse/click-ui` + +## Critical First Steps + +**ESSENTIAL AI-SPECIFIC RESOURCE**: https://clickhouse.design/click-ui/ai-quick-reference +This page is specifically designed for AI assistants and contains: + +- Complete component props reference +- All 165 icon names and 58 logo names +- Common error patterns to avoid +- State management patterns +- Validation examples +- Type definitions + +**ALWAYS consult the official documentation** at https://clickhouse.design/click-ui when: + +- Learning about a specific component's API +- Understanding current best practices +- Checking for updates or changes +- Reviewing component examples +- Exploring design patterns + +The documentation is the source of truth and may contain updates beyond this skill's knowledge. + +## Installation & Setup + +### Basic Installation + +```bash +npm i @clickhouse/click-ui +# or +yarn add @clickhouse/click-ui +``` + +### Essential Setup Pattern + +Every Click UI application MUST be wrapped in `ClickUIProvider`: + +```typescript +import { ClickUIProvider } from '@clickhouse/click-ui' + +function App() { + return ( + + {/* Your app components */} + + ) +} +``` + +**Note**: Click UI uses styled-components for styling, so no CSS import is required. The styles are injected automatically via JavaScript. + +## Core Architecture Concepts + +### 1. Provider Configuration + +The `ClickUIProvider` accepts two main props: + +```typescript + +``` + +### 2. Theming System + +Click UI uses a robust design token system: + +- **Dark mode**: Default theme, optimized for data visualization +- **Light mode**: Alternative theme +- **Theme switching**: Runtime theme changes supported +- **Design tokens**: Documented at https://clickhouse.design/click-ui/tokens + +### 3. Accessibility First + +All Click UI components are built with accessibility in mind: + +- ARIA labels and roles +- Keyboard navigation +- Screen reader support +- Reference: https://clickhouse.design/click-ui/accessibility + +## Component Categories + +Click UI components organized by category: + +| Category | Components | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Layout** | Container, Grid, GridContainer, Panel | +| **Forms** | TextField, TextArea, NumberField, PasswordField, SearchField, Checkbox, RadioGroup, Switch, Select, AutoComplete, DatePicker, DateRangePicker, FileUpload, Label | +| **Display** | Text, Title, Badge, Avatar, BigStat, CodeBlock, ProgressBar, Icon, Logo, Link | +| **Navigation** | Tabs, FileTabs, FullWidthTabs, Accordion, MultiAccordion, VerticalStepper, Pagination | +| **Sidebar** | SidebarNavigationItem, SidebarNavigationTitle, SidebarCollapsibleItem, SidebarCollapsibleTitle | +| **Overlays** | Dialog, Flyout, Popover, Tooltip, HoverCard, ConfirmationDialog | +| **Actions** | Button, ButtonGroup, IconButton, Dropdown, ContextMenu | +| **Feedback** | Alert, Toast, ToastProvider | +| **Data** | Table, Cards, DateDetails | +| **Utilities** | Spacer, Separator | + +### Sidebar Navigation Components + +Click UI provides dedicated sidebar navigation components: + +```typescript +import { SidebarNavigationItem } from '@clickhouse/click-ui' + + setActive('sql')} +/> +``` + +These are preferred over custom styled buttons for navigation menus. + +## Common Usage Patterns + +### Pattern 1: Theme Switching + +```typescript +import { ClickUIProvider, Switch, ThemeName } from '@clickhouse/click-ui' +import { useState } from 'react' + +function App() { + const [theme, setTheme] = useState('dark') + + const toggleTheme = () => { + setTheme(theme === 'dark' ? 'light' : 'dark') + } + + return ( + + + {/* Rest of app */} + + ) +} +``` + +### Pattern 2: Form Components + +```typescript +import { TextField, NumberField, Select, Button, Container } from '@clickhouse/click-ui' +import { useState } from 'react' + +function Form() { + const [username, setUsername] = useState('') + const [age, setAge] = useState('') + const [country, setCountry] = useState('') + + return ( + + + + + Option 1 + Option 2 + +``` + +### When Extending is Necessary + +If you must extend, use styled-components to add theme-aware styles: + +```typescript +import { Container } from '@clickhouse/click-ui' +import styled from 'styled-components' + +// CORRECT - Extend with theme tokens +const SidebarContainer = styled(Container)` + background: ${({ theme }) => theme.global.color.background.default}; + border-right: 1px solid ${({ theme }) => theme.global.color.stroke.default}; +` + +// If flex properties aren't working, you may need to add them explicitly +const FlexContainer = styled(Container)` + display: flex; + flex-direction: column; +` +``` + +### Theme Token Reference + +When extending components, use these theme tokens: + +- `theme.global.color.background.default` - Main background +- `theme.global.color.background.muted` - Muted/secondary background +- `theme.global.color.stroke.default` - Borders +- `theme.click.sidebar.main.color.background.default` - Sidebar background + +## Component Selection Guide + +### For Dropdowns/Selectors + +| Use Case | Component | +| --------------------- | ----------------------------------------------------------------------- | +| Service/item selector | `Select` with `Select.Item` | +| Database picker | `Select` with icon prop | +| Organization switcher | `Select` | +| User menu | `Dropdown` with `Dropdown.Trigger`, `Dropdown.Content`, `Dropdown.Item` | +| Context actions | `ContextMenu` | + +```typescript +// Service selector pattern + + +// User menu pattern + + + + + + Profile + + Dark theme + + + +``` + +### For Promotional/Alert Banners + +| Use Case | Component | +| ----------------------- | -------------------------------- | +| Trial expiration notice | `CardPromotion` | +| Feature announcement | `CardPromotion` | +| Warning banner | `Alert` | +| Info callout | `Panel` with appropriate styling | + +```typescript +// Promotional banner + +``` + +### For Navigation + +| Use Case | Component | +| ------------------------- | --------------------------------------------------- | +| Main nav items | `SidebarNavigationItem` with `type="main"` | +| Sub nav items | `SidebarNavigationItem` | +| Action buttons in sidebar | `Button` with `type="secondary"` and `align="left"` | +| Tabs | `Tabs` with `Tabs.TriggersList` and `Tabs.Trigger` | + +```typescript +// Navigation items + setActiveNav('sql')} +/> + +// Action buttons (like Connect, Ask AI) + + /> {groups.length === 0 ? ( diff --git a/src/components/access/RolesTab.tsx b/src/components/access/RolesTab.tsx index 2e4ed5c..9f7642d 100644 --- a/src/components/access/RolesTab.tsx +++ b/src/components/access/RolesTab.tsx @@ -77,6 +77,7 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { + /> {filtered.length === 0 ? ( diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 2bad3af..13345ec 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -20,6 +20,7 @@ import { flattenObject, unflattenObject, serializeKVPairs, + deepSerializeKVPairs, cn, normalizeImportConfig, hasConfigCapability, @@ -62,6 +63,7 @@ const profileMapOptions = (fieldPaths: string[]) => (r: { profileMap: Record }) => r.profileMap, ), enabled: fieldPaths.length > 0, + staleTime: 60_000, }); function resolvedConfigOptions(scope: t.ScopeSelection) { @@ -77,6 +79,7 @@ function resolvedConfigOptions(scope: t.ScopeSelection) { }, }), enabled: principalType != null && principalId != null, + staleTime: 60_000, }); } @@ -267,7 +270,32 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi return unflattenObject(scopeResolvedValues) as Record; }, [isEditingScope, scopeResolvedValues]); - const activeConfigValues = isEditingScope ? scopeConfigValues : configValues; + const baseActiveConfigValues = isEditingScope ? scopeConfigValues : configValues; + + const activeConfigValues = useMemo(() => { + if (!baseActiveConfigValues) return baseActiveConfigValues; + const indexedEdits = Object.entries(editedValues).filter(([k]) => /\.\d+$/.test(k)); + if (indexedEdits.length === 0) return baseActiveConfigValues; + const merged = { ...baseActiveConfigValues }; + for (const [path, value] of indexedEdits) { + const segments = path.split('.'); + const index = Number(segments.pop()!); + const arrayPath = segments; + let parent: Record = merged; + for (let i = 0; i < arrayPath.length - 1; i++) { + const seg = arrayPath[i]; + if (parent[seg] && typeof parent[seg] === 'object' && !Array.isArray(parent[seg])) { + parent[seg] = { ...(parent[seg] as Record) }; + parent = parent[seg] as Record; + } else break; + } + const lastSeg = arrayPath[arrayPath.length - 1]; + const arr = Array.isArray(parent[lastSeg]) ? [...(parent[lastSeg] as t.ConfigValue[])] : []; + arr[index] = value; + parent[lastSeg] = arr; + } + return merged; + }, [baseActiveConfigValues, editedValues]); const scopeConfiguredPaths = useMemo(() => { if (!scopeChangedPaths) return new Set(); @@ -354,7 +382,16 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi delete next[path]; return next; } - return { ...prev, [path]: value }; + const next = { ...prev, [path]: value }; + if (Array.isArray(value)) { + const prefix = `${path}.`; + for (const k of Object.keys(next)) { + if (k.startsWith(prefix) && /\.\d+$/.test(k)) delete next[k]; + } + } + const indexMatch = /^(.+)\.\d+$/.exec(path); + if (indexMatch) delete next[indexMatch[1]]; + return next; }); }); }, @@ -435,7 +472,10 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const saves = touched .filter((p) => editedValues[p] !== undefined) - .map((p) => ({ fieldPath: p, value: serializeKVPairs(editedValues[p]) })); + .map((p) => ({ + fieldPath: p, + value: /\.\d+$/.test(p) ? deepSerializeKVPairs(editedValues[p]) : serializeKVPairs(editedValues[p]), + })); const resets = touched.filter((p) => editedValues[p] === undefined); setSaving(true); @@ -504,11 +544,29 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const serializedEditedValues = useMemo(() => { const result: t.FlatConfigMap = {}; for (const [k, v] of Object.entries(editedValues)) { - result[k] = serializeKVPairs(v); + result[k] = /\.\d+$/.test(k) ? deepSerializeKVPairs(v) : serializeKVPairs(v); } return result; }, [editedValues]); + const originalValuesForDialog = useMemo(() => { + const baseline = isEditingScope ? scopeBaseline : flatBaseline; + const result: t.FlatConfigMap = { ...baseline }; + for (const path of Object.keys(editedValues)) { + if (path in result) continue; + const segments = path.split('.'); + let current: t.ConfigValue = configValues; + for (const seg of segments) { + if (current == null || typeof current !== 'object') { current = undefined; break; } + current = Array.isArray(current) + ? (current as t.ConfigValue[])[Number(seg)] + : (current as Record)[seg]; + } + if (current !== undefined) result[path] = current; + } + return result; + }, [editedValues, flatBaseline, isEditingScope, scopeBaseline, configValues]); + const [importSuccessMessage, setImportSuccessMessage] = useState(null); const showImportSuccess = useCallback((message?: string) => { @@ -844,7 +902,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi ( diff --git a/src/components/configuration/ConfigTableOfContents.tsx b/src/components/configuration/ConfigTableOfContents.tsx index 4f6ca4c..2381336 100644 --- a/src/components/configuration/ConfigTableOfContents.tsx +++ b/src/components/configuration/ConfigTableOfContents.tsx @@ -98,14 +98,30 @@ export const ConfigTableOfContents = memo(function ConfigTableOfContents({ return true; }; - const cardExpanded = existing ? expandCard(existing) : false; + // Expand collapsed Radix accordion items (MultiAccordion.Item). + const expandAccordionItem = (el: HTMLElement): boolean => { + if (el.getAttribute('data-state') !== 'closed') return false; + const trigger = el.querySelector(':scope > button[data-state="closed"]'); + if (trigger) { + trigger.click(); + return true; + } + return false; + }; + + const cardExpanded = existing + ? expandCard(existing) || expandAccordionItem(existing) + : false; const needsSettle = parentExpanded || needsExpand || cardExpanded; if (needsSettle) { scrollTimerRef.current = setTimeout(() => { expandAncestors(id); const el = document.getElementById(id) ?? existing; - if (el) expandCard(el); + if (el) { + expandCard(el); + expandAccordionItem(el); + } setTimeout(() => { const target = document.getElementById(id) ?? existing; if (target) scrollToEl(target); diff --git a/src/components/configuration/ConfirmSaveDialog.tsx b/src/components/configuration/ConfirmSaveDialog.tsx index 6417748..6946b96 100644 --- a/src/components/configuration/ConfirmSaveDialog.tsx +++ b/src/components/configuration/ConfirmSaveDialog.tsx @@ -66,6 +66,16 @@ export function ConfirmSaveDialog({ ); } +function resolvePathLabel(path: string, newValue: t.ConfigValue, oldValue: t.ConfigValue): string { + const match = /^(.+)\.(\d+)$/.exec(path); + if (!match) return path; + const val = (newValue ?? oldValue) as Record | undefined; + const name = val && typeof val === 'object' && !Array.isArray(val) + ? (val as Record).name + : undefined; + return name ? `${match[1]}[${match[2]}] (${name})` : `${match[1]}[${match[2]}]`; +} + function ChangeCard({ path, oldValue, @@ -79,12 +89,13 @@ function ChangeCard({ const notSet = localize('com_config_field_not_set'); const isRemoval = newValue === undefined || newValue === null; const isAddition = oldValue === undefined || oldValue === null; + const displayPath = resolvePathLabel(path, newValue, oldValue); return (
- {path} + {displayPath} {isAddition && ( diff --git a/src/components/configuration/FieldRenderer.tsx b/src/components/configuration/FieldRenderer.tsx index e93ebab..45dbcf0 100644 --- a/src/components/configuration/FieldRenderer.tsx +++ b/src/components/configuration/FieldRenderer.tsx @@ -1,5 +1,5 @@ import { Icon } from '@clickhouse/click-ui'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import type { ReactNode } from 'react'; import type * as t from '@/types'; import { @@ -10,7 +10,7 @@ import { getControlType, getEnumOptions, hasDescendant, - inferKVType, + toKVPair, isStringLikeItemType, splitUnionTypes, } from './utils'; @@ -66,12 +66,18 @@ function ArrayObjectNestedGroup({ const addTriggerRef = useRef<(() => void) | null>(null); const handleAdd = disabled ? undefined : () => addTriggerRef.current?.(); + const handleEntryChange = useCallback( + (index: number, value: t.ConfigValue) => onChange(`${path}.${index}`, value), + [onChange, path], + ); + const arrayField = ( onChange(path, v)} + onEntryChange={handleEntryChange} disabled={disabled} hideAddButton addTriggerRef={addTriggerRef} @@ -413,11 +419,7 @@ export function SingleFieldRenderer({ typeof currentValue === 'object' && currentValue !== null ? (currentValue as Record) : {}, - ).map(([k, v]) => ({ - key: k, - value: typeof v === 'string' ? v : JSON.stringify(v ?? ''), - valueType: inferKVType(v), - })); + ).map(([k, v]) => toKVPair(k, v)); return ( onChange(path, newPairs)} disabled={disabled} + valueTypes={field.recordValueKVTypes} aria-label={fieldLabel} /> @@ -1071,16 +1074,13 @@ export function renderInlineField( } if (controlType === 'record') { - const pairs: { key: string; value: string }[] = Array.isArray(fieldValue) - ? (fieldValue as { key: string; value: string }[]) + const pairs: t.KeyValuePair[] = Array.isArray(fieldValue) + ? (fieldValue as t.KeyValuePair[]) : Object.entries( typeof fieldValue === 'object' && fieldValue !== null ? (fieldValue as Record) : {}, - ).map(([k, v]) => ({ - key: k, - value: typeof v === 'string' ? v : JSON.stringify(v), - })); + ).map(([k, v]) => toKVPair(k, v)); return ( onChange(field.key, p)} disabled={disabled} + valueTypes={field.recordValueKVTypes} aria-label={fieldLabel} /> diff --git a/src/components/configuration/ProfileValueModal.tsx b/src/components/configuration/ProfileValueModal.tsx index 0bd4362..560bbf6 100644 --- a/src/components/configuration/ProfileValueModal.tsx +++ b/src/components/configuration/ProfileValueModal.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { PrincipalType } from 'librechat-data-provider'; import { Icon, Button, Dialog } from '@clickhouse/click-ui'; import type * as t from '@/types'; -import { getEnumOptions, getArrayItemType, inferKVType } from './utils'; +import { getEnumOptions, getArrayItemType, toKVPair } from './utils'; import { KeyValueField } from './fields/KeyValueField'; import { TrashButton } from '@/components/shared'; import { getScopeTypeConfig } from '@/constants'; @@ -202,11 +202,7 @@ function ModalValueControl({ typeof value === 'object' && value !== null ? (value as Record) : {}, - ).map(([k, v]) => ({ - key: k, - value: typeof v === 'string' ? v : JSON.stringify(v ?? ''), - valueType: inferKVType(v), - })); + ).map(([k, v]) => toKVPair(k, v)); return ( { + if (onEntryChange) { + onEntryChange(index, newValue); + return; + } const next = [...items]; next[index] = newValue; onChange(next); }, - [items, onChange], + [items, onChange, onEntryChange], ); return ( diff --git a/src/components/configuration/fields/KeyValueField.tsx b/src/components/configuration/fields/KeyValueField.tsx index 118a90b..a27ea35 100644 --- a/src/components/configuration/fields/KeyValueField.tsx +++ b/src/components/configuration/fields/KeyValueField.tsx @@ -1,26 +1,123 @@ import { Select } from '@clickhouse/click-ui'; -import { useRef, useLayoutEffect } from 'react'; +import TextareaAutosize from 'react-textarea-autosize'; +import { useState, useEffect, useRef, useLayoutEffect } from 'react'; import type * as t from '@/types'; import { AddItemButton, TrashButton } from '@/components/shared'; import { useLocalize } from '@/hooks'; -const VALUE_TYPES: t.KVValueType[] = ['string', 'number', 'boolean']; +const DEFAULT_TYPES: t.KVValueType[] = ['string', 'number', 'boolean']; const TYPE_LABELS: Record = { string: 'abc', number: '123', boolean: 'T/F', + json: '{ }', }; +function LocalInput({ + value, + onCommit, + type = 'text', + placeholder, + disabled, + className, + 'aria-label': ariaLabel, +}: { + value: string; + onCommit: (value: string) => void; + type?: string; + placeholder?: string; + disabled?: boolean; + className?: string; + 'aria-label'?: string; +}) { + const [local, setLocal] = useState(value); + const externalRef = useRef(value); + + useEffect(() => { + if (value !== externalRef.current) { + externalRef.current = value; + setLocal(value); + } + }, [value]); + + const commit = () => { + if (local !== externalRef.current) { + externalRef.current = local; + onCommit(local); + } + }; + + return ( + setLocal(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { if (e.key === 'Enter') commit(); }} + placeholder={placeholder} + disabled={disabled} + aria-label={ariaLabel} + className={className} + /> + ); +} + +function LocalTextarea({ + value, + onCommit, + placeholder, + disabled, + 'aria-label': ariaLabel, +}: { + value: string; + onCommit: (value: string) => void; + placeholder?: string; + disabled?: boolean; + 'aria-label'?: string; +}) { + const [local, setLocal] = useState(value); + const externalRef = useRef(value); + + useEffect(() => { + if (value !== externalRef.current) { + externalRef.current = value; + setLocal(value); + } + }, [value]); + + const commit = () => { + if (local !== externalRef.current) { + externalRef.current = local; + onCommit(local); + } + }; + + return ( + setLocal(e.target.value)} + onBlur={commit} + placeholder={placeholder} + disabled={disabled} + aria-label={ariaLabel} + minRows={1} + className="config-input w-full resize-none font-mono text-xs" + /> + ); +} + export function KeyValueField({ id, pairs, onChange, disabled, + valueTypes, keyPlaceholder, valuePlaceholder, 'aria-label': ariaLabel, }: t.KeyValueFieldProps) { const localize = useLocalize(); + const availableTypes = valueTypes ?? DEFAULT_TYPES; const listRef = useRef(null); const focusLastKeyRef = useRef(false); @@ -34,7 +131,7 @@ export function KeyValueField({ }); const handleAdd = () => { - onChange([...pairs, { key: '', value: '', valueType: 'string' }]); + onChange([...pairs, { key: '', value: '', valueType: availableTypes[0] }]); focusLastKeyRef.current = true; }; const handleRemove = (index: number) => onChange(pairs.filter((_, i) => i !== index)); @@ -54,6 +151,109 @@ export function KeyValueField({ onChange(next); }; + const renderPrimitiveRow = (vType: t.KVValueType, pair: t.KeyValuePair, index: number) => { + const valueLabel = `${localize('com_ui_value')} ${index + 1}`; + return ( +
+ handleChange(index, 'key', v)} + placeholder={keyPlaceholder ?? localize('com_ui_key')} + disabled={disabled} + aria-label={`${localize('com_ui_key')} ${index + 1}`} + className="config-input max-w-37.5 flex-1" + /> + {vType === 'boolean' ? ( +
+ +
+ ) : ( + handleChange(index, 'value', v)} + placeholder={valuePlaceholder ?? localize('com_ui_value')} + disabled={disabled} + aria-label={valueLabel} + className="config-input flex-2" + /> + )} + {!disabled && availableTypes.length > 1 && ( +
+ +
+ )} + {!disabled && ( + handleRemove(index)} + ariaLabel={`${localize('com_ui_delete')} ${localize('com_ui_entry')} ${index + 1}`} + /> + )} +
+ ); + }; + + const renderJsonRow = (pair: t.KeyValuePair, index: number) => ( +
+
+ handleChange(index, 'key', v)} + placeholder={keyPlaceholder ?? localize('com_ui_key')} + disabled={disabled} + aria-label={`${localize('com_ui_key')} ${index + 1}`} + className="config-input min-w-0 flex-1" + /> + {!disabled && availableTypes.length > 1 && ( +
+ +
+ )} + {!disabled && ( + handleRemove(index)} + ariaLabel={`${localize('com_ui_delete')} ${localize('com_ui_entry')} ${index + 1}`} + /> + )} +
+ handleChange(index, 'value', v)} + placeholder='{"key": "value"}' + disabled={disabled} + aria-label={`${localize('com_ui_value')} ${index + 1}`} + /> +
+ ); + return (
{pairs.map((pair, index) => { const vType = pair.valueType ?? 'string'; - return ( -
- handleChange(index, 'key', e.target.value)} - placeholder={keyPlaceholder ?? localize('com_ui_key')} - disabled={disabled} - aria-label={`${localize('com_ui_key')} ${index + 1}`} - className="config-input max-w-37.5 flex-1" - /> - {vType === 'boolean' ? ( -
- -
- ) : ( - handleChange(index, 'value', e.target.value)} - placeholder={valuePlaceholder ?? localize('com_ui_value')} - disabled={disabled} - aria-label={`${localize('com_ui_value')} ${index + 1}`} - className="config-input flex-2" - /> - )} - {!disabled && ( -
- -
- )} - {!disabled && ( - handleRemove(index)} - ariaLabel={`${localize('com_ui_delete')} ${localize('com_ui_entry')} ${index + 1}`} - /> - )} -
- ); + return vType === 'json' + ? renderJsonRow(pair, index) + : renderPrimitiveRow(vType, pair, index); })} {!disabled && ( diff --git a/src/components/configuration/sections/EndpointsRenderer.tsx b/src/components/configuration/sections/EndpointsRenderer.tsx index e1f19e0..ec9b764 100644 --- a/src/components/configuration/sections/EndpointsRenderer.tsx +++ b/src/components/configuration/sections/EndpointsRenderer.tsx @@ -632,6 +632,7 @@ export function CustomEndpointsRenderer(props: t.FieldRendererProps) { value={value} fields={customField.children ?? []} onChange={(v) => onChange(path, v)} + onEntryChange={(index, v) => onChange(`${path}.${index}`, v)} disabled={disabled} hideAddButton renderFields={renderGroupedEndpointFields} diff --git a/src/components/configuration/utils.ts b/src/components/configuration/utils.ts index dd9712b..68aa926 100644 --- a/src/components/configuration/utils.ts +++ b/src/components/configuration/utils.ts @@ -3,9 +3,16 @@ import type * as t from '@/types'; export function inferKVType(v: t.ConfigValue): t.KVValueType { if (typeof v === 'boolean') return 'boolean'; if (typeof v === 'number') return 'number'; + if (typeof v === 'object' && v !== null) return 'json'; return 'string'; } +export function toKVPair(k: string, v: t.ConfigValue): t.KeyValuePair { + const valueType = inferKVType(v); + if (valueType === 'json') return { key: k, value: JSON.stringify(v, null, 2), valueType }; + return { key: k, value: typeof v === 'string' ? v : String(v ?? ''), valueType }; +} + export function getControlType(field: t.SchemaField): t.ControlType { if (field.type === 'boolean') return 'toggle'; if (field.type.startsWith('enum')) return 'select'; diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 33caf47..fddc4bf 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -852,3 +852,134 @@ describe('YAML-editor fallback audit', () => { expect(codeFields.length).toBe(Object.keys(expectedCodeFields).length); }); }); + +/* --------------------------------------------------------------------------- + * Custom endpoint validation and schema extraction + * -----------------------------------------------------------------------*/ + +const ldpEndpoint = ( + require3('librechat-data-provider') as { endpointSchema: ZodV3Schema } +).endpointSchema; + +describe('custom endpoint schema', () => { + const endpointTree = extractSchemaTree(ldpEndpoint); + + describe('addParams field detection', () => { + const addParams = findField(endpointTree, 'addParams'); + + it('detects addParams as a record type', () => { + expect(addParams).toBeDefined(); + expect(addParams!.type).toBe('record'); + }); + + it('marks addParams as primitive recordValueType', () => { + expect(addParams!.recordValueType).toBe('primitive'); + }); + + it('includes json in recordValueKVTypes for addParams', () => { + expect(addParams!.recordValueKVTypes).toBeDefined(); + expect(addParams!.recordValueKVTypes).toContain('json'); + expect(addParams!.recordValueKVTypes).toContain('string'); + expect(addParams!.recordValueKVTypes).toContain('number'); + expect(addParams!.recordValueKVTypes).toContain('boolean'); + }); + }); + + describe('headers field detection', () => { + const headers = findField(endpointTree, 'headers'); + + it('detects headers as a record type', () => { + expect(headers).toBeDefined(); + expect(headers!.type).toBe('record'); + }); + + it('marks headers as primitive recordValueType', () => { + expect(headers!.recordValueType).toBe('primitive'); + }); + + it('restricts headers to string-only KV types', () => { + expect(headers!.recordValueKVTypes).toEqual(['string']); + }); + }); + + describe('dropParams field detection', () => { + const dropParams = findField(endpointTree, 'dropParams'); + + it('detects dropParams as an array type', () => { + expect(dropParams).toBeDefined(); + expect(dropParams!.type).toMatch(/^array/); + }); + }); +}); + +describe('resolveSubSchema for endpoints', () => { + it('resolves endpoints.custom to an array schema', () => { + const sub = resolveSubSchema(realConfigSchema, ['endpoints', 'custom']); + expect(sub).not.toBeNull(); + }); + + it('resolves endpoints.custom array element via numeric index', () => { + const sub = resolveSubSchema(realConfigSchema, ['endpoints', 'custom', '0']); + expect(sub).not.toBeNull(); + }); + + it('resolves named provider paths', () => { + for (const provider of ['openAI', 'anthropic', 'google', 'azureOpenAI']) { + const sub = resolveSubSchema(realConfigSchema, ['endpoints', provider]); + expect(sub).not.toBeNull(); + } + }); +}); + +describe('validateFieldValue for endpoints', () => { + const validEndpoint = { + name: 'TestEndpoint', + apiKey: '${TEST_KEY}', + baseURL: 'https://api.test.com/v1', + models: { default: ['model-1'], fetch: true }, + titleConvo: true, + titleModel: 'current_model', + }; + + it('validates a single custom endpoint entry (object)', () => { + const result = validateFieldValue('endpoints.custom.0', validEndpoint); + expect(result).toEqual({ success: true }); + }); + + it('validates a custom endpoint with addParams as object', () => { + const result = validateFieldValue('endpoints.custom.0', { + ...validEndpoint, + addParams: { stream: true, temperature: 0.7 }, + }); + expect(result).toEqual({ success: true }); + }); + + it('validates a custom endpoint with nested addParams', () => { + const result = validateFieldValue('endpoints.custom.0', { + ...validEndpoint, + addParams: { config: { nested: { deep: true } } }, + }); + expect(result).toEqual({ success: true }); + }); + + it('validates a custom endpoint with headers', () => { + const result = validateFieldValue('endpoints.custom.0', { + ...validEndpoint, + headers: { 'x-api-key': '${API_KEY}', 'x-custom': 'value' }, + }); + expect(result).toEqual({ success: true }); + }); + + it('validates a custom endpoint with dropParams', () => { + const result = validateFieldValue('endpoints.custom.0', { + ...validEndpoint, + dropParams: ['stop', 'frequency_penalty'], + }); + expect(result).toEqual({ success: true }); + }); + + it('gracefully handles unknown deep paths', () => { + const result = validateFieldValue('endpoints.custom.0.nonexistent.deep', 'value'); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/src/server/config.ts b/src/server/config.ts index e85bfa1..148bf65 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -101,6 +101,35 @@ function hasUnionObjectVariant(schema: t.ZodSchemaLike): boolean { return options.some((opt: t.ZodSchemaLike) => opt && typeof opt === 'object' && 'shape' in opt); } +const ZOD_TO_KV: Record = { + ZodString: 'string', + ZodNumber: 'number', + ZodBoolean: 'boolean', +}; + +function inferRecordKVTypes(schema: t.ZodSchemaLike): t.KVValueType[] | undefined { + if (!schema?._def) return undefined; + const tn = schema._def.typeName; + if (tn && tn in ZOD_TO_KV) return [ZOD_TO_KV[tn]]; + if (tn !== 'ZodUnion') return undefined; + const types = new Set(); + for (const opt of schema._def.options ?? []) { + const optTn = opt?._def?.typeName; + if (optTn && optTn in ZOD_TO_KV) { + types.add(ZOD_TO_KV[optTn]); + } else if ( + optTn === 'ZodRecord' || + optTn === 'ZodArray' || + optTn === 'ZodObject' || + (opt && typeof opt === 'object' && 'shape' in opt) + ) { + types.add('json'); + } + } + return types.size > 0 ? [...types] : undefined; +} + + /** Merges fields from union object variants into a single list. * When the same key appears in multiple variants with different literal * types, the literals are combined into a union(literal(...) | literal(...)) @@ -272,6 +301,7 @@ export function extractSchemaTree( let children: t.SchemaField[] | undefined; let recordValueType: 'primitive' | 'complex' | undefined; let recordValueAllowsPrimitive: boolean | undefined; + let recordValueKVTypes: t.KVValueType[] | undefined; if (isArray && innerSchema?._def?.type) { let elementSchema: t.ZodSchemaLike = innerSchema._def.type; @@ -301,6 +331,7 @@ export function extractSchemaTree( recordValueAllowsPrimitive = true; } else { recordValueType = 'primitive'; + recordValueKVTypes = inferRecordKVTypes(unwrapped); } } } @@ -318,6 +349,7 @@ export function extractSchemaTree( depth, recordValueType, recordValueAllowsPrimitive, + recordValueKVTypes, }); } } @@ -769,6 +801,52 @@ export const baseConfigOptions = queryOptions({ staleTime: 30_000, }); +const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/; + +async function mergeIndexedArrayEntries( + entries: Array<{ fieldPath: string; value: unknown }>, + mergedPaths?: Set, +): Promise> { + const indexed = new Map>(); + const rest: Array<{ fieldPath: string; value: unknown }> = []; + + for (const entry of entries) { + const match = INDEXED_ARRAY_RE.exec(entry.fieldPath); + if (match) { + const [, arrayPath, indexStr] = match; + if (!indexed.has(arrayPath)) indexed.set(arrayPath, new Map()); + indexed.get(arrayPath)!.set(Number(indexStr), entry.value); + } else { + rest.push(entry); + } + } + + if (indexed.size === 0) return entries; + + const baseResponse = await apiFetch('/api/admin/config/base'); + if (!baseResponse.ok) throw new Error(`Failed to fetch base config: ${baseResponse.status}`); + const { config: baseConfig } = (await baseResponse.json()) as { + config: Record; + }; + + for (const [arrayPath, updates] of indexed) { + const segments = arrayPath.split('.'); + let current: unknown = baseConfig; + for (const seg of segments) { + if (current == null || typeof current !== 'object') { current = undefined; break; } + current = (current as Record)[seg]; + } + const arr = Array.isArray(current) ? [...current] : []; + for (const [idx, value] of updates) { + arr[idx] = value; + } + rest.push({ fieldPath: arrayPath, value: arr }); + mergedPaths?.add(arrayPath); + } + + return rest; +} + export const saveBaseConfigFn = createServerFn({ method: 'POST' }) .inputValidator( z.object({ @@ -779,13 +857,23 @@ export const saveBaseConfigFn = createServerFn({ method: 'POST' }) }), ) .handler(async ({ data }) => { - const filtered = data.entries.filter((e) => !isInterfacePermissionPath(e.fieldPath)); + let filtered = data.entries.filter((e) => !isInterfacePermissionPath(e.fieldPath)); if (filtered.length === 0) return { success: true }; + + // Merge indexed array entries (e.g. endpoints.custom.2) back into + // full arrays so the API receives complete field values. + // Track which paths were merged so we can skip re-validation — the + // individual entries were already validated by the client and the + // merge only splices them into the existing array. + const mergedArrayPaths = new Set(); + filtered = await mergeIndexedArrayEntries(filtered, mergedArrayPaths); + const sections = [...new Set(filtered.map((e) => e.fieldPath.split('.')[0]))]; await requireAllSectionCapabilities(sections); const errors: t.FieldValidationError[] = []; for (const entry of filtered) { + if (mergedArrayPaths.has(entry.fieldPath)) continue; const result = validateFieldValue(entry.fieldPath, entry.value); if (!result.success) { errors.push({ fieldPath: entry.fieldPath, error: result.error }); diff --git a/src/types/config.ts b/src/types/config.ts index 7c7a500..52e40f5 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -39,6 +39,7 @@ export interface SchemaField { depth: number; recordValueType?: 'primitive' | 'complex'; recordValueAllowsPrimitive?: boolean; + recordValueKVTypes?: KVValueType[]; } export interface ZodDef { @@ -71,7 +72,7 @@ export interface SelectOption { value: string; } -export type KVValueType = 'string' | 'number' | 'boolean'; +export type KVValueType = 'string' | 'number' | 'boolean' | 'json'; export interface KeyValuePair { [k: string]: string | KVValueType | undefined; diff --git a/src/types/fields.ts b/src/types/fields.ts index 3db591e..a219fbe 100644 --- a/src/types/fields.ts +++ b/src/types/fields.ts @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import type React from 'react'; -import type { ConfigValue, SchemaField, SelectOption, KeyValuePair } from './config'; +import type { ConfigValue, SchemaField, SelectOption, KeyValuePair, KVValueType } from './config'; export interface SelectFieldProps { id: string; @@ -17,6 +17,7 @@ export interface KeyValueFieldProps { pairs: KeyValuePair[]; onChange: (pairs: KeyValuePair[]) => void; disabled?: boolean; + valueTypes?: KVValueType[]; keyPlaceholder?: string; valuePlaceholder?: string; 'aria-label'?: string; @@ -99,6 +100,9 @@ export interface ArrayObjectFieldProps { value: ConfigValue; fields: SchemaField[]; onChange: (value: ConfigValue) => void; + /** Per-entry change callback. When provided, individual entry edits use + * this instead of replacing the entire array via `onChange`. */ + onEntryChange?: (index: number, value: ConfigValue) => void; disabled?: boolean; /** Hide the bottom "Add entry" button (e.g. when add is in the section header). */ hideAddButton?: boolean; diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts new file mode 100644 index 0000000..8dd0d32 --- /dev/null +++ b/src/utils/format.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { serializeKVPairs, deepSerializeKVPairs } from './format'; + +describe('serializeKVPairs', () => { + it('converts KV pairs to a record', () => { + const pairs = [ + { key: 'name', value: 'test', valueType: 'string' as const }, + { key: 'count', value: '42', valueType: 'number' as const }, + { key: 'active', value: 'true', valueType: 'boolean' as const }, + ]; + expect(serializeKVPairs(pairs)).toEqual({ name: 'test', count: 42, active: true }); + }); + + it('handles json valueType by parsing JSON string', () => { + const pairs = [ + { key: 'config', value: '{"nested": true}', valueType: 'json' as const }, + ]; + expect(serializeKVPairs(pairs)).toEqual({ config: { nested: true } }); + }); + + it('falls back to string for invalid json', () => { + const pairs = [ + { key: 'bad', value: '{not json', valueType: 'json' as const }, + ]; + expect(serializeKVPairs(pairs)).toEqual({ bad: '{not json' }); + }); + + it('returns non-KV arrays unchanged', () => { + const arr = ['a', 'b', 'c']; + expect(serializeKVPairs(arr)).toBe(arr); + }); + + it('returns empty arrays unchanged', () => { + expect(serializeKVPairs([])).toEqual([]); + }); + + it('returns primitives unchanged', () => { + expect(serializeKVPairs('hello')).toBe('hello'); + expect(serializeKVPairs(42)).toBe(42); + expect(serializeKVPairs(true)).toBe(true); + }); + + it('skips dangerous keys', () => { + const pairs = [ + { key: '__proto__', value: 'bad', valueType: 'string' as const }, + { key: 'safe', value: 'good', valueType: 'string' as const }, + ]; + const result = serializeKVPairs(pairs) as Record; + expect(result.safe).toBe('good'); + expect('__proto__' in result).toBe(false); + }); + + it('skips pairs with empty keys', () => { + const pairs = [ + { key: '', value: 'orphan', valueType: 'string' as const }, + { key: 'valid', value: 'ok', valueType: 'string' as const }, + ]; + expect(serializeKVPairs(pairs)).toEqual({ valid: 'ok' }); + }); +}); + +describe('deepSerializeKVPairs', () => { + it('serializes nested KV pairs inside an object', () => { + const value = { + name: 'Moonshot', + apiKey: '${KEY}', + addParams: [ + { key: 'stream', value: 'true', valueType: 'boolean' }, + { key: 'temp', value: '0.7', valueType: 'number' }, + ], + }; + const result = deepSerializeKVPairs(value) as Record; + expect(result.name).toBe('Moonshot'); + expect(result.addParams).toEqual({ stream: true, temp: 0.7 }); + }); + + it('serializes KV pairs with json type in nested objects', () => { + const value = { + name: 'Test', + addParams: [ + { key: 'config', value: '{"nested": {"deep": true}}', valueType: 'json' }, + ], + }; + const result = deepSerializeKVPairs(value) as Record; + expect(result.addParams).toEqual({ config: { nested: { deep: true } } }); + }); + + it('preserves non-KV arrays', () => { + const value = { + models: { default: ['model-1', 'model-2'], fetch: true }, + }; + const result = deepSerializeKVPairs(value) as Record; + const models = result.models as Record; + expect(models.default).toEqual(['model-1', 'model-2']); + expect(models.fetch).toBe(true); + }); + + it('handles headers (string-only record) correctly', () => { + const value = { + headers: [ + { key: 'x-api-key', value: '${KEY}', valueType: 'string' }, + ], + }; + const result = deepSerializeKVPairs(value) as Record; + expect(result.headers).toEqual({ 'x-api-key': '${KEY}' }); + }); + + it('returns primitives unchanged', () => { + expect(deepSerializeKVPairs('hello')).toBe('hello'); + expect(deepSerializeKVPairs(42)).toBe(42); + expect(deepSerializeKVPairs(null)).toBe(null); + expect(deepSerializeKVPairs(undefined)).toBe(undefined); + }); + + it('handles a full custom endpoint object', () => { + const endpoint = { + name: 'TestAPI', + apiKey: '${API_KEY}', + baseURL: 'https://api.test.com/v1', + models: { default: ['gpt-4'], fetch: true }, + titleConvo: true, + titleModel: 'current_model', + headers: [ + { key: 'Authorization', value: 'Bearer ${TOKEN}', valueType: 'string' }, + ], + addParams: [ + { key: 'stream', value: 'true', valueType: 'boolean' }, + { key: 'config', value: '{"key": "value"}', valueType: 'json' }, + ], + dropParams: ['stop', 'presence_penalty'], + }; + + const result = deepSerializeKVPairs(endpoint) as Record; + expect(result.name).toBe('TestAPI'); + expect(result.models).toEqual({ default: ['gpt-4'], fetch: true }); + expect(result.headers).toEqual({ Authorization: 'Bearer ${TOKEN}' }); + expect(result.addParams).toEqual({ stream: true, config: { key: 'value' } }); + expect(result.dropParams).toEqual(['stop', 'presence_penalty']); + }); +}); diff --git a/src/utils/format.ts b/src/utils/format.ts index 4befa7a..9a3ce6a 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -11,7 +11,7 @@ export function serializeKVPairs(value: t.ConfigValue): t.ConfigValue { return value; const pairs = value as t.KeyValuePair[]; const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); - const record: Record = Object.create(null); + const record: Record = Object.create(null); for (const pair of pairs) { if (!pair.key || DANGEROUS_KEYS.has(pair.key)) continue; record[pair.key] = coerceKVValue(pair.value, pair.valueType ?? 'string'); @@ -19,12 +19,35 @@ export function serializeKVPairs(value: t.ConfigValue): t.ConfigValue { return record; } -function coerceKVValue(raw: string, type: t.KVValueType): string | number | boolean { +/** Recursively serialize KV pairs within an object tree. */ +export function deepSerializeKVPairs(value: t.ConfigValue): t.ConfigValue { + if (value == null || typeof value !== 'object') return value; + if (Array.isArray(value)) { + const serialized = serializeKVPairs(value); + if (serialized !== value) return serialized; + return value.map(deepSerializeKVPairs); + } + const obj = value as Record; + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) { + result[k] = deepSerializeKVPairs(v); + } + return result; +} + +function coerceKVValue(raw: string, type: t.KVValueType): t.ConfigValue { if (type === 'boolean') return raw === 'true'; if (type === 'number') { const n = Number(raw); return Number.isFinite(n) ? n : raw; } + if (type === 'json') { + try { + return JSON.parse(raw) as t.ConfigValue; + } catch { + return raw; + } + } return raw; } diff --git a/tools/eslint-plugin-click-ui/.eslintrc.json b/tools/eslint-plugin-click-ui/.eslintrc.json new file mode 100644 index 0000000..bc4b7d2 --- /dev/null +++ b/tools/eslint-plugin-click-ui/.eslintrc.json @@ -0,0 +1,45 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:click-ui/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "react", + "@typescript-eslint", + "click-ui" + ], + "rules": { + "click-ui/require-provider": "error", + "click-ui/require-css-import": "error", + "click-ui/button-requires-label": "error", + "click-ui/icon-name-format": "error", + "click-ui/logo-name-format": "error", + "click-ui/valid-icon-name": "warn", + "click-ui/valid-logo-name": "warn", + "click-ui/container-requires-orientation": "error", + "click-ui/table-structure": "error", + "click-ui/dialog-controlled-state": "error", + "click-ui/form-controlled-components": "error", + "click-ui/prefer-named-imports": "warn", + "click-ui/valid-theme-name": "error", + "click-ui/valid-spacing-token": "warn", + "click-ui/valid-icon-size": "warn", + "click-ui/valid-button-type": "warn", + "click-ui/select-requires-options": "error", + "click-ui/datepicker-controlled": "error" + } +} diff --git a/tools/eslint-plugin-click-ui/ARCHITECTURE.md b/tools/eslint-plugin-click-ui/ARCHITECTURE.md new file mode 100644 index 0000000..d2ef123 --- /dev/null +++ b/tools/eslint-plugin-click-ui/ARCHITECTURE.md @@ -0,0 +1,276 @@ +# Click UI ESLint Plugin - Architecture & Overview + +## Project Structure + +``` +eslint-plugin-click-ui/ +├── index.js # Main plugin entry point +├── package.json # Package configuration +├── README.md # Comprehensive documentation +├── QUICK_START.md # Quick start guide +├── .eslintrc.json # Example configuration +├── rules/ # Individual rule implementations +│ ├── require-provider.js +│ ├── require-css-import.js +│ ├── button-requires-label.js +│ ├── icon-name-format.js +│ ├── logo-name-format.js +│ ├── valid-icon-name.js +│ ├── valid-logo-name.js +│ ├── container-requires-orientation.js +│ ├── table-structure.js +│ ├── dialog-controlled-state.js +│ ├── form-controlled-components.js +│ ├── prefer-named-imports.js +│ ├── valid-theme-name.js +│ ├── valid-spacing-token.js +│ ├── valid-icon-size.js +│ ├── valid-button-type.js +│ ├── select-requires-options.js +│ └── datepicker-controlled.js +├── tests/ # Test suite +│ └── rules.test.js +├── correct-examples.tsx # Examples of correct usage +└── test-examples.tsx # Examples of violations +``` + +## Rule Categories + +### 1. Setup Rules (Critical) +These ensure the basic Click UI setup is correct: +- `require-provider`: Ensures ClickUIProvider wraps the app +- `require-css-import`: Ensures cui.css is imported + +**Impact**: Without these, Click UI won't work at all + +### 2. Component API Rules (Errors) +These catch incorrect component usage that will cause runtime errors: +- `button-requires-label`: Button needs label prop, not children +- `container-requires-orientation`: Container needs orientation +- `table-structure`: Table needs headers/rows, not data/columns +- `dialog-controlled-state`: Dialog needs controlled state +- `form-controlled-components`: Form inputs need value/onChange +- `select-requires-options`: Select needs all required props +- `datepicker-controlled`: DatePicker needs controlled state + +**Impact**: These will cause runtime errors or broken components + +### 3. Naming Convention Rules (Errors) +These enforce correct naming patterns: +- `icon-name-format`: Icons use hyphens (check-in-circle) +- `logo-name-format`: Logos use underscores (digital_ocean) + +**Impact**: Wrong formats won't find the icon/logo assets +**Auto-fixable**: Yes + +### 4. Validation Rules (Warnings) +These validate against known valid values: +- `valid-icon-name`: Checks against 165 valid icon names +- `valid-logo-name`: Checks against 58 valid logo names +- `valid-theme-name`: Validates theme values +- `valid-spacing-token`: Validates spacing tokens +- `valid-icon-size`: Validates icon sizes +- `valid-button-type`: Validates button types + +**Impact**: Invalid values won't render correctly +**Provides**: Helpful suggestions for typos + +### 5. Best Practice Rules (Warnings) +These encourage good patterns: +- `prefer-named-imports`: Use named imports for tree-shaking + +**Impact**: Performance and bundle size + +## Key Design Decisions + +### 1. Two Config Presets +- **recommended**: Balanced (errors for critical, warnings for suggestions) +- **strict**: All rules as errors for maximum enforcement + +### 2. Auto-fixable Where Possible +Rules that can be automatically fixed: +- `button-requires-label`: Converts children to label +- `icon-name-format`: Converts to hyphen format +- `logo-name-format`: Converts to underscore format + +### 3. Smart Detection +- Only checks root files (App.tsx, _app.tsx) for provider/CSS +- Validates icon/logo names against actual library +- Suggests similar names using Levenshtein distance + +### 4. TypeScript-Friendly +- Works with .tsx files +- Understands JSX expressions +- Validates against TypeScript types + +## Common Patterns Detected + +### Pattern 1: Missing Setup +```tsx +// ❌ Detected +function App() { + return + +// ✅ Fixed to + + +// ✅ Correct + + +// ✅ Good +