Add Niteshift Dials SDK for runtime design prototyping

Introduces a complete design dials system that allows designers and PMs
to adjust UI parameters at runtime without code changes.

**Dials SDK (`packages/dials/`):**
- useDynamicColor: Color values with design system integration
- useDynamicSpacing: Spacing/padding/margin controls
- useDynamicVariant: Discrete choice selections
- useDynamicBoolean: Toggle/feature flag controls
- useDynamicNumber: Numeric values with min/max/step
- DialsOverlay: Compact Leva-inspired UI (Ctrl+D to toggle)
- DialsProvider: React context for dial state management
- Design manifest integration for design system tokens

**App Integration:**
- Added DialsProvider to app Providers
- Example dials on WebsitePage (metrics bar, panels, navigation)
- MetricCard component with adjustable typography dials
- TypeScript manifest at src/config/niteshift-manifest.ts

**Documentation:**
- Comprehensive CLAUDE.md section on dials usage
- Best practices for preserving original appearance
- Examples for all dial types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sajid Mehmood 2025-11-25 13:13:28 -05:00
parent f4d0a65b16
commit 2727fd6dff
39 changed files with 4623 additions and 19 deletions

View file

@ -4,6 +4,8 @@ import { MetricsBar } from '@/components/metrics/MetricsBar';
import { formatShortTime, formatLongNumber } from '@/lib/format';
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useContext } from 'react';
import { TypographyContext } from './WebsitePage';
export function WebsiteMetricsBar({
websiteId,
@ -12,6 +14,7 @@ export function WebsiteMetricsBar({
showChange?: boolean;
compareMode?: boolean;
}) {
const typography = useContext(TypographyContext);
const { isAllTime } = useDateRange();
const { formatMessage, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId);
@ -79,6 +82,12 @@ export function WebsiteMetricsBar({
formatValue={formatValue}
reverseColors={reverseColors}
showChange={!isAllTime}
labelSize={typography.metricLabelSize as any}
valueSize={typography.metricValueSize as any}
labelWeight={typography.metricLabelWeight as any}
valueWeight={typography.metricValueWeight as any}
labelColor={typography.metricLabelColor}
valueColor={typography.metricValueColor}
/>
);
})}

View file

@ -8,6 +8,7 @@ import {
ChartPie,
UserPlus,
AlignEndHorizontal,
Sparkles,
} from '@/components/icons';
import { Lightning, Path, Money, Target, Funnel, Magnet, Network } from '@/components/svg';
import { useMessages, useNavigation } from '@/components/hooks';
@ -41,6 +42,12 @@ export function WebsiteNav({
icon: <Eye />,
path: renderPath(''),
},
{
id: 'overview-alt',
label: 'Overview Alt',
icon: <Sparkles />,
path: renderPath('/overview-alt'),
},
{
id: 'events',
label: formatMessage(labels.events),

View file

@ -6,17 +6,122 @@ import { WebsiteMetricsBar } from './WebsiteMetricsBar';
import { WebsitePanels } from './WebsitePanels';
import { WebsiteControls } from './WebsiteControls';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { useDynamicVariant, useDynamicColor } from '@niteshift/dials';
import { createContext } from 'react';
export const TypographyContext = createContext<{
metricLabelSize?: string;
metricValueSize?: string;
metricLabelWeight?: string;
metricValueWeight?: string;
metricLabelColor?: string;
metricValueColor?: string;
sectionHeadingSize?: string;
sectionHeadingWeight?: string;
sectionHeadingColor?: string;
}>({});
export function WebsitePage({ websiteId }: { websiteId: string }) {
// Metric Typography Controls
const metricLabelSize = useDynamicVariant('metric-label-size', {
label: 'Metric Label Size',
description: 'Font size for metric labels (Visitors, Views, etc.)',
default: '',
options: ['', '0', '1', '2', '3', '4'] as const,
group: 'Typography - Metrics',
});
const metricValueSize = useDynamicVariant('metric-value-size', {
label: 'Metric Value Size',
description: 'Font size for metric values (numbers)',
default: '8',
options: ['4', '5', '6', '7', '8', '9'] as const,
group: 'Typography - Metrics',
});
const metricLabelWeight = useDynamicVariant('metric-label-weight', {
label: 'Metric Label Weight',
description: 'Font weight for metric labels',
default: 'bold',
options: ['normal', 'medium', 'semibold', 'bold'] as const,
group: 'Typography - Metrics',
});
const metricValueWeight = useDynamicVariant('metric-value-weight', {
label: 'Metric Value Weight',
description: 'Font weight for metric values',
default: 'bold',
options: ['normal', 'medium', 'semibold', 'bold'] as const,
group: 'Typography - Metrics',
});
const metricLabelColor = useDynamicColor('metric-label-color', {
label: 'Metric Label Color',
description: 'Text color for metric labels',
default: '',
options: ['', '#000000', '#333333', '#666666', '#999999', '#3e63dd', '#30a46c', '#e5484d'],
allowCustom: true,
group: 'Typography - Metrics',
});
const metricValueColor = useDynamicColor('metric-value-color', {
label: 'Metric Value Color',
description: 'Text color for metric values',
default: '',
options: ['', '#000000', '#333333', '#666666', '#999999', '#3e63dd', '#30a46c', '#e5484d'],
allowCustom: true,
group: 'Typography - Metrics',
});
// Section Heading Controls
const sectionHeadingSize = useDynamicVariant('section-heading-size', {
label: 'Section Heading Size',
description: 'Font size for section headings (Pages, Sources, etc.)',
default: '2',
options: ['1', '2', '3', '4', '5'] as const,
group: 'Typography - Headings',
});
const sectionHeadingWeight = useDynamicVariant('section-heading-weight', {
label: 'Section Heading Weight',
description: 'Font weight for section headings',
default: 'bold',
options: ['normal', 'medium', 'semibold', 'bold'] as const,
group: 'Typography - Headings',
});
const sectionHeadingColor = useDynamicColor('section-heading-color', {
label: 'Section Heading Color',
description: 'Text color for section headings',
default: '',
options: ['', '#000000', '#333333', '#666666', '#999999', '#3e63dd', '#30a46c', '#e5484d'],
allowCustom: true,
group: 'Typography - Headings',
});
const typographyConfig = {
metricLabelSize,
metricValueSize,
metricLabelWeight,
metricValueWeight,
metricLabelColor,
metricValueColor,
sectionHeadingSize,
sectionHeadingWeight,
sectionHeadingColor,
};
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
<Panel minHeight="520px">
<WebsiteChart websiteId={websiteId} />
</Panel>
<WebsitePanels websiteId={websiteId} />
<ExpandedViewModal websiteId={websiteId} />
</Column>
<TypographyContext.Provider value={typographyConfig}>
<Column gap>
<WebsiteControls websiteId={websiteId} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
<Panel minHeight="520px">
<WebsiteChart websiteId={websiteId} />
</Panel>
<WebsitePanels websiteId={websiteId} />
<ExpandedViewModal websiteId={websiteId} />
</Column>
</TypographyContext.Provider>
);
}

View file

@ -6,10 +6,13 @@ import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
import { WorldMap } from '@/components/metrics/WorldMap';
import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { useContext } from 'react';
import { TypographyContext } from './WebsitePage';
export function WebsitePanels({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const typography = useContext(TypographyContext);
const tableProps = {
websiteId,
limit: 10,
@ -20,11 +23,25 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
const rowProps = { minHeight: '570px' };
const isSharePage = pathname.includes('/share/');
const headingStyle = {
fontWeight:
typography.sectionHeadingWeight === 'normal'
? 400
: typography.sectionHeadingWeight === 'medium'
? 500
: typography.sectionHeadingWeight === 'semibold'
? 600
: 700,
color: typography.sectionHeadingColor,
};
return (
<Grid gap="3">
<GridRow layout="two" {...rowProps}>
<Panel>
<Heading size="2">{formatMessage(labels.pages)}</Heading>
<Heading size={typography.sectionHeadingSize as any} style={headingStyle}>
{formatMessage(labels.pages)}
</Heading>
<Tabs>
<TabList>
<Tab id="path">{formatMessage(labels.path)}</Tab>
@ -43,7 +60,9 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
</Tabs>
</Panel>
<Panel>
<Heading size="2">{formatMessage(labels.sources)}</Heading>
<Heading size={typography.sectionHeadingSize as any} style={headingStyle}>
{formatMessage(labels.sources)}
</Heading>
<Tabs>
<TabList>
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
@ -65,7 +84,9 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
<GridRow layout="two" {...rowProps}>
<Panel>
<Heading size="2">{formatMessage(labels.environment)}</Heading>
<Heading size={typography.sectionHeadingSize as any} style={headingStyle}>
{formatMessage(labels.environment)}
</Heading>
<Tabs>
<TabList>
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
@ -85,7 +106,9 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
</Panel>
<Panel>
<Heading size="2">{formatMessage(labels.location)}</Heading>
<Heading size={typography.sectionHeadingSize as any} style={headingStyle}>
{formatMessage(labels.location)}
</Heading>
<Tabs>
<TabList>
<Tab id="country">{formatMessage(labels.countries)}</Tab>
@ -111,7 +134,9 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
</Panel>
<Panel>
<Heading size="2">{formatMessage(labels.traffic)}</Heading>
<Heading size={typography.sectionHeadingSize as any} style={headingStyle}>
{formatMessage(labels.traffic)}
</Heading>
<Row border="bottom" marginBottom="4" />
<WeeklyTraffic websiteId={websiteId} />
</Panel>
@ -119,7 +144,9 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
{isSharePage && (
<GridRow layout="two-one" {...rowProps}>
<Panel>
<Heading size="2">{formatMessage(labels.events)}</Heading>
<Heading size={typography.sectionHeadingSize as any} style={headingStyle}>
{formatMessage(labels.events)}
</Heading>
<Row border="bottom" marginBottom="4" />
<MetricsTable
websiteId={websiteId}

View file

@ -4,6 +4,8 @@ import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ZenProvider, RouterProvider } from '@umami/react-zen';
import { useRouter } from 'next/navigation';
import { DialsProvider, DialsOverlay } from '@niteshift/dials';
import { designManifest } from '@/config/niteshift-manifest';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { useLocale } from '@/components/hooks';
import 'chartjs-adapter-date-fns';
@ -53,7 +55,10 @@ export function Providers({ children }) {
<RouterProvider navigate={navigate}>
<MessagesProvider>
<QueryClientProvider client={client}>
<ErrorBoundary>{children}</ErrorBoundary>
<DialsProvider manifest={designManifest}>
<ErrorBoundary>{children}</ErrorBoundary>
<DialsOverlay defaultVisible={false} position="bottom-left" />
</DialsProvider>
</QueryClientProvider>
</MessagesProvider>
</RouterProvider>

View file

@ -6,6 +6,7 @@ import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/700.css';
import '@umami/react-zen/styles.css';
import '@niteshift/dials/styles.css';
import '@/styles/global.css';
import '@/styles/variables.css';

View file

@ -13,6 +13,12 @@ export interface MetricCardProps {
formatValue?: (n: any) => string;
showLabel?: boolean;
showChange?: boolean;
labelSize?: '0' | '1' | '2' | '3' | '4';
valueSize?: '4' | '5' | '6' | '7' | '8' | '9';
labelWeight?: 'normal' | 'medium' | 'semibold' | 'bold';
valueWeight?: 'normal' | 'medium' | 'semibold' | 'bold';
labelColor?: string;
valueColor?: string;
}
export const MetricCard = ({
@ -23,6 +29,12 @@ export const MetricCard = ({
formatValue = formatNumber,
showLabel = true,
showChange = false,
labelSize,
valueSize,
labelWeight,
valueWeight,
labelColor,
valueColor,
}: MetricCardProps) => {
const diff = value - change;
const pct = ((value - diff) / diff) * 100;
@ -39,11 +51,21 @@ export const MetricCard = ({
border
>
{showLabel && (
<Text weight="bold" wrap="nowrap">
<Text
{...(labelSize && { size: labelSize })}
weight={labelWeight || 'bold'}
wrap="nowrap"
{...(labelColor && { style: { color: labelColor } })}
>
{label}
</Text>
)}
<Text size="8" weight="bold" wrap="nowrap">
<Text
size={valueSize || '8'}
weight={valueWeight || 'bold'}
wrap="nowrap"
{...(valueColor && { style: { color: valueColor } })}
>
<AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
</Text>
{showChange && (

View file

@ -0,0 +1,198 @@
/**
* Niteshift Dials Design System Manifest
*
* This file defines the available design tokens for the Umami design system.
* These tokens are used by the Dials SDK to provide preset options for
* color, spacing, typography, and other design parameters.
*/
import type { DesignManifest } from '@niteshift/dials';
export const designManifest: DesignManifest = {
name: 'Umami Design System',
version: '1.0.0',
colors: {
primary: {
label: 'Primary Colors',
values: ['#147af3', '#2680eb', '#0090ff', '#3e63dd', '#5b5bd6'],
},
base: {
label: 'Base Colors (Light Theme)',
values: [
'#fcfcfc',
'#f9f9f9',
'#f0f0f0',
'#e8e8e8',
'#e0e0e0',
'#d9d9d9',
'#cecece',
'#bbbbbb',
'#8d8d8d',
'#838383',
'#646464',
'#202020',
],
},
baseDark: {
label: 'Base Colors (Dark Theme)',
values: [
'#111111',
'#191919',
'#222222',
'#2a2a2a',
'#313131',
'#3a3a3a',
'#484848',
'#606060',
'#6e6e6e',
'#7b7b7b',
'#b4b4b4',
'#eeeeee',
],
},
accent: {
label: 'Accent Colors',
values: {
gray: '#8d8d8d',
blue: '#0090ff',
indigo: '#3e63dd',
purple: '#8e4ec6',
violet: '#6e56cf',
pink: '#d6409f',
red: '#e5484d',
orange: '#f76b15',
amber: '#ffc53d',
yellow: '#ffe629',
green: '#30a46c',
teal: '#12a594',
cyan: '#00a2c7',
},
},
semantic: {
label: 'Semantic Colors',
values: {
success: '#30a46c',
danger: '#e5484d',
warning: '#f76b15',
info: '#0090ff',
},
},
},
spacing: {
label: 'Spacing Scale',
values: [
'4px',
'8px',
'12px',
'16px',
'24px',
'32px',
'40px',
'48px',
'64px',
'80px',
'96px',
'128px',
],
variables: [
'var(--spacing-1)',
'var(--spacing-2)',
'var(--spacing-3)',
'var(--spacing-4)',
'var(--spacing-5)',
'var(--spacing-6)',
'var(--spacing-7)',
'var(--spacing-8)',
'var(--spacing-9)',
'var(--spacing-10)',
'var(--spacing-11)',
'var(--spacing-12)',
],
},
typography: {
fontFamilies: {
label: 'Font Families',
values: ['Inter', 'system-ui', '-apple-system', 'JetBrains Mono'],
},
fontSizes: {
label: 'Font Sizes',
values: [
'11px',
'12px',
'14px',
'16px',
'18px',
'24px',
'30px',
'36px',
'48px',
'60px',
'72px',
'96px',
],
variables: [
'var(--font-size-1)',
'var(--font-size-2)',
'var(--font-size-3)',
'var(--font-size-4)',
'var(--font-size-5)',
'var(--font-size-6)',
'var(--font-size-7)',
'var(--font-size-8)',
'var(--font-size-9)',
'var(--font-size-10)',
'var(--font-size-11)',
'var(--font-size-12)',
],
},
fontWeights: {
label: 'Font Weights',
values: ['300', '400', '500', '600', '700', '800', '900'],
labels: ['Light', 'Regular', 'Medium', 'Semi Bold', 'Bold', 'Extra Bold', 'Black'],
},
headingSizes: {
label: 'Heading Sizes',
values: ['16px', '20px', '24px', '32px', '42px', '60px'],
variables: [
'var(--heading-size-1)',
'var(--heading-size-2)',
'var(--heading-size-3)',
'var(--heading-size-4)',
'var(--heading-size-5)',
'var(--heading-size-6)',
],
},
},
borderRadius: {
label: 'Border Radius',
values: ['2px', '4px', '8px', '16px', '9999px'],
variables: [
'var(--border-radius-1)',
'var(--border-radius-2)',
'var(--border-radius-3)',
'var(--border-radius-4)',
'var(--border-radius-full)',
],
labels: ['Small', 'Default', 'Medium', 'Large', 'Full'],
},
shadows: {
label: 'Box Shadows',
values: [
'0 1px 2px 0 rgb(0 0 0 / 0.05)',
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
'0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
],
variables: [
'var(--box-shadow-1)',
'var(--box-shadow-2)',
'var(--box-shadow-3)',
'var(--box-shadow-4)',
'var(--box-shadow-5)',
'var(--box-shadow-6)',
],
labels: ['Extra Small', 'Small', 'Medium', 'Large', 'Extra Large', '2XL'],
},
};