mirror of
https://github.com/umami-software/umami.git
synced 2026-02-10 15:47:13 +01:00
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:
parent
f4d0a65b16
commit
2727fd6dff
39 changed files with 4623 additions and 19 deletions
7
packages/dials/src/__tests__/.eslintrc.json
Normal file
7
packages/dials/src/__tests__/.eslintrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"rules": {
|
||||
"import/no-unresolved": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"no-unused-vars": "off"
|
||||
}
|
||||
}
|
||||
224
packages/dials/src/__tests__/registry.test.ts
Normal file
224
packages/dials/src/__tests__/registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
10
packages/dials/src/__tests__/setup.ts
Normal file
10
packages/dials/src/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Test setup file for Vitest
|
||||
*/
|
||||
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
// Clean up localStorage after each test
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
153
packages/dials/src/__tests__/useDynamicColor.test.tsx
Normal file
153
packages/dials/src/__tests__/useDynamicColor.test.tsx
Normal 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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue