mirror of
https://github.com/umami-software/umami.git
synced 2026-02-09 23:27:12 +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']);
|
||||
});
|
||||
});
|
||||
384
packages/dials/src/components/DialsOverlay.tsx
Normal file
384
packages/dials/src/components/DialsOverlay.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
58
packages/dials/src/components/DialsProvider.tsx
Normal file
58
packages/dials/src/components/DialsProvider.tsx
Normal 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);
|
||||
}
|
||||
48
packages/dials/src/controls/BooleanControl.tsx
Normal file
48
packages/dials/src/controls/BooleanControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
packages/dials/src/controls/ColorControl.tsx
Normal file
152
packages/dials/src/controls/ColorControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
packages/dials/src/controls/NumberControl.tsx
Normal file
85
packages/dials/src/controls/NumberControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
packages/dials/src/controls/SpacingControl.tsx
Normal file
57
packages/dials/src/controls/SpacingControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
packages/dials/src/controls/VariantControl.tsx
Normal file
65
packages/dials/src/controls/VariantControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
packages/dials/src/hooks/useDial.ts
Normal file
36
packages/dials/src/hooks/useDial.ts
Normal 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;
|
||||
}
|
||||
26
packages/dials/src/hooks/useDynamicBoolean.ts
Normal file
26
packages/dials/src/hooks/useDynamicBoolean.ts
Normal 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' });
|
||||
}
|
||||
52
packages/dials/src/hooks/useDynamicColor.ts
Normal file
52
packages/dials/src/hooks/useDynamicColor.ts
Normal 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);
|
||||
}
|
||||
28
packages/dials/src/hooks/useDynamicNumber.ts
Normal file
28
packages/dials/src/hooks/useDynamicNumber.ts
Normal 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' });
|
||||
}
|
||||
44
packages/dials/src/hooks/useDynamicSpacing.ts
Normal file
44
packages/dials/src/hooks/useDynamicSpacing.ts
Normal 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);
|
||||
}
|
||||
29
packages/dials/src/hooks/useDynamicVariant.ts
Normal file
29
packages/dials/src/hooks/useDynamicVariant.ts
Normal 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' });
|
||||
}
|
||||
42
packages/dials/src/index.ts
Normal file
42
packages/dials/src/index.ts
Normal 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';
|
||||
314
packages/dials/src/registry.ts
Normal file
314
packages/dials/src/registry.ts
Normal 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();
|
||||
457
packages/dials/src/styles.css
Normal file
457
packages/dials/src/styles.css
Normal 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
178
packages/dials/src/types.ts
Normal 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;
|
||||
149
packages/dials/src/utils/manifest.ts
Normal file
149
packages/dials/src/utils/manifest.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue