diff --git a/.changeset/add-icon-more-export.md b/.changeset/add-icon-more-export.md new file mode 100644 index 000000000..2d63bac78 --- /dev/null +++ b/.changeset/add-icon-more-export.md @@ -0,0 +1,5 @@ +--- +"@tiny-design/icons": minor +--- + +Add the new `IconMore` export to the icons package. diff --git a/apps/docs/src/containers/theme-studio/color-utils.ts b/apps/docs/src/containers/theme-studio/color-utils.ts index f1c62588e..1137cb87a 100644 --- a/apps/docs/src/containers/theme-studio/color-utils.ts +++ b/apps/docs/src/containers/theme-studio/color-utils.ts @@ -7,6 +7,14 @@ type OklchColor = { c: number; h: number; }; +export type ShadowValue = { + color: string; + opacity: number; + blur: number; + spread: number; + offsetX: number; + offsetY: number; +}; function normalizeAlpha(value: number): number { if (!Number.isFinite(value)) return 1; @@ -72,42 +80,66 @@ export function formatOklchColor(color: OklchColor): string { return `oklch(${color.l.toFixed(3)} ${color.c.toFixed(3)} ${color.h.toFixed(3)})`; } -export function deriveStatusPalette(styles: RuntimeStyles): Pick< +export function deriveStatusPalette( + styles: RuntimeStyles +): Pick< ThemeEditorFields, - 'success' | 'successForeground' | 'info' | 'infoForeground' | 'warning' | 'warningForeground' | 'danger' | 'dangerForeground' + | 'success' + | 'successForeground' + | 'info' + | 'infoForeground' + | 'warning' + | 'warningForeground' + | 'danger' + | 'dangerForeground' > { - const mode: ThemeMode = parseCssScalar(styles.background ?? '') != null - ? ((parseOklchColor(styles.background)?.l ?? 1) < 0.45 ? 'dark' : 'light') - : 'light'; - const seed = parseOklchColor(styles.primary) ?? parseOklchColor(styles.accent) ?? parseOklchColor(styles.ring); + const mode: ThemeMode = + parseCssScalar(styles.background ?? '') != null + ? (parseOklchColor(styles.background)?.l ?? 1) < 0.45 + ? 'dark' + : 'light' + : 'light'; + const seed = + parseOklchColor(styles.primary) ?? + parseOklchColor(styles.accent) ?? + parseOklchColor(styles.ring); const chromaBase = clamp(seed?.c ?? (mode === 'dark' ? 0.17 : 0.19), 0.1, 0.24); const lightnessShift = seed ? (seed.l - (mode === 'dark' ? 0.78 : 0.58)) * 0.08 : 0; const hueShift = seed ? ((seed.h - 220) / 220) * 6 : 0; const statusForeground = mode === 'dark' ? 'oklch(0.145 0 0)' : 'oklch(0.985 0 0)'; - const createStatus = (hue: number, lightness: number, chromaScale: number) => formatOklchColor({ - l: clamp(lightness + lightnessShift, mode === 'dark' ? 0.68 : 0.54, mode === 'dark' ? 0.84 : 0.74), - c: clamp(chromaBase * chromaScale, 0.12, 0.26), - h: normalizeHue(hue + hueShift), - }); + const createStatus = (hue: number, lightness: number, chromaScale: number) => + formatOklchColor({ + l: clamp( + lightness + lightnessShift, + mode === 'dark' ? 0.68 : 0.54, + mode === 'dark' ? 0.84 : 0.74 + ), + c: clamp(chromaBase * chromaScale, 0.12, 0.26), + h: normalizeHue(hue + hueShift), + }); return { success: createStatus(148, mode === 'dark' ? 0.76 : 0.62, 0.92), successForeground: statusForeground, - info: createStatus(238, mode === 'dark' ? 0.74 : 0.60, 0.96), + info: createStatus(238, mode === 'dark' ? 0.74 : 0.6, 0.96), infoForeground: statusForeground, - warning: createStatus(72, mode === 'dark' ? 0.80 : 0.69, 0.94), + warning: createStatus(72, mode === 'dark' ? 0.8 : 0.69, 0.94), warningForeground: mode === 'dark' ? 'oklch(0.145 0 0)' : 'oklch(0.205 0 0)', - danger: createStatus(28, mode === 'dark' ? 0.72 : 0.60, 1), + danger: createStatus(28, mode === 'dark' ? 0.72 : 0.6, 1), dangerForeground: statusForeground, }; } function parseHexColor(color: string): [number, number, number] | null { const normalized = color.trim().replace('#', ''); - const value = normalized.length === 3 - ? normalized.split('').map((char) => `${char}${char}`).join('') - : normalized; + const value = + normalized.length === 3 + ? normalized + .split('') + .map((char) => `${char}${char}`) + .join('') + : normalized; if (!/^[0-9a-f]{6}$/i.test(value)) return null; @@ -155,9 +187,138 @@ export function softenSurface(color: string, mode: ThemeMode, amount: number): s return mode === 'dark' ? tintColor(color, amount) : shadeColor(color, amount); } -export function buildShadow(styles: RuntimeStyles): string { +function splitTopLevelTokens(value: string): string[] { + const tokens: string[] = []; + let current = ''; + let depth = 0; + + for (const char of value) { + if (char === '(') depth += 1; + if (char === ')') depth = Math.max(0, depth - 1); + + if (/\s/.test(char) && depth === 0) { + if (current) { + tokens.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (current) tokens.push(current); + return tokens; +} + +function parsePxValue(token: string): number | null { + const trimmed = token.trim(); + if (/^-?0(?:\.0+)?(?:px)?$/i.test(trimmed)) return 0; + + const match = /^(-?\d+(?:\.\d+)?)px$/i.exec(trimmed); + if (!match) return null; + + const parsed = Number.parseFloat(match[1]); + return Number.isFinite(parsed) ? parsed : null; +} + +function rgbChannelToHex(value: string): string | null { + const parsed = Number.parseInt(value.trim(), 10); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 255) return null; + return parsed.toString(16).padStart(2, '0'); +} + +function parseShadowColor(value: string): Pick | null { + const colorMixMatch = + /^color-mix\(in srgb,\s*(.+?)\s+(-?\d+(?:\.\d+)?)%,\s*transparent\s*\)$/i.exec(value.trim()); + if (colorMixMatch) { + const percentage = Number.parseFloat(colorMixMatch[2]); + if (Number.isFinite(percentage)) { + return { + color: colorMixMatch[1].trim(), + opacity: clamp(percentage / 100, 0, 1), + }; + } + } + + const rgbaMatch = /^rgba\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\)$/i.exec( + value.trim() + ); + if (rgbaMatch) { + const red = rgbChannelToHex(rgbaMatch[1]); + const green = rgbChannelToHex(rgbaMatch[2]); + const blue = rgbChannelToHex(rgbaMatch[3]); + const alpha = Number.parseFloat(rgbaMatch[4]); + + if (red && green && blue && Number.isFinite(alpha)) { + return { + color: `#${red}${green}${blue}`, + opacity: clamp(alpha, 0, 1), + }; + } + } + + const rgbMatch = /^rgb\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*\)$/i.exec(value.trim()); + if (rgbMatch) { + const red = rgbChannelToHex(rgbMatch[1]); + const green = rgbChannelToHex(rgbMatch[2]); + const blue = rgbChannelToHex(rgbMatch[3]); + + if (red && green && blue) { + return { + color: `#${red}${green}${blue}`, + opacity: 1, + }; + } + } + + return value.trim() + ? { + color: value.trim(), + opacity: 1, + } + : null; +} + +export function formatShadowValue(shadow: ShadowValue): string { + const offsetX = `${shadow.offsetX}px`; + const offsetY = `${shadow.offsetY}px`; + const blur = `${shadow.blur}px`; + const spread = `${shadow.spread}px`; + + return `${offsetX} ${offsetY} ${blur} ${spread} ${toRgba(shadow.color, shadow.opacity)}`; +} + +export function parseShadowValue(value: string, fallback: ShadowValue): ShadowValue { + const trimmed = value.trim(); + if (!trimmed || trimmed.toLowerCase() === 'none') return fallback; + + const tokens = splitTopLevelTokens(trimmed); + if (tokens.length < 5) return fallback; + + const offsetX = parsePxValue(tokens[0]); + const offsetY = parsePxValue(tokens[1]); + const blur = parsePxValue(tokens[2]); + const spread = parsePxValue(tokens[3]); + const color = parseShadowColor(tokens.slice(4).join(' ')); + + if (offsetX == null || offsetY == null || blur == null || spread == null || !color) { + return fallback; + } + + return { + color: color.color, + opacity: color.opacity, + blur, + spread, + offsetX, + offsetY, + }; +} + +export function buildShadow(styles: RuntimeStyles, fallback = DEFAULT_FIELDS.shadowCard): string { const color = styles['shadow-color']; - if (!color) return DEFAULT_FIELDS.shadowCard; + if (!color) return fallback; const opacity = Number.parseFloat(styles['shadow-opacity'] ?? '0.1'); const blur = styles['shadow-blur'] ?? '0px'; diff --git a/apps/docs/src/containers/theme-studio/defaults.ts b/apps/docs/src/containers/theme-studio/defaults.ts index d8ecb58d9..3d24bb0fa 100644 --- a/apps/docs/src/containers/theme-studio/defaults.ts +++ b/apps/docs/src/containers/theme-studio/defaults.ts @@ -50,6 +50,7 @@ export const DEFAULT_FIELDS: ThemeEditorFields = { h2Size: '32px', letterSpacing: '-0.02em', radius: '0.3rem', + shadowControl: 'none', shadowCard: '0 20px 55px rgba(17, 24, 39, 0.08)', shadowFocus: '0 0 0 3px rgba(110, 65, 191, 0.22)', buttonRadius: '0.3rem', diff --git a/apps/docs/src/containers/theme-studio/editor-draft.ts b/apps/docs/src/containers/theme-studio/editor-draft.ts index ab5121139..ad2adb040 100644 --- a/apps/docs/src/containers/theme-studio/editor-draft.ts +++ b/apps/docs/src/containers/theme-studio/editor-draft.ts @@ -128,6 +128,7 @@ export function buildPreviewVars(fields: ThemeEditorFields): React.CSSProperties '--editor-h2-size': fields.h2Size, '--editor-letter-spacing': fields.letterSpacing, '--editor-radius': fields.radius, + '--editor-shadow-control': fields.shadowControl, '--editor-shadow-card': fields.shadowCard, '--editor-shadow-focus': fields.shadowFocus, '--editor-button-radius': fields.buttonRadius, diff --git a/apps/docs/src/containers/theme-studio/editor-fields.tsx b/apps/docs/src/containers/theme-studio/editor-fields.tsx index b8c060409..698ccb152 100644 --- a/apps/docs/src/containers/theme-studio/editor-fields.tsx +++ b/apps/docs/src/containers/theme-studio/editor-fields.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { ColorPicker, Input, InputNumber, Slider, Textarea } from '@tiny-design/react'; +import { formatShadowValue, parseShadowValue, type ShadowValue } from './color-utils'; import type { SliderFieldConfig } from './types'; export function swatchTextStyle(background: string, foreground: string): React.CSSProperties { @@ -140,3 +141,146 @@ export function SliderField({ ); } + +function ShadowMetricField({ + label, + value, + min, + max, + step, + unit, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + unit?: string; + onChange: (next: number) => void; +}): React.ReactElement { + return ( + + ); +} + +export function ShadowField({ + label, + value, + onChange, + fallback, +}: { + label: string; + value: string; + onChange: (next: string) => void; + fallback: ShadowValue; +}): React.ReactElement { + const parsed = React.useMemo(() => parseShadowValue(value, fallback), [fallback, value]); + + const updateShadow = (patch: Partial) => { + onChange( + formatShadowValue({ + ...parsed, + ...patch, + }) + ); + }; + + return ( +
+ {label} +
+
+ Color +
+ updateShadow({ color: next })} + presets={[ + '#000000', + '#ffffff', + '#1e9df1', + '#0f1419', + '#e11d48', + '#22c55e', + '#f59e0b', + '#8b5cf6', + ]} + /> + updateShadow({ color: event.target.value })} + /> +
+
+ updateShadow({ opacity: next })} + /> + updateShadow({ blur: next })} + /> + updateShadow({ spread: next })} + /> + updateShadow({ offsetX: next })} + /> + updateShadow({ offsetY: next })} + /> +
+
+ ); +} diff --git a/apps/docs/src/containers/theme-studio/preview-components.tsx b/apps/docs/src/containers/theme-studio/preview-components.tsx deleted file mode 100644 index cd61ea425..000000000 --- a/apps/docs/src/containers/theme-studio/preview-components.tsx +++ /dev/null @@ -1,1016 +0,0 @@ -import React from 'react'; -import { - Alert, - Avatar, - Button, - Card, - Checkbox, - DatePicker, - Divider, - Flex, - Grid, - Input, - Progress, - Radio, - Select, - Segmented, - Switch, - Table, - Tag, - Textarea, - Calendar, - Heading, - Paragraph, - Text, -} from '@tiny-design/react'; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from '@tiny-design/charts'; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Line, - LineChart, - XAxis, - YAxis, -} from 'recharts'; -import { swatchTextStyle } from './editor-fields'; -import type { ThemeEditorFields, ThemeEditorSection, ThemePreviewTemplate } from './types'; -import { IconGithub, IconGoogle } from '@tiny-design/icons'; - -const revenueData = [ - { month: 'Jan', revenue: 4200, target: 3800 }, - { month: 'Feb', revenue: 5100, target: 4300 }, - { month: 'Mar', revenue: 4800, target: 4500 }, - { month: 'Apr', revenue: 6200, target: 5200 }, - { month: 'May', revenue: 6900, target: 6100 }, - { month: 'Jun', revenue: 7600, target: 6600 }, -]; - -const revenueChartConfig: ChartConfig = { - revenue: { - label: 'Revenue', - color: 'var(--ty-chart-1)', - }, - target: { - label: 'Target', - color: 'var(--ty-chart-2)', - }, -}; - -const cardsRevenueData = [ - { month: 'Jan', value: 8.4 }, - { month: 'Feb', value: 7.2 }, - { month: 'Mar', value: 7.8 }, - { month: 'Apr', value: 6.9 }, - { month: 'May', value: 7.4 }, - { month: 'Jun', value: 8.1 }, - { month: 'Jul', value: 12.4 }, -]; - -const cardsRevenueChartConfig: ChartConfig = { - value: { - label: 'Revenue', - color: 'var(--ty-chart-4)', - }, -}; - -const moveGoalData = [ - { key: 'm1', dayLabel: 'M', value: 280 }, - { key: 't1', dayLabel: 'T', value: 340 }, - { key: 'w1', dayLabel: 'W', value: 220 }, - { key: 'th1', dayLabel: 'T', value: 310 }, - { key: 'f1', dayLabel: 'F', value: 360 }, - { key: 's1', dayLabel: 'S', value: 240 }, - { key: 'su1', dayLabel: 'S', value: 300 }, - { key: 'm2', dayLabel: 'M', value: 330 }, - { key: 't2', dayLabel: 'T', value: 260 }, - { key: 'w2', dayLabel: 'W', value: 320 }, - { key: 'th2', dayLabel: 'T', value: 350 }, - { key: 'f2', dayLabel: 'F', value: 290 }, -]; - -const moveGoalChartConfig: ChartConfig = { - value: { - label: 'Calories', - color: 'var(--ty-chart-1)', - }, -}; - -const cardsExerciseData = [ - { month: 'Mar', personal: 18, average: 22 }, - { month: 'Apr', personal: 14, average: 18 }, - { month: 'May', personal: 58, average: 26 }, - { month: 'Jun', personal: 28, average: 24 }, - { month: 'Jul', personal: 22, average: 20 }, - { month: 'Aug', personal: 26, average: 23 }, -]; - -const cardsExerciseChartConfig: ChartConfig = { - personal: { - label: 'You', - color: 'var(--ty-chart-2)', - }, - average: { - label: 'Average', - color: 'var(--ty-chart-5)', - }, -}; - -const subscriptionsData = [ - { month: 'Jan', value: 400 }, - { month: 'Feb', value: 620 }, - { month: 'Mar', value: 980 }, - { month: 'Apr', value: 1480 }, - { month: 'May', value: 1910 }, - { month: 'Jun', value: 880 }, - { month: 'Jul', value: 2350 }, -]; - -const subscriptionsChartConfig: ChartConfig = { - value: { - label: 'Subscriptions', - color: 'var(--editor-card-foreground)', - }, -}; - -const currYear = new Date().getFullYear(); -const currMonth = new Date().getMonth(); - -function LiveResponsePanel({ fields }: { fields: ThemeEditorFields }): React.ReactElement { - const colorPairs = [ - ['Primary', fields.primary, fields.primaryForeground], - ['Accent', fields.accent, fields.accentForeground], - ['Success', fields.success, fields.successForeground], - ['Info', fields.info, fields.infoForeground], - ['Warning', fields.warning, fields.warningForeground], - ['Danger', fields.danger, fields.dangerForeground], - ['Card', fields.card, fields.cardForeground], - ]; - - return ( - - - Live Colors - - {colorPairs.map(([label, background, foreground]) => ( -
- {label} -
- ))} -
-
- - - Charts - - {[fields.chart1, fields.chart2, fields.chart3, fields.chart4, fields.chart5].map( - (color, index) => ( - - ) - )} - - - - - Typography - - Ag - {fields.fontSans.split(',')[0].replaceAll('"', '')} - - {fields.fontSizeBase} / {fields.lineHeightBase} - - - - - - Surface - -
Card
-
Focus
-
-
- - - Sidebar - - -
- Primary -
-
- Accent -
-
-
-
-
- ); -} - -function CardsPreview(): React.ReactElement { - return ( - - - - - - - - Total Revenue - +20.1% from last month - - - $15,231.89 - - - - - } /> - - - - - - - - - - - Subscriptions - +180.1% from last month - - - +2,350 - - - - - - - - - - - } /> - - - - - - - - - - - Upgrade your subscription - - You are currently on the free plan. Upgrade to the pro plan to get access to all - features. - - -
- - - - - - - - - - - -
- -
- Starter Plan - Perfect for small businesses. -
-
-
-
- -
- Pro Plan - More features and storage. -
-
-
-
-
-