mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +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
356
CLAUDE.md
356
CLAUDE.md
|
|
@ -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 repo’s 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` – Umami’s 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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4
packages/dials/.eslintignore
Normal file
4
packages/dials/.eslintignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
*.test.ts
|
||||
*.test.tsx
|
||||
*.config.ts
|
||||
__tests__/
|
||||
4
packages/dials/.gitignore
vendored
Normal file
4
packages/dials/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
dist/
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
49
packages/dials/package.json
Normal file
49
packages/dials/package.json
Normal 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"
|
||||
}
|
||||
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;
|
||||
}
|
||||
25
packages/dials/tsconfig.json
Normal file
25
packages/dials/tsconfig.json
Normal 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"]
|
||||
}
|
||||
11
packages/dials/tsup.config.ts
Normal file
11
packages/dials/tsup.config.ts
Normal 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',
|
||||
});
|
||||
11
packages/dials/vitest.config.ts
Normal file
11
packages/dials/vitest.config.ts
Normal 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
1172
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
198
src/config/niteshift-manifest.ts
Normal file
198
src/config/niteshift-manifest.ts
Normal 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'],
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue