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

@ -0,0 +1,7 @@
{
"rules": {
"import/no-unresolved": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off"
}
}

View file

@ -0,0 +1,224 @@
/**
* Tests for the dial registry
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { getDialRegistry } from '../registry';
import type { DialConfig } from '../types';
describe('DialRegistry', () => {
let registry: ReturnType<typeof getDialRegistry>;
beforeEach(() => {
// Get fresh registry instance
registry = getDialRegistry();
// Clear all registered dials
registry.resetAll();
localStorage.clear();
});
describe('register', () => {
it('should register a new dial with default value', () => {
const config: DialConfig = {
type: 'color',
label: 'Test Color',
default: '#ff0000',
options: ['#ff0000', '#00ff00', '#0000ff'],
};
const value = registry.register('test-dial', 'color', config);
expect(value).toBe('#ff0000');
});
it('should return existing value for already registered dial', () => {
const config: DialConfig = {
type: 'color',
label: 'Test Color',
default: '#ff0000',
options: ['#ff0000', '#00ff00'],
};
const value1 = registry.register('test-dial', 'color', config);
const value2 = registry.register('test-dial', 'color', config);
expect(value1).toBe(value2);
});
});
describe('setValue', () => {
it('should set a new value and notify subscribers', () => {
const config: DialConfig = {
type: 'number',
label: 'Test Number',
default: 10,
min: 0,
max: 100,
};
registry.register('test-number', 'number', config);
const callback = vi.fn();
registry.subscribe('test-number', callback);
registry.setValue('test-number', 50);
expect(callback).toHaveBeenCalledWith('test-number', 50);
expect(registry.getValue('test-number')).toBe(50);
});
it('should persist value to localStorage', () => {
const config: DialConfig = {
type: 'variant',
label: 'Test Variant',
default: 'option1',
options: ['option1', 'option2', 'option3'],
};
registry.register('test-variant', 'variant', config);
registry.setValue('test-variant', 'option2');
const stored = localStorage.getItem('niteshift-dial-test-variant');
expect(stored).toBe('"option2"');
});
});
describe('reset', () => {
it('should reset dial to default value', () => {
const config: DialConfig = {
type: 'boolean',
label: 'Test Boolean',
default: false,
};
registry.register('test-bool', 'boolean', config);
registry.setValue('test-bool', true);
expect(registry.getValue('test-bool')).toBe(true);
registry.reset('test-bool');
expect(registry.getValue('test-bool')).toBe(false);
});
it('should remove value from localStorage', () => {
const config: DialConfig = {
type: 'spacing',
label: 'Test Spacing',
default: '16px',
options: ['8px', '16px', '24px'],
};
registry.register('test-spacing', 'spacing', config);
registry.setValue('test-spacing', '24px');
expect(localStorage.getItem('niteshift-dial-test-spacing')).toBeTruthy();
registry.reset('test-spacing');
expect(localStorage.getItem('niteshift-dial-test-spacing')).toBeNull();
});
});
describe('resetAll', () => {
it('should reset all dials to defaults', () => {
registry.register('dial-1', 'color', {
type: 'color',
label: 'Color 1',
default: '#ff0000',
});
registry.register('dial-2', 'number', {
type: 'number',
label: 'Number 1',
default: 10,
});
registry.setValue('dial-1', '#00ff00');
registry.setValue('dial-2', 50);
registry.resetAll();
expect(registry.getValue('dial-1')).toBe('#ff0000');
expect(registry.getValue('dial-2')).toBe(10);
});
});
describe('persistence', () => {
it('should load value from localStorage on registration', () => {
// Simulate existing localStorage value
localStorage.setItem('niteshift-dial-persisted', '"custom-value"');
const config: DialConfig = {
type: 'variant',
label: 'Persisted Dial',
default: 'default-value',
options: ['default-value', 'custom-value'],
};
const value = registry.register('persisted', 'variant', config);
expect(value).toBe('custom-value');
});
it('should handle corrupted localStorage gracefully', () => {
// Set invalid JSON in localStorage
localStorage.setItem('niteshift-dial-corrupted', 'invalid-json{');
const config: DialConfig = {
type: 'color',
label: 'Corrupted Dial',
default: '#ff0000',
};
const value = registry.register('corrupted', 'color', config);
// Should fall back to default
expect(value).toBe('#ff0000');
});
});
describe('subscriptions', () => {
it('should notify multiple subscribers', () => {
const config: DialConfig = {
type: 'number',
label: 'Test Number',
default: 0,
};
registry.register('test-multi', 'number', config);
const callback1 = vi.fn();
const callback2 = vi.fn();
registry.subscribe('test-multi', callback1);
registry.subscribe('test-multi', callback2);
registry.setValue('test-multi', 100);
expect(callback1).toHaveBeenCalledWith('test-multi', 100);
expect(callback2).toHaveBeenCalledWith('test-multi', 100);
});
it('should unsubscribe correctly', () => {
const config: DialConfig = {
type: 'boolean',
label: 'Test Boolean',
default: false,
};
registry.register('test-unsub', 'boolean', config);
const callback = vi.fn();
const unsubscribe = registry.subscribe('test-unsub', callback);
registry.setValue('test-unsub', true);
expect(callback).toHaveBeenCalledTimes(1);
unsubscribe();
registry.setValue('test-unsub', false);
// Should still be 1 (not called again)
expect(callback).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,10 @@
/**
* Test setup file for Vitest
*/
import { afterEach } from 'vitest';
// Clean up localStorage after each test
afterEach(() => {
localStorage.clear();
});

View file

