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']);
});
});