Add Niteshift Dials SDK for runtime design prototyping

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

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

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

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

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

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

356
CLAUDE.md
View file

@ -462,6 +462,362 @@ export default function MyPage() {
7. **TypeScript**: Leverage type inference - the codebase has strong typing
8. **Component library first**: Always check if `@umami/react-zen` has what you need before writing custom UI
## Niteshift Dials SDK
**New capability for design prototyping!** The Dials SDK allows you to expose design parameters as runtime-adjustable "dials" that users can tweak via an overlay UI. This gives designers and PMs fine-grained control without requiring code changes.
### Setup and Installation
The Dials SDK lives in `packages/dials/` and is automatically built when you run `pnpm install` via the repos postinstall script. **Do not edit the SDK source or build pipeline unless the user explicitly instructs you to do so.** Your default interaction with dials should be importing the provided hooks inside app components.
**Key files:**
- `packages/dials/src/` SDK source **(hands-off unless asked)**
- `packages/dials/dist/` Built package (gitignored, auto-generated)
- `src/config/niteshift-manifest.ts` Umamis design system manifest for reference in app code
If—*and only if*—a user asks you to modify the SDK internals, rebuild with:
```bash
pnpm --filter @niteshift/dials build
```
### When to Use Dials
Only add dials when the user explicitly says they want “dials” (or otherwise asks for adjustable controls). When they do, prioritize these scenarios:
- Subjective color/spacing/typography tweaks tied to design-system tokens
- Layout variants or experimental sections the user wants to tune live
- Feature toggles the user specifically calls out for dial-based control
**Key principle**: Follow the user's direction—never invent dials on your own; create them only when requested, and let the user decide which values need adjustment.
### Critical: Preserving Original Appearance
**⚠️ EXTREMELY IMPORTANT: When adding dials to existing code, the defaults MUST ALWAYS preserve the exact original appearance.**
Before adding dials to any component:
1. **Document the original values** - Record what props/styles existed before dials
2. **Match defaults exactly** - Dial defaults must produce identical output to pre-dial code
3. **Use empty string for "no prop"** - If original had no prop, use `default: ''` not `'inherit'` or a value
4. **Conditionally spread props** - Only pass props when they have truthy values
**Example - WRONG approach:**
```typescript
// Original code (before dials):
<Text weight="bold">{label}</Text> // No size prop!
// ❌ WRONG - adds size prop that wasn't there:
const labelSize = useDynamicVariant('label-size', {
default: '1', // ❌ Original had NO size, this changes appearance!
options: ['0', '1', '2', '3'] as const,
});
<Text size={labelSize} weight="bold">{label}</Text>
```
**Example - CORRECT approach:**
```typescript
// Original code (before dials):
<Text weight="bold">{label}</Text> // No size prop!
// ✅ CORRECT - empty string means "no change":
const labelSize = useDynamicVariant('label-size', {
default: '', // ✅ Empty string = no size prop = matches original
options: ['', '0', '1', '2', '3'] as const, // First option is "default/none"
});
// ✅ CORRECT - only pass size if truthy:
<Text
{...(labelSize && { size: labelSize })}
weight="bold"
>
{label}
</Text>
```
**Why this matters:**
- Users expect dials at default = original appearance
- Dials should enable exploration, not force changes
- Breaking the original look confuses users and defeats the purpose
**Testing your defaults:**
1. Add dials with defaults
2. View the page - should look IDENTICAL to before dials
3. Reset All in dials overlay - should look IDENTICAL to before dials
4. Only when adjusting dials should appearance change
### Design System Manifest
The design system manifest is defined in `src/config/niteshift-manifest.ts` as a TypeScript module:
- Colors (primary, base, accent, semantic)
- Spacing scale (4px to 128px)
- Typography (fonts, sizes, weights)
- Border radius, shadows
**Benefits of TypeScript manifest:**
- Type-safe with full IDE autocomplete
- Bundled with app (not publicly accessible)
- No runtime HTTP fetch (faster)
- Hot reload compatible
Reference these tokens in dial configs to provide users with design system-aligned options.
### Manifest-Powered Defaults
**Smart defaults from the design system!** When you omit the `options` parameter in color and spacing dials, the SDK automatically pulls values from the design manifest. This reduces boilerplate and ensures consistency with your design system.
**Color dials (design system defaults)**
```typescript
import { useDynamicColor } from '@niteshift/dials';
const badgeColor = useDynamicColor('hero-badge-color', {
label: 'Hero Badge Color',
group: 'Hero Section',
default: 'var(--primary-color)',
manifestCategory: 'primary', // pulls tokens from designManifest.colors.primary
allowCustom: true,
});
return <Badge style={{ backgroundColor: badgeColor }}>Top Performer</Badge>;
```
**Spacing dials (design system defaults)**
```typescript
import { useDynamicSpacing } from '@niteshift/dials';
const cardPadding = useDynamicSpacing('hero-card-padding', {
label: 'Hero Card Padding',
group: 'Hero Section',
default: 'var(--spacing-5)',
manifestCategory: 'spacing',
});
return <Card style={{ padding: cardPadding }}>{children}</Card>;
```
// Manifest defaults (automatic - uses full spacing scale):
const margin2 = useDynamicSpacing('margin-2', {
label: 'Margin',
default: '24px',
// options omitted - uses designManifest.spacing.values (4px to 128px)
});
```
**When to use manifest defaults:**
- ✅ You want design system consistency
- ✅ You're prototyping and want quick setup
- ✅ The default color category (accent) or spacing scale fits your needs
- ❌ You need a specific subset of values
- ❌ You're using custom values outside the design system
### Available Dial Types
#### Color Dials
For any color value (backgrounds, text, borders, etc.):
```typescript
import { useDynamicColor } from '@niteshift/dials';
const bgColor = useDynamicColor('hero-background', {
label: 'Hero Background Color',
description: 'Background color for the hero section',
group: 'Hero Section',
default: '#1a1a1a',
options: ['#1a1a1a', '#2d2d2d', '#404040', '#525252'], // From design system
allowCustom: true // Allows custom hex input
});
<div style={{ backgroundColor: bgColor }}>...</div>
```
#### Spacing Dials
For padding, margin, gap, dimensions:
```typescript
import { useDynamicSpacing } from '@niteshift/dials';
const padding = useDynamicSpacing('card-padding', {
label: 'Card Padding',
group: 'Card Component',
default: '1.5rem',
options: ['0.5rem', '1rem', '1.5rem', '2rem', '3rem'],
allowCustom: true
});
<div style={{ padding }}>...</div>
```
#### Variant Dials
For discrete choices (layouts, styles, chart types):
```typescript
import { useDynamicVariant } from '@niteshift/dials';
const layout = useDynamicVariant('dashboard-layout', {
label: 'Dashboard Layout',
group: 'Dashboard',
default: 'grid',
options: ['grid', 'list', 'compact'] as const
});
{layout === 'grid' && <GridView />}
{layout === 'list' && <ListView />}
{layout === 'compact' && <CompactView />}
```
#### Boolean Dials
For toggles, feature flags, show/hide:
```typescript
import { useDynamicBoolean } from '@niteshift/dials';
const showDelta = useDynamicBoolean('show-metrics-delta', {
label: 'Show Change Indicators',
description: 'Display +/- changes in metrics',
default: true,
trueLabel: 'Visible',
falseLabel: 'Hidden',
group: 'Metrics Bar'
});
{showDelta && <DeltaIndicator value={change} />}
```
#### Number Dials
For numeric values with constraints:
```typescript
import { useDynamicNumber } from '@niteshift/dials';
const chartHeight = useDynamicNumber('chart-height', {
label: 'Chart Height',
default: 400,
min: 200,
max: 800,
step: 50,
unit: 'px',
options: [300, 400, 500, 600], // Preset options
group: 'Chart'
});
<Chart height={chartHeight} />
```
### Advanced Use Cases
**Layout Controls:**
```typescript
const columns = useDynamicVariant('metrics-columns', {
label: 'Metrics Layout',
default: '4',
options: ['2', '3', '4', '6'] as const,
group: 'Dashboard'
});
<Grid columns={Number(columns)}>
{metrics.map(m => <MetricCard key={m.id} {...m} />)}
</Grid>
```
**Chart Type Selection:**
```typescript
const chartType = useDynamicVariant('analytics-chart', {
label: 'Visualization Type',
default: 'line',
options: ['line', 'bar', 'area'] as const,
group: 'Analytics'
});
{chartType === 'line' && <LineChart data={data} />}
{chartType === 'bar' && <BarChart data={data} />}
{chartType === 'area' && <AreaChart data={data} />}
```
**Feature Flags:**
```typescript
const showSparklines = useDynamicBoolean('show-sparklines', {
label: 'Show Sparklines',
default: false,
group: 'Metrics Display'
});
<MetricsBar data={metrics} showSparklines={showSparklines} />
```
**Icon Selection:**
```typescript
const emptyIcon = useDynamicVariant('empty-state-icon', {
label: 'Empty State Icon',
default: 'inbox',
options: ['inbox', 'folder', 'archive', 'alert'] as const,
group: 'Empty States'
});
const icons = { inbox: <Inbox />, folder: <Folder />, archive: <Archive />, alert: <Alert /> };
<EmptyState icon={icons[emptyIcon]} />
```
### Best Practices
1. **Use semantic IDs**: e.g., `'hero-background'` not `'color-1'`
2. **Provide design system options first**: Always include tokens from `.niteshift-manifest`
3. **Group related dials**: Use the `group` property to organize by component/section
4. **Add helpful labels**: Make labels clear for non-technical users
5. **Set sensible defaults**: Choose the best option; users can refine later
6. **Reference manifest colors**: Pull from design system categories:
```typescript
// Colors from .niteshift-manifest
options: ['#147af3', '#2680eb', '#0090ff', '#3e63dd'] // Primary colors
```
### Communicating with Users
After creating dials, tell the user:
> "I've made [X, Y, Z] adjustable via design dials. Press **Ctrl+D** on macOS (use the Control key, not Command) or **Ctrl+Alt+D** on Windows/Linux to open the panel and fine-tune these values. You can select from design system options or enter custom values."
### Accessing the Overlay
- **Keyboard shortcut**: `Ctrl+D` on macOS, `Ctrl+Alt+D` on Windows/Linux toggles the dials overlay
- **Location**: Bottom-left floating panel
- **Persistence**: Visibility state and dial values persist across reloads (localStorage)
- **Features**:
- Search/filter dials
- Grouped by component/section
- Reset individual dials or all at once
- Keyboard shortcut hint shown in overlay header
### Examples from Umami
**WebsitePage with dynamic chart:**
```typescript
const chartHeight = useDynamicNumber('website-chart-height', {
label: 'Chart Height',
default: 520,
options: [400, 520, 640, 760],
allowCustom: true,
unit: 'px',
group: 'Website Analytics'
});
<Panel minHeight={`${chartHeight}px`}>
<WebsiteChart websiteId={websiteId} />
</Panel>
```
**Dashboard with layout options:**
```typescript
const layout = useDynamicVariant('dashboard-layout', {
label: 'Board Layout',
default: 'grid',
options: ['grid', 'list', 'masonry'] as const,
group: 'Dashboard'
});
{layout === 'grid' && <GridLayout boards={boards} />}
{layout === 'list' && <ListView boards={boards} />}
{layout === 'masonry' && <MasonryLayout boards={boards} />}
```
### Implementation Notes
- Dials are already integrated into the app via `DialsProvider` in `src/app/Providers.tsx`
- The overlay (`DialsOverlay`) is automatically rendered
- Values are persisted to localStorage and survive hot reloads
- No additional setup required - just import and use the hooks!
## Project Architecture
### Directory Structure

View file

@ -45,6 +45,7 @@
"change-password": "node scripts/change-password.js",
"lint": "next lint --quiet",
"prepare": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky install",
"postinstall": "pnpm --filter @niteshift/dials build",
"postbuild": "node scripts/postbuild.js",
"test": "jest",
"cypress-open": "cypress open cypress run",
@ -73,6 +74,7 @@
"@dicebear/core": "^9.2.3",
"@fontsource/inter": "^5.2.8",
"@hello-pangea/dnd": "^17.0.0",
"@niteshift/dials": "workspace:*",
"@prisma/adapter-pg": "^6.18.0",
"@prisma/client": "^6.18.0",
"@prisma/extension-read-replicas": "^0.4.1",

View file

@ -0,0 +1,4 @@
*.test.ts
*.test.tsx
*.config.ts
__tests__/

4
packages/dials/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
dist/
node_modules/
*.log
.DS_Store

View file

@ -0,0 +1,49 @@
{
"name": "@niteshift/dials",
"version": "0.1.0",
"description": "Runtime design parameter controls for rapid prototyping",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./styles.css": "./dist/styles.css"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@testing-library/react": "^14.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.2.0",
"jsdom": "^23.0.0",
"tsup": "^8.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
},
"keywords": [
"design",
"prototyping",
"ai",
"controls",
"runtime-config"
],
"author": "Niteshift",
"license": "MIT"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
sourcemap: true,
clean: true,
external: ['react', 'react-dom', '@/config/niteshift-manifest'],
onSuccess: 'cp src/styles.css dist/styles.css',
});

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
},
});

1172
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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