@ -0,0 +1,153 @@
/**
* Tests for useDynamicColor hook
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDynamicColor } from '../hooks/useDynamicColor';
import { getDialRegistry } from '../registry';
import { DialsProvider } from '../components/DialsProvider';
import type { DesignManifest } from '../types';
// Wrapper with DialsProvider
function createWrapper(manifest?: DesignManifest | null) {
return ({ children }: { children: React.ReactNode }) => (
<DialsProvider manifest={manifest}>{children}</DialsProvider>
);
}
describe('useDynamicColor', () => {
let registry: ReturnType<typeof getDialRegistry>;
beforeEach(() => {
registry = getDialRegistry();
registry.resetAll();
localStorage.clear();
});
it('should return default color value', () => {
const { result } = renderHook(
() =>
useDynamicColor('test-color', {
label: 'Test Color',
default: '#ff0000',
options: ['#ff0000', '#00ff00', '#0000ff'],
}),
{ wrapper: createWrapper() },
);
expect(result.current).toBe('#ff0000');
});
it('should update when registry value changes', () => {
const { result } = renderHook(
() =>
useDynamicColor('reactive-color', {
label: 'Reactive Color',
default: '#ff0000',
options: ['#ff0000', '#00ff00', '#0000ff'],
}),
{ wrapper: createWrapper() },
);
expect(result.current).toBe('#ff0000');
// Update via registry
act(() => {
registry.setValue('reactive-color', '#00ff00');
});
expect(result.current).toBe('#00ff00');
});
it('should use manifest defaults when options not provided', () => {
const manifest: DesignManifest = {
name: 'Test Manifest',
version: '1.0.0',
colors: {
accent: {
label: 'Accent Colors',
values: {
blue: '#0090ff',
green: '#30a46c',
red: '#e5484d',
},
},
},
};
const { result } = renderHook(
() =>
useDynamicColor('manifest-color', {
label: 'Manifest Color',
default: '#0090ff',
// No options - should use manifest
}),
{ wrapper: createWrapper(manifest) },
);
expect(result.current).toBe('#0090ff');
// Verify the dial was registered with manifest options
const dial = registry.getAllDials().find(d => d.id === 'manifest-color');
expect(dial?.config.options).toEqual(['#0090ff', '#30a46c', '#e5484d']);
});
it('should prefer explicit options over manifest', () => {
const manifest: DesignManifest = {
name: 'Test Manifest',
version: '1.0.0',
colors: {
accent: {
label: 'Accent Colors',
values: ['#0090ff', '#30a46c'],
},
},
};
const explicitOptions = ['#ff0000', '#00ff00'];
const { result } = renderHook(
() =>
useDynamicColor('explicit-color', {
label: 'Explicit Color',
default: '#ff0000',
options: explicitOptions,
}),
{ wrapper: createWrapper(manifest) },
);
const dial = registry.getAllDials().find(d => d.id === 'explicit-color');
expect(dial?.config.options).toEqual(explicitOptions);
});
it('should handle manifest category selection', () => {
const manifest: DesignManifest = {
name: 'Test Manifest',
version: '1.0.0',
colors: {
primary: {
label: 'Primary Colors',
values: ['#147af3', '#2680eb'],
},
accent: {
label: 'Accent Colors',
values: ['#0090ff', '#30a46c'],
},
},
};
const { result } = renderHook(
() =>
useDynamicColor('primary-color', {
label: 'Primary Color',
default: '#147af3',
manifestCategory: 'primary',
} as any), // manifestCategory is not in type yet, but handled in implementation
{ wrapper: createWrapper(manifest) },
);
const dial = registry.getAllDials().find(d => d.id === 'primary-color');
expect(dial?.config.options).toEqual(['#147af3', '#2680eb']);
});
});

View file

@ -0,0 +1,384 @@
/**
* Main overlay UI component for displaying and controlling dials
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Gauge } from 'lucide-react';
import { getDialRegistry } from '../registry';
import { ColorControl } from '../controls/ColorControl';
import { SpacingControl } from '../controls/SpacingControl';
import { VariantControl } from '../controls/VariantControl';
import { BooleanControl } from '../controls/BooleanControl';
import { NumberControl } from '../controls/NumberControl';
import type { DialRegistration } from '../types';
export interface DialsOverlayProps {
/** Initial visibility state */
defaultVisible?: boolean;
/** Position of the overlay */
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
}
/**
* Overlay UI for controlling dials
* Should be rendered at the root level of your app
*
* @example
* ```typescript
* <DialsProvider>
* <App />
* <DialsOverlay defaultVisible={false} toggleKey="k" position="bottom-right" />
* </DialsProvider>
* ```
*/
export function DialsOverlay({
defaultVisible = true,
position = 'bottom-left',
}: DialsOverlayProps) {
// Load visibility state from localStorage (avoiding hydration mismatch)
const [isVisible, setIsVisible] = useState(defaultVisible);
// Load from localStorage after mount to avoid hydration issues
useEffect(() => {
const stored = localStorage.getItem('niteshift-dials-visible');
if (stored !== null) {
setIsVisible(stored === 'true');
}
}, []);
const [searchTerm, setSearchTerm] = useState('');
const [dials, setDials] = useState<DialRegistration[]>([]);
const [hasNextOverlay, setHasNextOverlay] = useState(false);
const [isMacLike, setIsMacLike] = useState(false);
const [shortcutLabel, setShortcutLabel] = useState('Ctrl+D (macOS) / Ctrl+Alt+D (Win/Linux)');
const registry = getDialRegistry();
// Persist visibility state to localStorage
useEffect(() => {
localStorage.setItem('niteshift-dials-visible', String(isVisible));
}, [isVisible]);
// Detect Next.js error overlay
useEffect(() => {
const checkNextOverlay = () => {
// Next.js error overlay has specific identifiers
const nextjsOverlay =
document.querySelector('nextjs-portal') ||
document.querySelector('[data-nextjs-dialog-overlay]') ||
document.querySelector('[data-nextjs-toast]');
setHasNextOverlay(!!nextjsOverlay);
};
// Check on mount and set up observer
checkNextOverlay();
const observer = new MutationObserver(checkNextOverlay);
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
// Subscribe to registry changes
useEffect(() => {
const updateDials = () => {
setDials(registry.getAllDials());
};
// Initial load
updateDials();
// Subscribe to changes
const unsubscribe = registry.subscribeToRegistry(updateDials);
return unsubscribe;
}, [registry]);
// Detect platform to configure shortcut labels
useEffect(() => {
if (typeof navigator === 'undefined') return;
const isMac = /Mac|iPhone|iPod|iPad/i.test(navigator.platform);
setIsMacLike(isMac);
}, []);
useEffect(() => {
setShortcutLabel(isMacLike ? 'Ctrl+D (macOS)' : 'Ctrl+Alt+D (Windows/Linux)');
}, [isMacLike]);
// Keyboard shortcut to toggle visibility
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
if (key !== 'd') return;
const macCombo = isMacLike && e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
const otherCombo = !isMacLike && e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey;
if (macCombo || otherCombo) {
e.preventDefault();
setIsVisible(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [isMacLike]);
// Filter and group dials
const filteredDials = useMemo(() => {
if (!searchTerm) return dials;
const term = searchTerm.toLowerCase();
return dials.filter(dial => {
const label = dial.config.label.toLowerCase();
const group = dial.config.group?.toLowerCase() || '';
const id = dial.id.toLowerCase();
return label.includes(term) || group.includes(term) || id.includes(term);
});
}, [dials, searchTerm]);
const groupedDials = useMemo(() => {
const groups = new Map<string, DialRegistration[]>();
for (const dial of filteredDials) {
const group = dial.config.group || 'Ungrouped';
if (!groups.has(group)) {
groups.set(group, []);
}
groups.get(group)!.push(dial);
}
return groups;
}, [filteredDials]);
const handleChange = (id: string, value: any) => {
registry.setValue(id, value);
};
const handleReset = (id: string) => {
registry.reset(id);
};
const handleResetAll = () => {
if (confirm('Reset all dials to their default values?')) {
registry.resetAll();
}
};
// Calculate bottom position based on Next.js overlay presence
const bottomPosition = hasNextOverlay ? '140px' : '20px';
if (!isVisible) {
return (
<button
className="dials-toggle-button"
onClick={() => setIsVisible(true)}
title={`Show Dials (${shortcutLabel})`}
style={{
position: 'fixed',
[position.includes('bottom') ? 'bottom' : 'top']: position.includes('bottom')
? bottomPosition
: '20px',
[position.includes('right') ? 'right' : 'left']: '20px',
width: '48px',
height: '48px',
borderRadius: '50%',
border: '2px solid #666',
background: '#1a1a1a',
color: '#fff',
fontSize: '20px',
cursor: 'pointer',
zIndex: 9999999, // Very high to be above everything
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Gauge size={24} />
</button>
);
}
return (
<div
className="dials-overlay"
style={{
position: 'fixed',
[position.includes('bottom') ? 'bottom' : 'top']: position.includes('bottom')
? bottomPosition
: '20px',
[position.includes('right') ? 'right' : 'left']: '20px',
width: '320px',
maxHeight: '80vh',
background: '#181c20',
border: '1px solid #292d39',
borderRadius: '4px',
boxShadow: '0 4px 14px rgba(0,0,0,0.4)',
zIndex: 9999999, // Very high to be above everything
display: 'flex',
flexDirection: 'column',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '11px',
}}
>
{/* Header */}
<div
style={{
padding: '10px 12px',
borderBottom: '1px solid #292d39',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Gauge size={16} color="#8c92a4" />
<div>
<h3 style={{ margin: 0, fontSize: '13px', fontWeight: 500, color: '#fefefe' }}>
Design Dials
</h3>
<div style={{ fontSize: '10px', color: '#8c92a4', marginTop: '2px' }}>
{shortcutLabel}
</div>
</div>
</div>
<button
onClick={() => setIsVisible(false)}
style={{
background: 'none',
border: 'none',
fontSize: '20px',
cursor: 'pointer',
padding: 0,
lineHeight: 1,
color: '#8c92a4',
}}
title="Close (Shift+Cmd/Ctrl+D)"
>
×
</button>
</div>
{/* Search */}
<div style={{ padding: '8px 12px', borderBottom: '1px solid #292d39' }}>
<input
type="search"
placeholder="Search dials..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid transparent',
borderRadius: '2px',
fontSize: '11px',
background: '#373c4b',
color: '#fefefe',
outline: 'none',
}}
/>
</div>
{/* Dials list */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '0',
}}
>
{filteredDials.length === 0 ? (
<div style={{ textAlign: 'center', color: '#8c92a4', padding: '32px 16px' }}>
{searchTerm ? 'No dials match your search' : 'No dials registered yet'}
</div>
) : (
Array.from(groupedDials.entries()).map(([groupName, groupDials]) => (
<div key={groupName} style={{ marginBottom: '0' }}>
<h4
style={{
margin: '0',
padding: '8px 12px',
fontSize: '10px',
fontWeight: 500,
color: '#b4b4b4',
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: '#292d39',
borderBottom: '1px solid #373c4b',
}}
>
{groupName}
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
{groupDials.map(dial => (
<div key={dial.id}>{renderControl(dial, handleChange, handleReset)}</div>
))}
</div>
</div>
))
)}
</div>
{/* Footer */}
<div
style={{
padding: '8px 12px',
borderTop: '1px solid #292d39',
display: 'flex',
gap: '8px',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<button
onClick={handleResetAll}
disabled={dials.length === 0}
style={{
padding: '4px 12px',
background: '#373c4b',
border: '1px solid transparent',
borderRadius: '2px',
cursor: dials.length > 0 ? 'pointer' : 'not-allowed',
fontSize: '11px',
color: dials.length > 0 ? '#fefefe' : '#535760',
transition: 'border-color 0.15s',
}}
>
Reset All
</button>
<div style={{ fontSize: '11px', color: '#b4b4b4', display: 'flex', alignItems: 'center' }}>
{dials.length} dial{dials.length !== 1 ? 's' : ''}
</div>
</div>
</div>
);
}
/**
* Render the appropriate control component based on dial type
*/
function renderControl(
dial: DialRegistration,
onChange: (id: string, value: any) => void,
onReset: (id: string) => void,
) {
const commonProps = {
id: dial.id,
value: dial.currentValue,
onChange: (value: any) => onChange(dial.id, value),
onReset: () => onReset(dial.id),
};
switch (dial.type) {
case 'color':
return <ColorControl {...commonProps} config={dial.config as any} />;
case 'spacing':
return <SpacingControl {...commonProps} config={dial.config as any} />;
case 'variant':
return <VariantControl {...commonProps} config={dial.config as any} />;
case 'boolean':
return <BooleanControl {...commonProps} config={dial.config as any} />;
case 'number':
return <NumberControl {...commonProps} config={dial.config as any} />;
default:
return null;
}
}

View file

@ -0,0 +1,58 @@
/**
* React context provider for dials
* Provides access to the design manifest and configuration
*/
import React, { createContext, useContext, useEffect, type ReactNode } from 'react';
import { getDialRegistry } from '../registry';
import type { DesignManifest } from '../types';
interface DialsContextValue {
manifest: DesignManifest | null;
}
const DialsContext = createContext<DialsContextValue>({
manifest: null,
});
export interface DialsProviderProps {
children: ReactNode;
/** Design system manifest (imported from config) */
manifest?: DesignManifest | null;
/** Optional project ID for scoping localStorage */
projectId?: string;
}
/**
* Provider component for dials
* Should wrap your app at the root level
*
* @example
* ```typescript
* import { designManifest } from '@/config/niteshift-manifest';
*
* <DialsProvider manifest={designManifest}>
* <App />
* <DialsOverlay />
* </DialsProvider>
* ```
*/
export function DialsProvider({ children, manifest = null, projectId }: DialsProviderProps) {
useEffect(() => {
// Set project ID if provided
if (projectId) {
const registry = getDialRegistry();
registry.setProjectId(projectId);
}
}, [projectId]);
return <DialsContext.Provider value={{ manifest }}>{children}</DialsContext.Provider>;
}
/**
* Hook to access the dials context
* @returns The dials context value (manifest)
*/
export function useDialsContext(): DialsContextValue {
return useContext(DialsContext);
}

View file

@ -0,0 +1,48 @@
/**
* Boolean control component for the overlay UI
*/
import React from 'react';
import type { BooleanDialConfig } from '../types';
export interface BooleanControlProps {
id: string;
value: boolean;
config: BooleanDialConfig;
onChange: (value: boolean) => void;
onReset: () => void;
}
export function BooleanControl({ id, value, config, onChange, onReset }: BooleanControlProps) {
const trueLabel = config.trueLabel || 'On';
const falseLabel = config.falseLabel || 'Off';
return (
<div className="dial-control boolean-control">
<div className="control-header">
<label htmlFor={id}>{config.label}</label>
{config.description && <span className="control-description">{config.description}</span>}
<button className="reset-button" onClick={onReset} title="Reset to default">
</button>
</div>
<div className="control-body">
<div className="boolean-toggle">
<button
className={`toggle-option ${value ? 'active' : ''}`}
onClick={() => onChange(true)}
>
{trueLabel}
</button>
<button
className={`toggle-option ${!value ? 'active' : ''}`}
onClick={() => onChange(false)}
>
{falseLabel}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,152 @@
/**
* Color control component for the overlay UI
*/
import React, { useState, useRef, useEffect } from 'react';
import type { ColorDialConfig } from '../types';
import { designManifest } from '@/config/niteshift-manifest';
export interface ColorControlProps {
id: string;
value: string;
config: ColorDialConfig;
onChange: (value: string) => void;
onReset: () => void;
}
// Helper to get color name from design system
function getColorName(hex: string): string | null {
const normalizedHex = hex.toLowerCase();
// Check accent colors
if (designManifest.colors.accent.values) {
for (const [name, color] of Object.entries(designManifest.colors.accent.values)) {
if (color.toLowerCase() === normalizedHex) {
return name.charAt(0).toUpperCase() + name.slice(1);
}
}
}
// Check semantic colors
if (designManifest.colors.semantic.values) {
for (const [name, color] of Object.entries(designManifest.colors.semantic.values)) {
if (color.toLowerCase() === normalizedHex) {
return name.charAt(0).toUpperCase() + name.slice(1);
}
}
}
return null;
}
export function ColorControl({ id, value, config, onChange, onReset }: ColorControlProps) {
const [showPicker, setShowPicker] = useState(false);
const [pickerPosition, setPickerPosition] = useState({ top: 0, left: 0 });
const swatchRef = useRef<HTMLDivElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const colorName = getColorName(value);
const handleSwatchClick = () => {
if (swatchRef.current) {
const rect = swatchRef.current.getBoundingClientRect();
setPickerPosition({
top: rect.bottom + 4,
left: rect.left,
});
setShowPicker(true);
}
};
const handlePresetClick = (color: string) => {
onChange(color);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
// Close picker when clicking outside
useEffect(() => {
if (!showPicker) return;
const handleClickOutside = (e: MouseEvent) => {
if (
pickerRef.current &&
!pickerRef.current.contains(e.target as Node) &&
swatchRef.current &&
!swatchRef.current.contains(e.target as Node)
) {
setShowPicker(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPicker]);
return (
<div className="dial-control color-control">
<div className="control-header">
<label htmlFor={id} title={config.description}>
{config.label}
</label>
<button className="reset-button" onClick={onReset} title="Reset to default">
</button>
</div>
<div className="control-body">
{/* Swatch */}
<div
ref={swatchRef}
className="color-swatch"
style={{ backgroundColor: value }}
onClick={handleSwatchClick}
title={colorName || value}
/>
{/* Input */}
<input
type="text"
className="color-value-input"
value={colorName || value}
onChange={handleInputChange}
placeholder="#000000"
/>
{/* Popover picker */}
{showPicker && (
<>
<div className="color-picker-overlay" onClick={() => setShowPicker(false)} />
<div
ref={pickerRef}
className="color-picker-wrapper"
style={{
top: pickerPosition.top,
left: pickerPosition.left,
}}
>
{/* Preset colors */}
{config.options && config.options.length > 0 && (
<div className="color-presets">
{config.options.map(color => {
const name = getColorName(color);
return (
<div
key={color}
className={`color-preset ${value === color ? 'active' : ''}`}
style={{ backgroundColor: color }}
onClick={() => handlePresetClick(color)}
title={name || color}
/>
);
})}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,85 @@
/**
* Number control component for the overlay UI
*/
import React from 'react';
import type { NumberDialConfig } from '../types';
export interface NumberControlProps {
id: string;
value: number;
config: NumberDialConfig;
onChange: (value: number) => void;
onReset: () => void;
}
export function NumberControl({ id, value, config, onChange, onReset }: NumberControlProps) {
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(Number(e.target.value));
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const num = Number(e.target.value);
if (!isNaN(num)) {
onChange(num);
}
};
const hasRange = config.min !== undefined && config.max !== undefined;
return (
<div className="dial-control number-control">
<div className="control-header">
<label htmlFor={id}>{config.label}</label>
{config.description && <span className="control-description">{config.description}</span>}
<button className="reset-button" onClick={onReset} title="Reset to default">
</button>
</div>
<div className="control-body">
{hasRange && (
<>
{/* Slider */}
<div className="number-slider">
<input
type="range"
min={config.min}
max={config.max}
step={config.step || 1}
value={value}
onChange={handleSliderChange}
title={`${config.min} - ${config.max}`}
/>
</div>
{/* Input */}
<div className="number-input">
<input
type="number"
value={value}
onChange={handleInputChange}
min={config.min}
max={config.max}
step={config.step || 1}
/>
{config.unit && <span className="number-unit">{config.unit}</span>}
</div>
</>
)}
{!hasRange && (
<div className="number-input" style={{ gridColumn: '1 / -1' }}>
<input
type="number"
value={value}
onChange={handleInputChange}
min={config.min}
max={config.max}
step={config.step || 1}
/>
{config.unit && <span className="number-unit">{config.unit}</span>}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
/**
* Spacing control component for the overlay UI
*/
import React from 'react';
import type { SpacingDialConfig } from '../types';
export interface SpacingControlProps {
id: string;
value: string;
config: SpacingDialConfig;
onChange: (value: string) => void;
onReset: () => void;
}
export function SpacingControl({ id, value, config, onChange, onReset }: SpacingControlProps) {
return (
<div className="dial-control spacing-control">
<div className="control-header">
<label htmlFor={id}>{config.label}</label>
{config.description && <span className="control-description">{config.description}</span>}
<button className="reset-button" onClick={onReset} title="Reset to default">
</button>
</div>
<div className="control-body">
{/* Preset spacing values */}
{config.options && config.options.length > 0 && (
<div className="spacing-options">
{config.options.map(spacing => (
<button
key={spacing}
className={`spacing-option ${value === spacing ? 'active' : ''}`}
onClick={() => onChange(spacing)}
>
{spacing}
</button>
))}
</div>
)}
{/* Custom spacing input */}
{config.allowCustom && (
<div className="spacing-custom">
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={`e.g., 16${config.unit || 'px'}`}
/>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
/**
* Variant control component for the overlay UI
*/
import React from 'react';
import type { VariantDialConfig } from '../types';
export interface VariantControlProps {
id: string;
value: string;
config: VariantDialConfig;
onChange: (value: string) => void;
onReset: () => void;
}
export function VariantControl({ id, value, config, onChange, onReset }: VariantControlProps) {
// Check if all options are numeric strings
const allNumeric = config.options.every(opt => !isNaN(Number(opt)));
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const index = Number(e.target.value);
onChange(config.options[index]);
};
const currentIndex = config.options.indexOf(value);
return (
<div className="dial-control variant-control">
<div className="control-header">
<label htmlFor={id}>{config.label}</label>
{config.description && <span className="control-description">{config.description}</span>}
<button className="reset-button" onClick={onReset} title="Reset to default">
</button>
</div>
<div className="control-body">
{allNumeric ? (
<div className="variant-slider">
<input
type="range"
min={0}
max={config.options.length - 1}
step={1}
value={currentIndex}
onChange={handleSliderChange}
title={`${config.options[0]} - ${config.options[config.options.length - 1]}`}
/>
</div>
) : (
<select className="variant-select" value={value} onChange={e => onChange(e.target.value)}>
{config.options.map(option => {
const label = config.optionLabels?.[option] || option;
return (
<option key={option} value={option}>
{label}
</option>
);
})}
</select>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,36 @@
/**
* Core React hook for creating a dynamic dial
* This is the base hook that all specific dial hooks use internally
*/
import { useState, useEffect } from 'react';
import { getDialRegistry } from '../registry';
import type { DialType, DialConfig } from '../types';
/**
* Core hook for creating a dial
* Registers the dial in the global registry and subscribes to changes
*
* @param id - Unique identifier for this dial
* @param type - Type of dial
* @param config - Configuration for the dial
* @returns Current value of the dial
*/
export function useDial<T>(id: string, type: DialType, config: DialConfig): T {
const registry = getDialRegistry();
// Register the dial and get initial value
const [value, setValue] = useState<T>(() => registry.register<T>(id, type, config));
useEffect(() => {
// Subscribe to changes for this specific dial
const unsubscribe = registry.subscribe(id, (dialId, newValue) => {
setValue(newValue);
});
// Cleanup subscription on unmount
return unsubscribe;
}, [id, registry]);
return value;
}

View file

@ -0,0 +1,26 @@
/**
* React hook for creating a boolean dial (toggle)
*/
import { useDial } from './useDial';
import type { BooleanDialConfig } from '../types';
/**
* Create a dynamic boolean dial for toggles and feature flags
*
* @example
* ```typescript
* const showMetrics = useDynamicBoolean('show-metrics', {
* label: 'Show Metrics',
* default: true,
* trueLabel: 'Visible',
* falseLabel: 'Hidden',
* group: 'Dashboard'
* });
*
* {showMetrics && <MetricsPanel />}
* ```
*/
export function useDynamicBoolean(id: string, config: Omit<BooleanDialConfig, 'type'>): boolean {
return useDial<boolean>(id, 'boolean', { ...config, type: 'boolean' });
}

View file

@ -0,0 +1,52 @@
/**
* React hook for creating a color dial
*/
import { useDial } from './useDial';
import { useDialsContext } from '../components/DialsProvider';
import type { ColorDialConfig } from '../types';
/**
* Create a dynamic color dial
*
* When options are not provided, automatically pulls color values from the
* design manifest (if available). Supports manifest categories like 'primary',
* 'accent', 'semantic', etc.
*
* @example
* ```typescript
* // With explicit options:
* const bgColor = useDynamicColor('hero-bg', {
* label: 'Background Color',
* default: '#1a1a1a',
* options: ['#1a1a1a', '#2d2d2d', '#404040'],
* group: 'Hero Section'
* });
*
* // With manifest defaults (auto-populated from designManifest.colors.accent):
* const accentColor = useDynamicColor('accent', {
* label: 'Accent Color',
* default: '#3e63dd',
* manifestCategory: 'accent', // pulls from manifest
* });
* ```
*/
export function useDynamicColor(id: string, config: Omit<ColorDialConfig, 'type'>): string {
const { manifest } = useDialsContext();
// Build config with optional manifest defaults
const finalConfig: ColorDialConfig = { ...config, type: 'color' as const };
// If options not provided and we have a manifest, try to load from manifest
if (!config.options && manifest?.colors) {
const category = (config as any).manifestCategory || 'accent';
const colorCategory = manifest.colors[category];
if (colorCategory?.values) {
const values = colorCategory.values;
finalConfig.options = Array.isArray(values) ? values : Object.values(values);
}
}
return useDial<string>(id, 'color', finalConfig);
}

View file

@ -0,0 +1,28 @@
/**
* React hook for creating a number dial
*/
import { useDial } from './useDial';
import type { NumberDialConfig } from '../types';
/**
* Create a dynamic number dial
*
* @example
* ```typescript
* const chartHeight = useDynamicNumber('chart-height', {
* label: 'Chart Height',
* default: 400,
* min: 200,
* max: 800,
* step: 50,
* unit: 'px',
* group: 'Chart'
* });
*
* <Chart height={chartHeight} />
* ```
*/
export function useDynamicNumber(id: string, config: Omit<NumberDialConfig, 'type'>): number {
return useDial<number>(id, 'number', { ...config, type: 'number' });
}

View file

@ -0,0 +1,44 @@
/**
* React hook for creating a spacing dial
*/
import { useDial } from './useDial';
import { useDialsContext } from '../components/DialsProvider';
import type { SpacingDialConfig } from '../types';
/**
* Create a dynamic spacing dial
*
* When options are not provided, automatically pulls spacing values from the
* design manifest's spacing scale (if available).
*
* @example
* ```typescript
* // With explicit options:
* const padding = useDynamicSpacing('card-padding', {
* label: 'Card Padding',
* default: '1rem',
* options: ['0.5rem', '1rem', '1.5rem', '2rem'],
* group: 'Card'
* });
*
* // With manifest defaults (auto-populated from designManifest.spacing):
* const margin = useDynamicSpacing('section-margin', {
* label: 'Section Margin',
* default: '24px', // pulls options from manifest.spacing.values
* });
* ```
*/
export function useDynamicSpacing(id: string, config: Omit<SpacingDialConfig, 'type'>): string {
const { manifest } = useDialsContext();
// Build config with optional manifest defaults
const finalConfig: SpacingDialConfig = { ...config, type: 'spacing' as const };
// If options not provided and we have a manifest, load from manifest spacing
if (!config.options && manifest?.spacing?.values) {
finalConfig.options = manifest.spacing.values;
}
return useDial<string>(id, 'spacing', finalConfig);
}

View file

@ -0,0 +1,29 @@
/**
* React hook for creating a variant dial
*/
import { useDial } from './useDial';
import type { VariantDialConfig } from '../types';
/**
* Create a dynamic variant dial for discrete choices
*
* @example
* ```typescript
* const layout = useDynamicVariant('dashboard-layout', {
* label: 'Layout Style',
* default: 'grid',
* options: ['grid', 'list', 'compact'] as const,
* group: 'Dashboard'
* });
*
* {layout === 'grid' && <GridView />}
* {layout === 'list' && <ListView />}
* ```
*/
export function useDynamicVariant<T extends string>(
id: string,
config: Omit<VariantDialConfig<T>, 'type'>,
): T {
return useDial<T>(id, 'variant', { ...config, type: 'variant' });
}

View file

@ -0,0 +1,42 @@
/**
* Dials SDK - Runtime design parameter controls for rapid prototyping
* @packageDocumentation
*/
// Core types
export type {
DialType,
DialConfig,
ColorDialConfig,
SpacingDialConfig,
VariantDialConfig,
BooleanDialConfig,
NumberDialConfig,
DialRegistration,
DesignManifest,
} from './types';
// React hooks
export { useDynamicColor } from './hooks/useDynamicColor';
export { useDynamicSpacing } from './hooks/useDynamicSpacing';
export { useDynamicVariant } from './hooks/useDynamicVariant';
export { useDynamicBoolean } from './hooks/useDynamicBoolean';
export { useDynamicNumber } from './hooks/useDynamicNumber';
// Components
export { DialsProvider, useDialsContext } from './components/DialsProvider';
export { DialsOverlay } from './components/DialsOverlay';
// Registry (for advanced usage)
export { getDialRegistry } from './registry';
// Manifest utilities
export {
loadManifest,
getManifestColors,
getManifestSpacing,
getManifestTypography,
getManifestBorderRadius,
getManifestShadows,
buildColorOptions,
} from './utils/manifest';

View file

@ -0,0 +1,314 @@
/**
* Global dial registry - singleton that manages all dials
* Survives hot reloads by using a singleton pattern
* Persists values to localStorage
*/
import type {
DialType,
DialConfig,
DialRegistration,
DialChangeListener,
DialRegistryListener,
} from './types';
const STORAGE_KEY_PREFIX = 'niteshift-dials';
const STORAGE_VERSION = '1';
/**
* Get the storage key for persisting dial values
* Can be scoped by project ID in the future
*/
function getStorageKey(projectId?: string): string {
return projectId
? `${STORAGE_KEY_PREFIX}-${projectId}-v${STORAGE_VERSION}`
: `${STORAGE_KEY_PREFIX}-v${STORAGE_VERSION}`;
}
/**
* Singleton registry for all dials
*/
class DialRegistry {
private static instance: DialRegistry | null = null;
/** All registered dials */
private dials = new Map<string, DialRegistration>();
/** Listeners for specific dial changes */
private changeListeners = new Map<string, Set<DialChangeListener>>();
/** Listeners for any registry change (for overlay UI) */
private registryListeners = new Set<DialRegistryListener>();
/** Project ID for storage scoping */
private projectId?: string;
private constructor() {
// Load persisted values on initialization
this.loadFromStorage();
}
/**
* Get the singleton instance
*/
static getInstance(): DialRegistry {
if (!DialRegistry.instance) {
DialRegistry.instance = new DialRegistry();
}
return DialRegistry.instance;
}
/**
* Set the project ID for storage scoping
*/
setProjectId(projectId: string): void {
this.projectId = projectId;
this.loadFromStorage();
}
/**
* Register a new dial or get existing value
* Returns the current value (persisted or default)
*/
register<T>(id: string, type: DialType, config: DialConfig): T {
// If already registered, return current value
if (this.dials.has(id)) {
return this.dials.get(id)!.currentValue as T;
}
// Check for persisted value
const persistedValue = this.getPersistedValue(id);
const currentValue = persistedValue !== null ? persistedValue : config.default;
const registration: DialRegistration = {
id,
type,
config,
currentValue,
updatedAt: Date.now(),
};
this.dials.set(id, registration);
this.notifyRegistryListeners();
return currentValue as T;
}
/**
* Update a dial's value
*/
setValue(id: string, value: any): void {
const dial = this.dials.get(id);
if (!dial) {
// eslint-disable-next-line no-console
console.warn(`[Dials] Attempted to set value for unregistered dial: ${id}`);
return;
}
dial.currentValue = value;
dial.updatedAt = Date.now();
this.persistValue(id, value);
this.notifyChangeListeners(id, value);
this.notifyRegistryListeners();
}
/**
* Get a dial's current value
*/
getValue(id: string): any {
return this.dials.get(id)?.currentValue;
}
/**
* Get a dial's registration
*/
getDial(id: string): DialRegistration | undefined {
return this.dials.get(id);
}
/**
* Get all registered dials
*/
getAllDials(): DialRegistration[] {
return Array.from(this.dials.values());
}
/**
* Get dials by group
*/
getDialsByGroup(): Map<string, DialRegistration[]> {
const groups = new Map<string, DialRegistration[]>();
for (const dial of this.dials.values()) {
const group = dial.config.group || 'Ungrouped';
if (!groups.has(group)) {
groups.set(group, []);
}
groups.get(group)!.push(dial);
}
return groups;
}
/**
* Reset a dial to its default value
*/
reset(id: string): void {
const dial = this.dials.get(id);
if (!dial) return;
this.setValue(id, dial.config.default);
}
/**
* Reset all dials to their default values
*/
resetAll(): void {
for (const [id] of this.dials) {
this.reset(id);
}
}
/**
* Subscribe to changes for a specific dial
*/
subscribe(id: string, listener: DialChangeListener): () => void {
if (!this.changeListeners.has(id)) {
this.changeListeners.set(id, new Set());
}
this.changeListeners.get(id)!.add(listener);
// Return unsubscribe function
return () => {
this.changeListeners.get(id)?.delete(listener);
};
}
/**
* Subscribe to any registry change (for overlay UI)
*/
subscribeToRegistry(listener: DialRegistryListener): () => void {
this.registryListeners.add(listener);
// Return unsubscribe function
return () => {
this.registryListeners.delete(listener);
};
}
/**
* Notify listeners of a dial value change
*/
private notifyChangeListeners(id: string, value: any): void {
const listeners = this.changeListeners.get(id);
if (listeners) {
listeners.forEach(listener => listener(id, value));
}
}
/**
* Notify registry listeners (for overlay UI updates)
* Deferred to avoid React "setState during render" errors
*/
private notifyRegistryListeners(): void {
queueMicrotask(() => {
this.registryListeners.forEach(listener => listener());
});
}
/**
* Load persisted values from localStorage
*/
private loadFromStorage(): void {
if (typeof window === 'undefined') return;
try {
const key = getStorageKey(this.projectId);
const stored = localStorage.getItem(key);
if (stored) {
JSON.parse(stored); // Validate JSON, values will be applied when dials are registered
}
} catch (error) {
// eslint-disable-next-line no-console
console.warn('[Dials] Failed to load from localStorage:', error);
}
}
/**
* Get a persisted value for a dial
*/
private getPersistedValue(id: string): any | null {
if (typeof window === 'undefined') return null;
try {
const key = getStorageKey(this.projectId);
const stored = localStorage.getItem(key);
if (stored) {
const data = JSON.parse(stored);
return data[id] !== undefined ? data[id] : null;
}
} catch (error) {
// eslint-disable-next-line no-console
console.warn('[Dials] Failed to get persisted value:', error);
}
return null;
}
/**
* Persist a dial value to localStorage
*/
private persistValue(id: string, value: any): void {
if (typeof window === 'undefined') return;
try {
const key = getStorageKey(this.projectId);
const stored = localStorage.getItem(key);
const data = stored ? JSON.parse(stored) : {};
data[id] = value;
localStorage.setItem(key, JSON.stringify(data));
} catch (error) {
// eslint-disable-next-line no-console
console.warn('[Dials] Failed to persist value:', error);
}
}
/**
* Export all current values as an object
*/
exportValues(): Record<string, any> {
const values: Record<string, any> = {};
for (const [id, dial] of this.dials) {
values[id] = dial.currentValue;
}
return values;
}
/**
* Export all dials with their configurations
*/
exportDials(): DialRegistration[] {
return this.getAllDials();
}
/**
* Clear all persisted values
*/
clearStorage(): void {
if (typeof window === 'undefined') return;
try {
const key = getStorageKey(this.projectId);
localStorage.removeItem(key);
} catch (error) {
// eslint-disable-next-line no-console
console.warn('[Dials] Failed to clear storage:', error);
}
}
}
// Export singleton instance getter
export const getDialRegistry = () => DialRegistry.getInstance();

View file

@ -0,0 +1,457 @@
/**
* Styles for dials overlay and controls - Compact Leva-inspired design
*/
/* Control base styles - Horizontal layout */
.dial-control {
display: grid;
grid-template-columns: 1fr 140px;
column-gap: 8px;
min-height: 24px;
padding: 6px 12px;
background: #181c20;
border-bottom: 1px solid #292d39;
transition: background 0.15s;
overflow: hidden;
}
.dial-control:hover {
background: #1f2329;
}
.control-header {
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
}
.control-header label {
font-weight: 400;
font-size: 11px;
color: #d4d4d4;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
}
.control-header label:hover {
color: #fefefe;
}
.control-description {
display: none; /* Moved to tooltip */
}
.reset-button {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 0;
color: #535760;
transition: color 0.15s;
opacity: 0;
width: 16px;
text-align: center;
}
.dial-control:hover .reset-button {
opacity: 1;
}
.reset-button:hover {
color: #8c92a4;
}
.control-body {
display: flex;
align-items: center;
min-height: 24px;
overflow: hidden;
max-width: 100%;
}
/* Color control - Inline layout */
.color-control .control-body {
display: grid;
grid-template-columns: 20px 1fr;
gap: 6px;
width: 100%;
}
.color-swatch {
width: 20px;
height: 20px;
border: 1px solid #535760;
border-radius: 2px;
cursor: pointer;
transition: border-color 0.15s;
}
.color-swatch:hover {
border-color: #8c92a4;
}
.color-value-input {
flex: 1;
padding: 2px 6px;
background: #373c4b;
border: 1px solid transparent;
border-radius: 2px;
font-family: ui-monospace, 'SF Mono', monospace;
font-size: 11px;
color: #fefefe;
outline: none;
transition: border-color 0.15s;
}
.color-value-input:hover {
border-color: #535760;
}
.color-value-input:focus {
border-color: #0066dc;
}
/* Color picker popover */
.color-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.3);
}
.color-picker-wrapper {
position: fixed;
z-index: 10001;
padding: 8px;
background: #181c20;
border: 1px solid #292d39;
border-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
}
.color-presets {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #292d39;
}
.color-preset {
width: 20px;
height: 20px;
border: 1px solid #535760;
border-radius: 2px;
cursor: pointer;
transition: all 0.15s;
}
.color-preset:hover {
transform: scale(1.15);
border-color: #8c92a4;
}
.color-preset.active {
border-color: #0066dc;
box-shadow: 0 0 0 2px #0066dc;
}
.color-custom {
margin-top: 4px;
}
.custom-toggle {
width: 100%;
padding: 6px 12px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
.custom-toggle:hover {
background: #f5f5f5;
}
.custom-input {
display: flex;
gap: 4px;
}
.custom-input input {
flex: 1;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
}
.custom-input button {
padding: 6px 12px;
background: #1a1a1a;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.custom-input button:last-child {
background: #fff;
color: #333;
border: 1px solid #ddd;
}
/* Spacing control */
.spacing-options {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 6px;
}
.spacing-option {
padding: 8px 12px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
text-align: center;
}
.spacing-option:hover {
border-color: #999;
}
.spacing-option.active {
background: #1a1a1a;
color: #fff;
border-color: #1a1a1a;
}
.spacing-custom input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
}
/* Variant control */
.variant-select {
width: 100%;
padding: 2px 20px 2px 6px;
background: #373c4b;
border: 1px solid transparent;
border-radius: 2px;
font-size: 11px;
color: #fefefe;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
appearance: none;
background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg width="8" height="5" viewBox="0 0 8 5" fill="none" xmlns="http://www.w3.org/2000/svg"%3e%3cpath d="M0 0L4 4L8 0" fill="%238c92a4"/%3e%3c/svg%3e');
background-repeat: no-repeat;
background-position: right 6px center;
background-size: 8px 5px;
}
.variant-select:hover {
border-color: #535760;
}
.variant-select:focus {
border-color: #0066dc;
}
.variant-select option {
background: #373c4b;
color: #fefefe;
}
.variant-slider {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
width: 100%;
}
.variant-slider input[type='range'] {
width: 100%;
height: 3px;
background: #373c4b;
border-radius: 2px;
outline: none;
appearance: none;
}
.variant-slider input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 8px;
height: 16px;
background: #8c92a4;
border-radius: 2px;
cursor: pointer;
}
.variant-slider input[type='range']::-webkit-slider-thumb:hover {
background: #fefefe;
}
.variant-slider input[type='range']::-moz-range-thumb {
width: 8px;
height: 16px;
background: #8c92a4;
border: none;
border-radius: 2px;
cursor: pointer;
}
.variant-slider input[type='range']::-moz-range-thumb:hover {
background: #fefefe;
}
/* Boolean control */
.boolean-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
width: 100%;
}
.toggle-option {
padding: 2px 8px;
background: #373c4b;
border: 1px solid transparent;
border-radius: 2px;
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
text-align: center;
color: #8c92a4;
}
.toggle-option:hover {
border-color: #535760;
color: #fefefe;
}
.toggle-option.active {
background: #0066dc;
color: #fefefe;
border-color: #0066dc;
}
/* Number control - Inline slider + input */
.number-control .control-body {
display: grid;
grid-template-columns: 1fr 42px;
gap: 6px;
width: 100%;
max-width: 100%;
}
.number-slider {
display: flex;
align-items: center;
width: 100%;
}
.number-slider input[type='range'] {
width: 100%;
height: 3px;
background: #373c4b;
border-radius: 2px;
outline: none;
appearance: none;
}
.number-slider input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 8px;
height: 16px;
background: #8c92a4;
border-radius: 2px;
cursor: pointer;
}
.number-slider input[type='range']::-webkit-slider-thumb:hover {
background: #fefefe;
}
.number-slider input[type='range']::-moz-range-thumb {
width: 8px;
height: 16px;
background: #8c92a4;
border: none;
border-radius: 2px;
cursor: pointer;
}
.number-slider input[type='range']::-moz-range-thumb:hover {
background: #fefefe;
}
.number-input {
display: flex;
align-items: center;
gap: 2px;
}
.number-input input {
width: 100%;
padding: 2px 4px;
background: #373c4b;
border: 1px solid transparent;
border-radius: 2px;
font-family: ui-monospace, 'SF Mono', monospace;
font-size: 11px;
color: #fefefe;
text-align: right;
outline: none;
transition: border-color 0.15s;
}
.number-input input:hover {
border-color: #535760;
}
.number-input input:focus {
border-color: #0066dc;
text-align: left;
}
.number-unit {
font-size: 10px;
color: #8c92a4;
}
.slider-labels {
display: none; /* Removed for compactness */
}
.slider-value {
display: none; /* Removed for compactness */
}
/* Search input placeholder */
input[type='search']::placeholder {
color: #8c92a4;
opacity: 1;
}
input[type='search']:focus::placeholder {
color: #6e6e6e;
}

178
packages/dials/src/types.ts Normal file
View file

@ -0,0 +1,178 @@
/**
* Core type definitions for the Dials SDK
*/
export type DialType = 'color' | 'spacing' | 'variant' | 'boolean' | 'number';
/**
* Base configuration shared by all dial types
*/
export interface BaseDialConfig<T> {
/** Human-readable label for the dial */
label: string;
/** Optional description/help text */
description?: string;
/** Group/category for organization in overlay UI */
group?: string;
/** Default value */
default: T;
}
/**
* Color dial configuration
* For any color value (backgrounds, text, borders, etc.)
*/
export interface ColorDialConfig extends BaseDialConfig<string> {
type?: 'color';
/** Predefined color options (from design system) */
options?: string[];
/** Allow custom hex input */
allowCustom?: boolean;
/** Color format hint */
format?: 'hex' | 'rgb' | 'hsl' | 'var';
}
/**
* Spacing dial configuration
* For padding, margin, gap, width, height, etc.
*/
export interface SpacingDialConfig extends BaseDialConfig<string> {
type?: 'spacing';
/** Predefined spacing options (e.g., '4px', '8px', 'var(--spacing-3)') */
options?: string[];
/** Allow custom values */
allowCustom?: boolean;
/** Unit for custom values */
unit?: 'px' | 'rem' | 'em' | '%';
/** Min value for custom input */
min?: number;
/** Max value for custom input */
max?: number;
}
/**
* Variant dial configuration
* For discrete choices (layouts, styles, chart types, etc.)
*/
export interface VariantDialConfig<T extends string = string> extends BaseDialConfig<T> {
type?: 'variant';
/** Array of allowed values (enum-like) */
options: readonly T[];
/** Optional labels for each option (if different from value) */
optionLabels?: Record<T, string>;
}
/**
* Boolean dial configuration
* For toggles, feature flags, show/hide, etc.
*/
export interface BooleanDialConfig extends BaseDialConfig<boolean> {
type?: 'boolean';
/** Label for "true" state */
trueLabel?: string;
/** Label for "false" state */
falseLabel?: string;
}
/**
* Number dial configuration
* For numeric values with constraints
*/
export interface NumberDialConfig extends BaseDialConfig<number> {
type?: 'number';
/** Minimum value */
min?: number;
/** Maximum value */
max?: number;
/** Step increment */
step?: number;
/** Unit to display (e.g., 'px', '%', 'ms') */
unit?: string;
/** Predefined number options */
options?: number[];
}
/**
* Union type for all dial configurations
*/
export type DialConfig =
| ColorDialConfig
| SpacingDialConfig
| VariantDialConfig<any>
| BooleanDialConfig
| NumberDialConfig;
/**
* Internal dial registration stored in registry
*/
export interface DialRegistration {
/** Unique identifier for the dial */
id: string;
/** Type of dial */
type: DialType;
/** Configuration */
config: DialConfig;
/** Current value (user override or default) */
currentValue: any;
/** Timestamp of last update */
updatedAt?: number;
}
/**
* Design system manifest structure
*/
export interface DesignManifest {
name?: string;
version?: string;
colors?: {
[category: string]: {
label?: string;
values: string[] | Record<string, string>;
};
};
spacing?: {
label?: string;
values: string[];
variables?: string[];
};
typography?: {
fontFamilies?: {
label?: string;
values: string[];
};
fontSizes?: {
label?: string;
values: string[];
variables?: string[];
};
fontWeights?: {
label?: string;
values: string[];
labels?: string[];
};
headingSizes?: {
label?: string;
values: string[];
variables?: string[];
};
};
borderRadius?: {
label?: string;
values: string[];
variables?: string[];
labels?: string[];
};
shadows?: {
label?: string;
values: string[];
variables?: string[];
labels?: string[];
};
[key: string]: any;
}
/**
* Event types for dial changes
*/
export type DialChangeListener = (id: string, value: any) => void;
export type DialRegistryListener = () => void;

View file

@ -0,0 +1,149 @@
/**
* Utilities for loading and working with the design system manifest
*/
import type { DesignManifest } from '../types';
let cachedManifest: DesignManifest | null = null;
/**
* Load the design system manifest from .niteshift-manifest
* Caches the result for subsequent calls
*/
export async function loadManifest(
manifestPath = '/.niteshift-manifest',
): Promise<DesignManifest | null> {
// Return cached manifest if available
if (cachedManifest) {
return cachedManifest;
}
try {
const response = await fetch(manifestPath);
if (!response.ok) {
// eslint-disable-next-line no-console
console.warn('[Dials] Design manifest not found at', manifestPath);
return null;
}
const manifest = await response.json();
cachedManifest = manifest;
return manifest;
} catch (error) {
// eslint-disable-next-line no-console
console.warn('[Dials] Failed to load design manifest:', error);
return null;
}
}
/**
* Get color options from the manifest
* @param category - Optional category (e.g., 'primary', 'accent', 'semantic')
* @returns Array of color values
*/
export function getManifestColors(manifest: DesignManifest, category?: string): string[] {
if (!manifest.colors) return [];
if (category && manifest.colors[category]) {
const cat = manifest.colors[category];
if (Array.isArray(cat.values)) {
return cat.values;
} else if (typeof cat.values === 'object') {
return Object.values(cat.values);
}
}
// Return all colors if no category specified
const allColors: string[] = [];
for (const cat of Object.values(manifest.colors)) {
if (Array.isArray(cat.values)) {
allColors.push(...cat.values);
} else if (typeof cat.values === 'object') {
allColors.push(...Object.values(cat.values));
}
}
return allColors;
}
/**
* Get spacing options from the manifest
* @param useVariables - If true, returns CSS variable names instead of pixel values
* @returns Array of spacing values
*/
export function getManifestSpacing(manifest: DesignManifest, useVariables = false): string[] {
if (!manifest.spacing) return [];
if (useVariables && manifest.spacing.variables) {
return manifest.spacing.variables;
}
return manifest.spacing.values || [];
}
/**
* Get typography options from the manifest
*/
export function getManifestTypography(
manifest: DesignManifest,
type: 'fontFamilies' | 'fontSizes' | 'fontWeights' | 'headingSizes',
useVariables = false,
): string[] {
if (!manifest.typography || !manifest.typography[type]) return [];
const config = manifest.typography[type];
if (useVariables && 'variables' in config && config.variables) {
return config.variables;
}
return config.values || [];
}
/**
* Get border radius options from the manifest
*/
export function getManifestBorderRadius(manifest: DesignManifest, useVariables = false): string[] {
if (!manifest.borderRadius) return [];
if (useVariables && manifest.borderRadius.variables) {
return manifest.borderRadius.variables;
}
return manifest.borderRadius.values || [];
}
/**
* Get shadow options from the manifest
*/
export function getManifestShadows(manifest: DesignManifest, useVariables = false): string[] {
if (!manifest.shadows) return [];
if (useVariables && manifest.shadows.variables) {
return manifest.shadows.variables;
}
return manifest.shadows.values || [];
}
/**
* Helper to build color options with categories
* Returns a flat array with all colors from specified categories
*/
export function buildColorOptions(manifest: DesignManifest, categories: string[]): string[] {
const colors: string[] = [];
for (const category of categories) {
const categoryColors = getManifestColors(manifest, category);
colors.push(...categoryColors);
}
return colors;
}
/**
* Invalidate the cached manifest (useful for hot reload scenarios)
*/
export function invalidateManifestCache(): void {
cachedManifest = null;
}