From 5780d7575513061e99fc5eebac85f65912427ea1 Mon Sep 17 00:00:00 2001 From: Yunsung Lee Date: Sat, 7 Mar 2026 17:12:22 +0900 Subject: [PATCH 1/4] feat: add interactive setup wizard (forge setup) Single command to go from zero to running dashboard. Uses @clack/prompts for a 5-step interactive wizard (name, preset, map, AI, create) and supports --non-interactive --format json for agent use. Closes #28 Co-Authored-By: Claude Opus 4.6 --- forge/bin/forge.ts | 2 + forge/src/commands/__tests__/setup.test.ts | 320 +++++++++++++++ forge/src/commands/env.ts | 6 +- forge/src/commands/setup.ts | 433 +++++++++++++++++++++ 4 files changed, 758 insertions(+), 3 deletions(-) create mode 100644 forge/src/commands/__tests__/setup.test.ts create mode 100644 forge/src/commands/setup.ts diff --git a/forge/bin/forge.ts b/forge/bin/forge.ts index b77c8a4..b71c287 100644 --- a/forge/bin/forge.ts +++ b/forge/bin/forge.ts @@ -11,6 +11,7 @@ import { registerDevCommand } from '../src/commands/dev.js'; import { registerDeployCommand } from '../src/commands/deploy.js'; import { registerEnvCommands } from '../src/commands/env.js'; import { registerPresetCommands } from '../src/commands/preset.js'; +import { registerSetupCommand } from '../src/commands/setup.js'; const program = new Command(); @@ -33,5 +34,6 @@ registerDevCommand(program); registerDeployCommand(program); registerEnvCommands(program); registerPresetCommands(program); +registerSetupCommand(program); program.parse(); diff --git a/forge/src/commands/__tests__/setup.test.ts b/forge/src/commands/__tests__/setup.test.ts new file mode 100644 index 0000000..60e19ae --- /dev/null +++ b/forge/src/commands/__tests__/setup.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerSetupCommand } from '../setup.js'; +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn(), + readdirSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + text: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })), + log: { step: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn() }, + note: vi.fn(), + cancel: vi.fn(), + isCancel: vi.fn(() => false), +})); + +const mockedReadFileSync = vi.mocked(readFileSync); +const mockedWriteFileSync = vi.mocked(writeFileSync); +const mockedExistsSync = vi.mocked(existsSync); +const mockedReaddirSync = vi.mocked(readdirSync); + +const techMinimalPreset = JSON.stringify({ + monitor: { name: 'Tech Watch', slug: 'tech-watch', domain: 'technology', description: 'Tech dashboard', tags: [], branding: { primaryColor: '#0052CC' } }, + sources: [ + { name: 'hackernews', type: 'rss', url: 'https://hn.algolia.com/api/v1/search', category: 'tech' }, + { name: 'techcrunch', type: 'rss', url: 'https://techcrunch.com/feed/', category: 'tech' }, + { name: 'arstechnica', type: 'rss', url: 'https://feeds.arstechnica.com/arstechnica/index', category: 'tech' }, + ], + panels: [ + { name: 'tech-news', type: 'news-feed', displayName: 'Tech News', position: 0 }, + { name: 'ai-brief', type: 'ai-brief', displayName: 'AI Brief', position: 1 }, + ], + layers: [], + ai: { enabled: true, fallbackChain: ['groq'], providers: { groq: { model: 'llama-3.3-70b-versatile', apiKeyEnv: 'GROQ_API_KEY' } } }, + map: { center: [-95, 38], projection: 'mercator' }, +}); + +function createProgram(): Command { + const program = new Command(); + program.option('--format '); + program.option('--non-interactive'); + program.option('--dry-run'); + registerSetupCommand(program); + return program; +} + +let logOutput: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + logOutput = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => { logOutput.push(msg); }); + vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`exit:${code}`); + }); +}); + +function mockNoExistingConfig() { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return false; + if (p.endsWith('config.ts')) return false; + if (p.endsWith('.env.local')) return false; + if (p.endsWith('presets')) return true; + if (p.endsWith('.json')) return true; // preset files + return false; + }); +} + +function mockPresetsDir() { + mockedReaddirSync.mockReturnValue(['tech-minimal.json', 'blank.json'] as any); + mockedReadFileSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('tech-minimal.json')) return techMinimalPreset; + if (p.endsWith('blank.json')) return JSON.stringify({ monitor: { domain: 'general' }, sources: [], panels: [], layers: [] }); + return '{}'; + }); +} + +describe('setup command (non-interactive)', () => { + it('creates config with defaults when no options given', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.command).toBe('setup'); + expect(output.data.name).toBe('My Monitor'); + expect(output.data.preset).toBe('blank'); + expect(output.data.aiEnabled).toBe(true); + expect(output.changes).toHaveLength(3); + expect(output.next_steps).toContain('forge validate'); + }); + + it('applies specified preset', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--template', 'tech-minimal', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.preset).toBe('tech-minimal'); + expect(output.data.sources).toBe(3); + expect(output.data.panels).toBe(2); + }); + + it('sets map configuration', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--center', '-95,38', '--projection', 'globe', '--day-night', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.projection).toBe('globe'); + }); + + it('disables AI with --no-ai', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--no-ai', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.aiEnabled).toBe(false); + }); + + it('writes API keys to .env.local', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--groq-key', 'gsk_test123', '--format', 'json']); + + const envWriteCall = vi.mocked(writeFileSync).mock.calls.find( + ([path]) => String(path).endsWith('.env.local') + ); + expect(envWriteCall).toBeDefined(); + expect(envWriteCall![1]).toContain('GROQ_API_KEY=gsk_test123'); + }); + + it('dry-run does not write files', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--dry-run', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.changes[0].description).toContain('Would'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + + it('fails when config already exists', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return true; + return false; + }); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('already exists'); + }); + + it('warns when AI enabled but no keys provided', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.warnings).toContainEqual(expect.stringContaining('no API keys')); + }); + + it('warns about missing preset gracefully', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return false; + if (p.endsWith('.env.local')) return false; + if (p.endsWith('nonexistent.json')) return false; + return false; + }); + mockedReaddirSync.mockReturnValue([] as any); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--template', 'nonexistent', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.warnings).toContainEqual(expect.stringContaining('not found')); + }); +}); + +describe('setup command (interactive)', () => { + it('happy path through full wizard', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const clack = await import('@clack/prompts'); + const mockedText = vi.mocked(clack.text); + const mockedSelect = vi.mocked(clack.select); + const mockedConfirm = vi.mocked(clack.confirm); + + // Step 1: name, description + mockedText.mockResolvedValueOnce('Test Monitor'); + mockedText.mockResolvedValueOnce('A test dashboard'); + // Step 2: preset + mockedSelect.mockResolvedValueOnce('tech-minimal'); + // Step 3: projection, center, day/night + mockedSelect.mockResolvedValueOnce('globe'); + mockedText.mockResolvedValueOnce('126.97, 37.56'); + mockedConfirm.mockResolvedValueOnce(true); + // Step 4: AI enabled, groq key, openrouter key + mockedConfirm.mockResolvedValueOnce(true); + mockedText.mockResolvedValueOnce('gsk_wizard_key'); + mockedText.mockResolvedValueOnce(''); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup']); + + // Config should be written + expect(mockedWriteFileSync).toHaveBeenCalled(); + + // .env.local should contain the groq key + const envCall = mockedWriteFileSync.mock.calls.find( + ([path]) => String(path).endsWith('.env.local') + ); + expect(envCall).toBeDefined(); + expect(envCall![1]).toContain('GROQ_API_KEY=gsk_wizard_key'); + + // Outro should be called + expect(clack.outro).toHaveBeenCalled(); + }); + + it('cancellation exits gracefully', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const clack = await import('@clack/prompts'); + vi.mocked(clack.isCancel).mockReturnValue(true); + vi.mocked(clack.text).mockResolvedValueOnce(Symbol('cancel') as any); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup']), + ).rejects.toThrow('exit:0'); + + expect(clack.cancel).toHaveBeenCalledWith('Setup cancelled.'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + + it('merges with existing .env.local', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return false; + if (p.endsWith('.env.local')) return true; + if (p.endsWith('presets')) return true; + return p.endsWith('tech-minimal.json'); + }); + mockPresetsDir(); + // Return existing .env.local content when reading it + mockedReadFileSync.mockImplementation((path: any, ...args: any[]) => { + const p = String(path); + if (p.endsWith('.env.local')) return 'EXISTING_KEY=existing_value\n'; + if (p.endsWith('tech-minimal.json')) return techMinimalPreset; + if (p.endsWith('blank.json')) return JSON.stringify({ monitor: { domain: 'general' }, sources: [], panels: [], layers: [] }); + return '{}'; + }); + + const clack = await import('@clack/prompts'); + vi.mocked(clack.isCancel).mockReturnValue(false); + // Step 1 + vi.mocked(clack.text).mockResolvedValueOnce('Merge Test'); + vi.mocked(clack.text).mockResolvedValueOnce(''); + // Step 2 + vi.mocked(clack.select).mockResolvedValueOnce('blank'); + // Step 3 + vi.mocked(clack.select).mockResolvedValueOnce('mercator'); + vi.mocked(clack.text).mockResolvedValueOnce('0, 20'); + vi.mocked(clack.confirm).mockResolvedValueOnce(false); + // Step 4: AI disabled + vi.mocked(clack.confirm).mockResolvedValueOnce(false); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup']); + + const envCall = mockedWriteFileSync.mock.calls.find( + ([path]) => String(path).endsWith('.env.local') + ); + expect(envCall).toBeDefined(); + expect(envCall![1]).toContain('EXISTING_KEY=existing_value'); + }); +}); diff --git a/forge/src/commands/env.ts b/forge/src/commands/env.ts index 9968f57..1397c8e 100644 --- a/forge/src/commands/env.ts +++ b/forge/src/commands/env.ts @@ -83,13 +83,13 @@ export function registerEnvCommands(program: Command): void { }); } -interface EnvVarInfo { +export interface EnvVarInfo { key: string; description: string; required: boolean; } -function collectRequiredEnvVars(config: import('../config/schema.js').MonitorForgeConfig): EnvVarInfo[] { +export function collectRequiredEnvVars(config: import('../config/schema.js').MonitorForgeConfig): EnvVarInfo[] { const vars: EnvVarInfo[] = []; // AI providers @@ -124,7 +124,7 @@ function collectRequiredEnvVars(config: import('../config/schema.js').MonitorFor return vars; } -function parseEnvFile(content: string): Record { +export function parseEnvFile(content: string): Record { const result: Record = {}; for (const line of content.split('\n')) { const trimmed = line.trim(); diff --git a/forge/src/commands/setup.ts b/forge/src/commands/setup.ts new file mode 100644 index 0000000..0b4e4ef --- /dev/null +++ b/forge/src/commands/setup.ts @@ -0,0 +1,433 @@ +import type { Command } from 'commander'; +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { resolve, basename } from 'node:path'; +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { configExists, writeConfig } from '../config/loader.js'; +import { createDefaultConfig } from '../config/defaults.js'; +import type { MonitorForgeConfig } from '../config/schema.js'; +import { formatOutput, success, failure, type OutputFormat, type Change } from '../output/format.js'; +import { collectRequiredEnvVars, parseEnvFile } from './env.js'; + +interface SetupParams { + name: string; + description: string; + slug: string; + preset: string; + center: [number, number]; + projection: 'mercator' | 'globe'; + dayNight: boolean; + aiEnabled: boolean; + groqKey: string; + openrouterKey: string; +} + +interface SetupResult { + config: MonitorForgeConfig; + changes: Change[]; + warnings: string[]; +} + +function loadPresets(): Array<{ value: string; label: string; hint: string }> { + const presetsDir = resolve(process.cwd(), 'presets'); + if (!existsSync(presetsDir)) return []; + + const files = readdirSync(presetsDir).filter(f => f.endsWith('.json')); + return files.map(f => { + const content = JSON.parse(readFileSync(resolve(presetsDir, f), 'utf-8')); + const name = basename(f, '.json'); + return { + value: name, + label: name, + hint: `${content.monitor?.domain ?? 'general'} | ${content.sources?.length ?? 0} sources, ${content.panels?.length ?? 0} panels`, + }; + }); +} + +function toSlug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'my-monitor'; +} + +function buildAndWrite(params: SetupParams, dryRun: boolean): SetupResult { + const changes: Change[] = []; + const warnings: string[] = []; + + // Load preset + let presetOverrides: Partial = {}; + if (params.preset !== 'blank') { + const presetPath = resolve(process.cwd(), 'presets', `${params.preset}.json`); + if (existsSync(presetPath)) { + presetOverrides = JSON.parse(readFileSync(presetPath, 'utf-8')); + } else { + warnings.push(`Preset "${params.preset}" not found, using blank config.`); + } + } + + // Build AI config + const aiConfig = params.aiEnabled + ? (presetOverrides.ai && Object.keys(presetOverrides.ai.providers ?? {}).length > 0 + ? { ...presetOverrides.ai, enabled: true } + : { + enabled: true, + fallbackChain: ['groq', 'openrouter'], + providers: { + groq: { model: 'llama-3.3-70b-versatile', apiKeyEnv: 'GROQ_API_KEY' }, + openrouter: { model: 'meta-llama/llama-3.3-70b-instruct', apiKeyEnv: 'OPENROUTER_API_KEY' }, + }, + analysis: { summarization: true, entityExtraction: true, sentimentAnalysis: true, focalPointDetection: false }, + }) + : { + enabled: false, + fallbackChain: [] as string[], + providers: {}, + analysis: { summarization: false, entityExtraction: false, sentimentAnalysis: false, focalPointDetection: false }, + }; + + const config = createDefaultConfig({ + ...presetOverrides, + monitor: { + name: params.name, + slug: params.slug, + description: params.description || presetOverrides.monitor?.description || 'A custom real-time intelligence dashboard', + domain: presetOverrides.monitor?.domain ?? 'general', + tags: presetOverrides.monitor?.tags ?? [], + branding: presetOverrides.monitor?.branding ?? { primaryColor: '#0052CC' }, + }, + map: Object.assign({}, presetOverrides.map, { + center: params.center, + projection: params.projection, + dayNightOverlay: params.dayNight, + }), + ai: aiConfig, + }); + + if (dryRun) { + changes.push( + { type: 'created', file: 'monitor-forge.config.json', description: 'Would create config file' }, + { type: 'created', file: '.env.local', description: 'Would create env file with API keys' }, + { type: 'created', file: '.env.example', description: 'Would generate env template' }, + ); + } else { + // Write config + const configPath = writeConfig(config); + changes.push({ type: 'created', file: configPath, description: 'Created config file' }); + + // Write .env.local (merge with existing) + const envLocalPath = resolve(process.cwd(), '.env.local'); + let existing: Record = {}; + if (existsSync(envLocalPath)) { + existing = parseEnvFile(readFileSync(envLocalPath, 'utf-8')); + } + + if (params.groqKey) existing['GROQ_API_KEY'] = params.groqKey; + if (params.openrouterKey) existing['OPENROUTER_API_KEY'] = params.openrouterKey; + + const envVars = collectRequiredEnvVars(config); + const envLines: string[] = []; + for (const v of envVars) { + const value = existing[v.key] ?? ''; + envLines.push(`# ${v.description}${v.required ? ' (required)' : ''}`); + envLines.push(`${v.key}=${value}`); + envLines.push(''); + } + // Include any extra keys from existing .env.local not in envVars + const envVarKeys = new Set(envVars.map(v => v.key)); + for (const [key, value] of Object.entries(existing)) { + if (!envVarKeys.has(key)) { + envLines.push(`${key}=${value}`); + envLines.push(''); + } + } + + writeFileSync(envLocalPath, envLines.join('\n'), 'utf-8'); + changes.push({ type: 'created', file: '.env.local', description: 'Created env file with API keys' }); + + // Write .env.example + const exampleContent = envVars.map(v => + `# ${v.description}${v.required ? ' (required)' : ' (optional)'}\n${v.key}=` + ).join('\n\n') + '\n'; + writeFileSync(resolve(process.cwd(), '.env.example'), exampleContent, 'utf-8'); + changes.push({ type: 'created', file: '.env.example', description: 'Generated env template' }); + } + + // Validation warnings + if (config.sources.length === 0) { + warnings.push('No data sources configured. Run `forge source add` to add feeds.'); + } + if (config.panels.length === 0) { + warnings.push('No panels configured. Run `forge panel add` to add UI panels.'); + } + if (params.aiEnabled && !params.groqKey && !params.openrouterKey) { + warnings.push('AI is enabled but no API keys provided. Add keys to .env.local before running.'); + } + + return { config, changes, warnings }; +} + +async function runInteractiveWizard(dryRun: boolean): Promise { + p.intro(pc.bgCyan(pc.black(' monitor-forge setup '))); + + // Check existing config + if (configExists()) { + const overwrite = await p.confirm({ + message: 'monitor-forge.config.json already exists. Overwrite?', + }); + if (p.isCancel(overwrite) || !overwrite) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + } + + // Step 1/5: Monitor identity + p.log.step(pc.bold('Step 1/5') + ' — Monitor identity'); + + const name = await p.text({ + message: 'What is the name of your monitor?', + placeholder: 'My Intelligence Dashboard', + defaultValue: 'My Monitor', + validate: (val) => { + if (!val || val.trim().length === 0) return 'Name is required'; + if (val.length > 64) return 'Name must be 64 characters or less'; + }, + }); + if (p.isCancel(name)) { p.cancel('Setup cancelled.'); process.exit(0); } + + const description = await p.text({ + message: 'Short description (optional):', + placeholder: 'A custom real-time intelligence dashboard', + defaultValue: '', + }); + if (p.isCancel(description)) { p.cancel('Setup cancelled.'); process.exit(0); } + + // Step 2/5: Choose a preset + p.log.step(pc.bold('Step 2/5') + ' — Choose a preset'); + + const presets = loadPresets(); + let selectedPreset: string; + + if (presets.length === 0) { + p.log.warn('No presets found. Using blank configuration.'); + selectedPreset = 'blank'; + } else { + const preset = await p.select({ + message: 'Choose a starting preset:', + options: presets, + }); + if (p.isCancel(preset)) { p.cancel('Setup cancelled.'); process.exit(0); } + selectedPreset = preset as string; + } + + // Load preset to get defaults for subsequent steps + let presetData: Partial = {}; + if (selectedPreset !== 'blank') { + const presetPath = resolve(process.cwd(), 'presets', `${selectedPreset}.json`); + if (existsSync(presetPath)) { + presetData = JSON.parse(readFileSync(presetPath, 'utf-8')); + } + } + + // Step 3/5: Map configuration + p.log.step(pc.bold('Step 3/5') + ' — Map configuration'); + + const projection = await p.select({ + message: 'Map projection:', + options: [ + { value: 'mercator', label: 'Mercator', hint: 'Flat map — best for regional focus' }, + { value: 'globe', label: 'Globe', hint: '3D globe — best for global view' }, + ], + initialValue: presetData.map?.projection ?? 'mercator', + }); + if (p.isCancel(projection)) { p.cancel('Setup cancelled.'); process.exit(0); } + + const defaultCenter = presetData.map?.center ?? [0, 20]; + const centerInput = await p.text({ + message: 'Map center (lng, lat):', + placeholder: '0, 20', + defaultValue: defaultCenter.join(', '), + validate: (val) => { + const parts = val.split(',').map(s => s.trim()); + if (parts.length !== 2) return 'Enter longitude, latitude (e.g. -95, 38)'; + const [lng, lat] = parts.map(Number); + if (isNaN(lng) || isNaN(lat)) return 'Both values must be numbers'; + if (lng < -180 || lng > 180) return 'Longitude must be between -180 and 180'; + if (lat < -90 || lat > 90) return 'Latitude must be between -90 and 90'; + }, + }); + if (p.isCancel(centerInput)) { p.cancel('Setup cancelled.'); process.exit(0); } + + const dayNight = await p.confirm({ + message: 'Enable day/night terminator overlay?', + initialValue: presetData.map?.dayNightOverlay ?? false, + }); + if (p.isCancel(dayNight)) { p.cancel('Setup cancelled.'); process.exit(0); } + + // Step 4/5: AI analysis + p.log.step(pc.bold('Step 4/5') + ' — AI analysis'); + + const aiEnabled = await p.confirm({ + message: 'Enable AI-powered analysis? (summarization, entity extraction)', + initialValue: presetData.ai?.enabled ?? true, + }); + if (p.isCancel(aiEnabled)) { p.cancel('Setup cancelled.'); process.exit(0); } + + let groqKey = ''; + let openrouterKey = ''; + + if (aiEnabled) { + p.log.info(pc.dim('AI requires API keys. Free tiers available at:')); + p.log.info(pc.dim(' Groq: https://console.groq.com')); + p.log.info(pc.dim(' OpenRouter: https://openrouter.ai/keys')); + + const gk = await p.text({ + message: 'Groq API key (press Enter to skip):', + placeholder: 'gsk_...', + defaultValue: '', + }); + if (p.isCancel(gk)) { p.cancel('Setup cancelled.'); process.exit(0); } + groqKey = gk as string; + + const ork = await p.text({ + message: 'OpenRouter API key (press Enter to skip):', + placeholder: 'sk-or-...', + defaultValue: '', + }); + if (p.isCancel(ork)) { p.cancel('Setup cancelled.'); process.exit(0); } + openrouterKey = ork as string; + } + + // Step 5/5: Create + p.log.step(pc.bold('Step 5/5') + ' — Creating your dashboard'); + + const s = p.spinner(); + s.start('Generating configuration...'); + + const [lng, lat] = (centerInput as string).split(',').map(s => parseFloat(s.trim())); + + const result = buildAndWrite({ + name: name as string, + description: description as string, + slug: toSlug(name as string), + preset: selectedPreset, + center: [lng, lat], + projection: projection as 'mercator' | 'globe', + dayNight: dayNight as boolean, + aiEnabled: aiEnabled as boolean, + groqKey, + openrouterKey, + }, dryRun); + + s.stop('Configuration complete!'); + + // Summary + p.log.success(pc.green('Dashboard created successfully!')); + p.log.info(` Name: ${pc.bold(result.config.monitor.name)}`); + p.log.info(` Preset: ${pc.bold(selectedPreset)}`); + p.log.info(` Sources: ${pc.bold(String(result.config.sources.length))}`); + p.log.info(` Panels: ${pc.bold(String(result.config.panels.length))}`); + p.log.info(` AI: ${pc.bold(result.config.ai.enabled ? 'enabled' : 'disabled')}`); + p.log.info(` Projection: ${pc.bold(result.config.map.projection)}`); + + if (result.warnings.length > 0) { + for (const w of result.warnings) { + p.log.warn(pc.yellow(w)); + } + } + + p.note( + [ + `${pc.dim('$')} npm run validate`, + `${pc.dim('$')} npm run dev`, + ].join('\n'), + 'Next steps', + ); + + p.outro(pc.bgGreen(pc.black(' Happy monitoring! '))); +} + +function runNonInteractive( + opts: Record, + format: OutputFormat, + dryRun: boolean, +): void { + if (configExists() && !dryRun) { + console.log(formatOutput( + failure('setup', 'monitor-forge.config.json already exists. Delete it first or use --dry-run.'), + format, + )); + process.exit(1); + } + + const name = (opts.name as string) ?? 'My Monitor'; + const desc = (opts.description as string) ?? ''; + const template = (opts.template as string) ?? 'blank'; + const centerStr = opts.center as string | undefined; + const center: [number, number] = centerStr + ? centerStr.split(',').map(s => parseFloat(s.trim())) as [number, number] + : [0, 20]; + const projection = (opts.projection as 'mercator' | 'globe') ?? 'mercator'; + const dayNight = !!opts.dayNight; + const aiEnabled = opts.ai !== false; + const groqKey = (opts.groqKey as string) ?? ''; + const openrouterKey = (opts.openrouterKey as string) ?? ''; + + const result = buildAndWrite({ + name, + description: desc, + slug: toSlug(name), + preset: template, + center, + projection, + dayNight, + aiEnabled, + groqKey, + openrouterKey, + }, dryRun); + + console.log(formatOutput( + success('setup', { + name: result.config.monitor.name, + slug: result.config.monitor.slug, + preset: template, + sources: result.config.sources.length, + layers: result.config.layers.length, + panels: result.config.panels.length, + aiEnabled: result.config.ai.enabled, + projection: result.config.map.projection, + }, { + changes: result.changes, + warnings: result.warnings, + next_steps: [ + 'forge validate', + 'forge dev', + ], + }), + format, + )); +} + +export function registerSetupCommand(program: Command): void { + program + .command('setup') + .description('Interactive setup wizard — go from zero to running dashboard') + .option('--name ', 'Monitor name') + .option('--description ', 'Short description') + .option('--template ', 'Starting preset') + .option('--center ', 'Map center as "lng,lat"') + .option('--projection ', 'Map projection: mercator or globe') + .option('--day-night', 'Enable day/night overlay') + .option('--ai', 'Enable AI analysis') + .option('--no-ai', 'Disable AI analysis') + .option('--groq-key ', 'Groq API key') + .option('--openrouter-key ', 'OpenRouter API key') + .action(async (opts) => { + const format = (program.opts().format ?? 'table') as OutputFormat; + const nonInteractive = program.opts().nonInteractive ?? false; + const dryRun = program.opts().dryRun ?? false; + + if (format === 'json' || nonInteractive) { + runNonInteractive(opts, format, dryRun); + } else { + await runInteractiveWizard(dryRun); + } + }); +} From 126a20cac3fc557c2d20e86fec25e57614393f0a Mon Sep 17 00:00:00 2001 From: Yunsung Lee Date: Sat, 7 Mar 2026 23:40:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?Zod=20validation,=20dry-run=20guard,=20projection=20check,=20te?= =?UTF-8?q?st=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate config with MonitorForgeConfigSchema.parse() before writing (matches updateConfig pattern) - Skip interactive overwrite prompt during --dry-run - Reject invalid --projection values in non-interactive mode - Assert config content in interactive wizard test - Add test for invalid projection rejection Co-Authored-By: Claude Opus 4.6 --- forge/src/commands/__tests__/setup.test.ts | 25 +++++++++++++++++++++- forge/src/commands/setup.ts | 18 +++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/forge/src/commands/__tests__/setup.test.ts b/forge/src/commands/__tests__/setup.test.ts index 60e19ae..16df60a 100644 --- a/forge/src/commands/__tests__/setup.test.ts +++ b/forge/src/commands/__tests__/setup.test.ts @@ -199,6 +199,20 @@ describe('setup command (non-interactive)', () => { expect(output.warnings).toContainEqual(expect.stringContaining('no API keys')); }); + it('rejects invalid projection value', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--projection', 'equirectangular', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('Invalid projection'); + }); + it('warns about missing preset gracefully', async () => { mockedExistsSync.mockImplementation((path: any) => { const p = String(path); @@ -245,8 +259,17 @@ describe('setup command (interactive)', () => { const program = createProgram(); await program.parseAsync(['node', 'forge', 'setup']); - // Config should be written + // Config should be written with correct values expect(mockedWriteFileSync).toHaveBeenCalled(); + const configCall = mockedWriteFileSync.mock.calls.find( + ([path]) => String(path).endsWith('config.json') + ); + expect(configCall).toBeDefined(); + const writtenConfig = JSON.parse(configCall![1] as string); + expect(writtenConfig.monitor.name).toBe('Test Monitor'); + expect(writtenConfig.map.projection).toBe('globe'); + expect(writtenConfig.map.center).toEqual([126.97, 37.56]); + expect(writtenConfig.ai.enabled).toBe(true); // .env.local should contain the groq key const envCall = mockedWriteFileSync.mock.calls.find( diff --git a/forge/src/commands/setup.ts b/forge/src/commands/setup.ts index 0b4e4ef..56aa690 100644 --- a/forge/src/commands/setup.ts +++ b/forge/src/commands/setup.ts @@ -5,7 +5,7 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; import { configExists, writeConfig } from '../config/loader.js'; import { createDefaultConfig } from '../config/defaults.js'; -import type { MonitorForgeConfig } from '../config/schema.js'; +import { MonitorForgeConfigSchema, type MonitorForgeConfig } from '../config/schema.js'; import { formatOutput, success, failure, type OutputFormat, type Change } from '../output/format.js'; import { collectRequiredEnvVars, parseEnvFile } from './env.js'; @@ -83,7 +83,7 @@ function buildAndWrite(params: SetupParams, dryRun: boolean): SetupResult { analysis: { summarization: false, entityExtraction: false, sentimentAnalysis: false, focalPointDetection: false }, }; - const config = createDefaultConfig({ + const config = MonitorForgeConfigSchema.parse(createDefaultConfig({ ...presetOverrides, monitor: { name: params.name, @@ -99,7 +99,7 @@ function buildAndWrite(params: SetupParams, dryRun: boolean): SetupResult { dayNightOverlay: params.dayNight, }), ai: aiConfig, - }); + })); if (dryRun) { changes.push( @@ -168,7 +168,7 @@ async function runInteractiveWizard(dryRun: boolean): Promise { p.intro(pc.bgCyan(pc.black(' monitor-forge setup '))); // Check existing config - if (configExists()) { + if (configExists() && !dryRun) { const overwrite = await p.confirm({ message: 'monitor-forge.config.json already exists. Overwrite?', }); @@ -364,7 +364,15 @@ function runNonInteractive( const center: [number, number] = centerStr ? centerStr.split(',').map(s => parseFloat(s.trim())) as [number, number] : [0, 20]; - const projection = (opts.projection as 'mercator' | 'globe') ?? 'mercator'; + const projectionRaw = (opts.projection as string) ?? 'mercator'; + if (projectionRaw !== 'mercator' && projectionRaw !== 'globe') { + console.log(formatOutput( + failure('setup', `Invalid projection "${projectionRaw}". Must be "mercator" or "globe".`), + format, + )); + process.exit(1); + } + const projection = projectionRaw as 'mercator' | 'globe'; const dayNight = !!opts.dayNight; const aiEnabled = opts.ai !== false; const groqKey = (opts.groqKey as string) ?? ''; From 47d6bad48fb3c4ba9e323b2d2c8a879ac84df7d2 Mon Sep 17 00:00:00 2001 From: alohays Date: Sun, 8 Mar 2026 09:29:11 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20QA=20findings=20=E2=80=94?= =?UTF-8?q?=20JSON.parse=20resilience,=20center=20validation,=20env=20perm?= =?UTF-8?q?issions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap preset JSON.parse() in try-catch at 3 locations (loadPresets, buildAndWrite, interactive wizard) so a single corrupted preset file doesn't crash the entire setup command - Add NaN and coordinate range validation for non-interactive --center (longitude -180..180, latitude -90..90) with clear error messages - Set chmod 0600 on .env.local after writing to protect API keys - Switch vi.clearAllMocks() → vi.resetAllMocks() to prevent mock leakage - Add 6 new tests: invalid center (NaN, out-of-range, single value), dry-run with existing config, malformed preset JSON, chmod verification - Strengthen interactive happy-path assertions (slug, sources, panels, dayNightOverlay) Co-Authored-By: Claude Opus 4.6 --- forge/src/commands/__tests__/setup.test.ts | 108 ++++++++++++++++++++- forge/src/commands/setup.ts | 69 ++++++++++--- 2 files changed, 160 insertions(+), 17 deletions(-) diff --git a/forge/src/commands/__tests__/setup.test.ts b/forge/src/commands/__tests__/setup.test.ts index 16df60a..6c3b277 100644 --- a/forge/src/commands/__tests__/setup.test.ts +++ b/forge/src/commands/__tests__/setup.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Command } from 'commander'; import { registerSetupCommand } from '../setup.js'; -import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, readdirSync, chmodSync } from 'node:fs'; vi.mock('node:fs', () => ({ readFileSync: vi.fn(), @@ -9,6 +9,7 @@ vi.mock('node:fs', () => ({ existsSync: vi.fn(), readdirSync: vi.fn(), mkdirSync: vi.fn(), + chmodSync: vi.fn(), })); vi.mock('@clack/prompts', () => ({ @@ -57,7 +58,7 @@ function createProgram(): Command { let logOutput: string[] = []; beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); logOutput = []; vi.spyOn(console, 'log').mockImplementation((msg: string) => { logOutput.push(msg); }); vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { @@ -230,6 +231,105 @@ describe('setup command (non-interactive)', () => { expect(output.success).toBe(true); expect(output.warnings).toContainEqual(expect.stringContaining('not found')); }); + + it('rejects invalid center coordinates (NaN)', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--center', 'abc,def', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('must be numbers'); + }); + + it('rejects center coordinates out of range', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--center', '200,100', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('out of range'); + }); + + it('rejects center with single value', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--center', '123', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('lng,lat'); + }); + + it('dry-run succeeds even when config already exists', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return true; + if (p.endsWith('presets')) return true; + if (p.endsWith('.json')) return true; + return false; + }); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--dry-run', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.changes[0].description).toContain('Would'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + + it('handles malformed preset JSON gracefully', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return false; + if (p.endsWith('.env.local')) return false; + if (p.endsWith('presets')) return true; + if (p.endsWith('broken.json')) return true; + return false; + }); + mockedReaddirSync.mockReturnValue([] as any); + mockedReadFileSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('broken.json')) return '{invalid json!!!'; + return '{}'; + }); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--template', 'broken', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.warnings).toContainEqual(expect.stringContaining('invalid JSON')); + }); + + it('sets chmod 0600 on .env.local', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--groq-key', 'gsk_test', '--format', 'json']); + + const chmodCall = vi.mocked(chmodSync).mock.calls.find( + ([path]) => String(path).endsWith('.env.local') + ); + expect(chmodCall).toBeDefined(); + expect(chmodCall![1]).toBe(0o600); + }); }); describe('setup command (interactive)', () => { @@ -267,9 +367,13 @@ describe('setup command (interactive)', () => { expect(configCall).toBeDefined(); const writtenConfig = JSON.parse(configCall![1] as string); expect(writtenConfig.monitor.name).toBe('Test Monitor'); + expect(writtenConfig.monitor.slug).toBe('test-monitor'); expect(writtenConfig.map.projection).toBe('globe'); expect(writtenConfig.map.center).toEqual([126.97, 37.56]); + expect(writtenConfig.map.dayNightOverlay).toBe(true); expect(writtenConfig.ai.enabled).toBe(true); + expect(writtenConfig.sources).toHaveLength(3); + expect(writtenConfig.panels).toHaveLength(2); // .env.local should contain the groq key const envCall = mockedWriteFileSync.mock.calls.find( diff --git a/forge/src/commands/setup.ts b/forge/src/commands/setup.ts index 56aa690..e083453 100644 --- a/forge/src/commands/setup.ts +++ b/forge/src/commands/setup.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, writeFileSync, chmodSync } from 'node:fs'; import { resolve, basename } from 'node:path'; import * as p from '@clack/prompts'; import pc from 'picocolors'; @@ -33,15 +33,21 @@ function loadPresets(): Array<{ value: string; label: string; hint: string }> { if (!existsSync(presetsDir)) return []; const files = readdirSync(presetsDir).filter(f => f.endsWith('.json')); - return files.map(f => { - const content = JSON.parse(readFileSync(resolve(presetsDir, f), 'utf-8')); - const name = basename(f, '.json'); - return { - value: name, - label: name, - hint: `${content.monitor?.domain ?? 'general'} | ${content.sources?.length ?? 0} sources, ${content.panels?.length ?? 0} panels`, - }; - }); + const results: Array<{ value: string; label: string; hint: string }> = []; + for (const f of files) { + try { + const content = JSON.parse(readFileSync(resolve(presetsDir, f), 'utf-8')); + const name = basename(f, '.json'); + results.push({ + value: name, + label: name, + hint: `${content.monitor?.domain ?? 'general'} | ${content.sources?.length ?? 0} sources, ${content.panels?.length ?? 0} panels`, + }); + } catch { + console.error(`Warning: skipping malformed preset file "${f}"`); + } + } + return results; } function toSlug(name: string): string { @@ -57,7 +63,11 @@ function buildAndWrite(params: SetupParams, dryRun: boolean): SetupResult { if (params.preset !== 'blank') { const presetPath = resolve(process.cwd(), 'presets', `${params.preset}.json`); if (existsSync(presetPath)) { - presetOverrides = JSON.parse(readFileSync(presetPath, 'utf-8')); + try { + presetOverrides = JSON.parse(readFileSync(presetPath, 'utf-8')); + } catch { + warnings.push(`Preset "${params.preset}" has invalid JSON, using blank config.`); + } } else { warnings.push(`Preset "${params.preset}" not found, using blank config.`); } @@ -140,6 +150,7 @@ function buildAndWrite(params: SetupParams, dryRun: boolean): SetupResult { } writeFileSync(envLocalPath, envLines.join('\n'), 'utf-8'); + chmodSync(envLocalPath, 0o600); changes.push({ type: 'created', file: '.env.local', description: 'Created env file with API keys' }); // Write .env.example @@ -222,7 +233,11 @@ async function runInteractiveWizard(dryRun: boolean): Promise { if (selectedPreset !== 'blank') { const presetPath = resolve(process.cwd(), 'presets', `${selectedPreset}.json`); if (existsSync(presetPath)) { - presetData = JSON.parse(readFileSync(presetPath, 'utf-8')); + try { + presetData = JSON.parse(readFileSync(presetPath, 'utf-8')); + } catch { + p.log.warn(`Preset "${selectedPreset}" has invalid JSON, using defaults.`); + } } } @@ -361,9 +376,33 @@ function runNonInteractive( const desc = (opts.description as string) ?? ''; const template = (opts.template as string) ?? 'blank'; const centerStr = opts.center as string | undefined; - const center: [number, number] = centerStr - ? centerStr.split(',').map(s => parseFloat(s.trim())) as [number, number] - : [0, 20]; + let center: [number, number] = [0, 20]; + if (centerStr) { + const parts = centerStr.split(',').map(s => s.trim()); + if (parts.length !== 2) { + console.log(formatOutput( + failure('setup', 'Invalid center format. Expected "lng,lat" (e.g. "-95,38").'), + format, + )); + process.exit(1); + } + const [lng, lat] = parts.map(s => parseFloat(s)); + if (isNaN(lng) || isNaN(lat)) { + console.log(formatOutput( + failure('setup', 'Invalid center coordinates. Both values must be numbers.'), + format, + )); + process.exit(1); + } + if (lng < -180 || lng > 180 || lat < -90 || lat > 90) { + console.log(formatOutput( + failure('setup', 'Center coordinates out of range. Longitude: -180..180, Latitude: -90..90.'), + format, + )); + process.exit(1); + } + center = [lng, lat]; + } const projectionRaw = (opts.projection as string) ?? 'mercator'; if (projectionRaw !== 'mercator' && projectionRaw !== 'globe') { console.log(formatOutput( From 5b2ffc142407ecf09a4e136f85219c1ae10e4727 Mon Sep 17 00:00:00 2001 From: alohays Date: Sun, 8 Mar 2026 16:35:02 +0900 Subject: [PATCH 4/4] feat: visual theming engine + expanded preset library (#29, #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a complete visual theming system and expand the preset library from 7 to 15 presets across 7 domains. Each preset now ships with a distinctive palette, branding colors, and map layers with GeoJSON data — transforming the out-of-box experience from "generic dark dashboard" to domain-specific intelligence views. Theming engine (#30): - ThemeSchema with mode (dark/light/auto), 7 palette presets, custom accent colors, panel position, width, and compact mode - `forge theme set` and `forge theme status` CLI commands - Runtime CSS variable injection in App.ts with OS theme listener - Light mode, auto mode, compact mode, and left-panel CSS layouts - Build-time theme resolution via theme-resolved.ts manifest Expanded presets (#29): - 8 new presets: cyber, climate, korea, health (minimal + full tiers) - 7 domain-specific GeoJSON files with 87 total data points - Map layers added to all 7 full-tier presets - _meta blocks on all 15 presets (category, difficulty, API keys) - `preset list` now shows category and difficulty metadata - Domain-specific branding colors and palette assignments Co-Authored-By: Claude Opus 4.6 --- data/geo/conflict-zones.geojson | 80 ++++++ data/geo/financial-centers.geojson | 65 +++++ data/geo/korean-peninsula.geojson | 65 +++++ data/geo/natural-disasters.geojson | 80 ++++++ data/geo/tech-hubs.geojson | 80 ++++++ data/geo/threat-origins.geojson | 55 ++++ data/geo/who-regions.geojson | 45 ++++ forge/bin/forge.ts | 2 + forge/src/commands/preset.ts | 5 +- forge/src/commands/theme.ts | 108 ++++++++ forge/src/config/defaults.ts | 9 + forge/src/config/loader.test.ts | 2 + forge/src/config/schema.test.ts | 1 + forge/src/config/schema.ts | 17 ++ forge/src/generators/env-generator.test.ts | 1 + .../src/generators/manifest-generator.test.ts | 4 +- forge/src/generators/manifest-generator.ts | 9 + forge/src/generators/vercel-generator.test.ts | 1 + forge/src/theme/__tests__/palettes.test.ts | 58 +++++ forge/src/theme/__tests__/resolver.test.ts | 105 ++++++++ forge/src/theme/palettes.ts | 235 ++++++++++++++++++ forge/src/theme/resolver.ts | 47 ++++ presets/blank.json | 7 + presets/climate-full.json | 167 +++++++++++++ presets/climate-minimal.json | 98 ++++++++ presets/cyber-full.json | 174 +++++++++++++ presets/cyber-minimal.json | 98 ++++++++ presets/finance-full.json | 27 +- presets/finance-minimal.json | 15 +- presets/geopolitics-full.json | 27 +- presets/geopolitics-minimal.json | 15 +- presets/health-full.json | 174 +++++++++++++ presets/health-minimal.json | 97 ++++++++ presets/korea-full.json | 173 +++++++++++++ presets/korea-minimal.json | 97 ++++++++ presets/tech-full.json | 22 +- presets/tech-minimal.json | 10 + src/App.ts | 47 ++++ src/styles/base.css | 74 ++++++ test/presets-pipeline.test.ts | 3 +- test/presets.test.ts | 15 +- 41 files changed, 2403 insertions(+), 11 deletions(-) create mode 100644 data/geo/conflict-zones.geojson create mode 100644 data/geo/financial-centers.geojson create mode 100644 data/geo/korean-peninsula.geojson create mode 100644 data/geo/natural-disasters.geojson create mode 100644 data/geo/tech-hubs.geojson create mode 100644 data/geo/threat-origins.geojson create mode 100644 data/geo/who-regions.geojson create mode 100644 forge/src/commands/theme.ts create mode 100644 forge/src/theme/__tests__/palettes.test.ts create mode 100644 forge/src/theme/__tests__/resolver.test.ts create mode 100644 forge/src/theme/palettes.ts create mode 100644 forge/src/theme/resolver.ts create mode 100644 presets/climate-full.json create mode 100644 presets/climate-minimal.json create mode 100644 presets/cyber-full.json create mode 100644 presets/cyber-minimal.json create mode 100644 presets/health-full.json create mode 100644 presets/health-minimal.json create mode 100644 presets/korea-full.json create mode 100644 presets/korea-minimal.json diff --git a/data/geo/conflict-zones.geojson b/data/geo/conflict-zones.geojson new file mode 100644 index 0000000..73e828f --- /dev/null +++ b/data/geo/conflict-zones.geojson @@ -0,0 +1,80 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [30.5234, 50.4501] }, + "properties": { "name": "Ukraine Conflict", "category": "active-conflict", "weight": 10 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [34.3088, 31.3547] }, + "properties": { "name": "Gaza Conflict Zone", "category": "active-conflict", "weight": 10 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [32.5599, 15.5007] }, + "properties": { "name": "Sudan Civil War", "category": "active-conflict", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [45.3182, 2.0469] }, + "properties": { "name": "Somalia Instability", "category": "active-conflict", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-8.0029, 12.6392] }, + "properties": { "name": "Sahel Crisis", "category": "instability", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [36.2765, 33.5138] }, + "properties": { "name": "Syria Post-Conflict", "category": "post-conflict", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [69.2075, 34.5553] }, + "properties": { "name": "Afghanistan Instability", "category": "instability", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [121.5654, 25.0330] }, + "properties": { "name": "Taiwan Strait Flashpoint", "category": "flashpoint", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [125.7625, 39.0392] }, + "properties": { "name": "Korean Peninsula Flashpoint", "category": "flashpoint", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [51.3890, 35.6892] }, + "properties": { "name": "Iran Regional Tensions", "category": "flashpoint", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [96.0785, 19.7633] }, + "properties": { "name": "Myanmar Civil Conflict", "category": "active-conflict", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [44.1910, 15.3694] }, + "properties": { "name": "Yemen Civil War", "category": "active-conflict", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [38.7468, 9.0246] }, + "properties": { "name": "Ethiopia Post-Conflict", "category": "post-conflict", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [13.1801, 32.9023] }, + "properties": { "name": "Libya Instability", "category": "instability", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [20.4489, 44.7866] }, + "properties": { "name": "Serbia-Kosovo Tension", "category": "tension", "weight": 3 } + } + ] +} diff --git a/data/geo/financial-centers.geojson b/data/geo/financial-centers.geojson new file mode 100644 index 0000000..29287ef --- /dev/null +++ b/data/geo/financial-centers.geojson @@ -0,0 +1,65 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-74.0088, 40.7060] }, + "properties": { "name": "NYSE / NASDAQ", "category": "exchange", "weight": 10 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.0922, 51.5155] }, + "properties": { "name": "London Stock Exchange", "category": "exchange", "weight": 9 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [139.7671, 35.6815] }, + "properties": { "name": "Tokyo Stock Exchange", "category": "exchange", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [114.1747, 22.2783] }, + "properties": { "name": "Hong Kong Exchange", "category": "exchange", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [121.4692, 31.2327] }, + "properties": { "name": "Shanghai Stock Exchange", "category": "exchange", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [8.6821, 50.1109] }, + "properties": { "name": "Deutsche Borse", "category": "exchange", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [103.8508, 1.2838] }, + "properties": { "name": "Singapore Exchange", "category": "exchange", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [8.5417, 47.3769] }, + "properties": { "name": "SIX Swiss Exchange", "category": "exchange", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [72.8347, 18.9300] }, + "properties": { "name": "BSE / NSE India", "category": "exchange", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [151.2093, -33.8688] }, + "properties": { "name": "Australian Securities Exchange", "category": "exchange", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [55.2708, 25.2048] }, + "properties": { "name": "Dubai Financial Market", "category": "exchange", "weight": 4 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-46.6333, -23.5505] }, + "properties": { "name": "B3 Brazil Exchange", "category": "exchange", "weight": 5 } + } + ] +} diff --git a/data/geo/korean-peninsula.geojson b/data/geo/korean-peninsula.geojson new file mode 100644 index 0000000..9bd0e2a --- /dev/null +++ b/data/geo/korean-peninsula.geojson @@ -0,0 +1,65 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [126.9780, 37.5665] }, + "properties": { "name": "Seoul (Capital, ROK)", "category": "capital", "weight": 10 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [125.7625, 39.0392] }, + "properties": { "name": "Pyongyang (Capital, DPRK)", "category": "capital", "weight": 10 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [126.6775, 37.9564] }, + "properties": { "name": "DMZ / Joint Security Area", "category": "military", "weight": 9 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [125.7550, 39.8035] }, + "properties": { "name": "Yongbyon Nuclear Complex", "category": "nuclear", "weight": 9 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [129.0837, 41.2988] }, + "properties": { "name": "Punggye-ri Nuclear Test Site", "category": "nuclear", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [124.7050, 39.6601] }, + "properties": { "name": "Sohae Satellite Launch Station", "category": "military", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [129.6652, 40.8560] }, + "properties": { "name": "Tonghae Missile Launch Site", "category": "military", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [129.0756, 35.1796] }, + "properties": { "name": "Busan Major Port", "category": "economic", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [126.7052, 37.4563] }, + "properties": { "name": "Incheon Airport / Port Hub", "category": "economic", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [127.0134, 36.9543] }, + "properties": { "name": "Camp Humphreys (USFK HQ)", "category": "military", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [126.5547, 37.9709] }, + "properties": { "name": "Kaesong Industrial Complex", "category": "economic", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [126.4983, 33.4890] }, + "properties": { "name": "Jeju Naval Base", "category": "military", "weight": 4 } + } + ] +} diff --git a/data/geo/natural-disasters.geojson b/data/geo/natural-disasters.geojson new file mode 100644 index 0000000..96869f5 --- /dev/null +++ b/data/geo/natural-disasters.geojson @@ -0,0 +1,80 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [139.6917, 35.6895] }, + "properties": { "name": "Earthquake / Tsunami Zone", "category": "seismic", "weight": 9 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [120.9842, 14.5995] }, + "properties": { "name": "Typhoon / Earthquake Zone", "category": "multi-hazard", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [106.8456, -6.2088] }, + "properties": { "name": "Earthquake / Flood Zone", "category": "multi-hazard", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [172.6362, -43.5321] }, + "properties": { "name": "Earthquake Zone", "category": "seismic", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-99.1332, 19.4326] }, + "properties": { "name": "Earthquake Zone", "category": "seismic", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-70.6693, -33.4489] }, + "properties": { "name": "Earthquake Zone", "category": "seismic", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [90.4125, 23.8103] }, + "properties": { "name": "Flood / Cyclone Zone", "category": "flood", "weight": 9 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-90.0715, 29.9511] }, + "properties": { "name": "Hurricane Zone", "category": "hurricane", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [72.8777, 19.0760] }, + "properties": { "name": "Monsoon Flood Zone", "category": "flood", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-118.2437, 34.0522] }, + "properties": { "name": "Wildfire Zone", "category": "wildfire", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [151.2093, -33.8688] }, + "properties": { "name": "Bushfire Zone", "category": "wildfire", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [45.3182, 2.0469] }, + "properties": { "name": "Drought Zone", "category": "drought", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [77.1025, 28.7041] }, + "properties": { "name": "Extreme Heat Zone", "category": "heat", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [15.6267, 78.2232] }, + "properties": { "name": "Arctic Melting", "category": "ice-loss", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-51.7214, 64.1836] }, + "properties": { "name": "Ice Sheet Loss", "category": "ice-loss", "weight": 7 } + } + ] +} diff --git a/data/geo/tech-hubs.geojson b/data/geo/tech-hubs.geojson new file mode 100644 index 0000000..f40e1da --- /dev/null +++ b/data/geo/tech-hubs.geojson @@ -0,0 +1,80 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-122.4194, 37.7749] }, + "properties": { "name": "Silicon Valley", "category": "tech-hub", "weight": 10 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-122.3321, 47.6062] }, + "properties": { "name": "Seattle Tech Hub", "category": "tech-hub", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-97.7431, 30.2672] }, + "properties": { "name": "Austin Tech Scene", "category": "tech-hub", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-73.9857, 40.7484] }, + "properties": { "name": "NYC Tech Hub", "category": "tech-hub", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-71.0589, 42.3601] }, + "properties": { "name": "Boston Tech Hub", "category": "tech-hub", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.1278, 51.5074] }, + "properties": { "name": "London Tech City", "category": "tech-hub", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [13.4050, 52.5200] }, + "properties": { "name": "Berlin Startup Hub", "category": "tech-hub", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [34.7818, 32.0853] }, + "properties": { "name": "Tel Aviv Tech Hub", "category": "tech-hub", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [77.5946, 12.9716] }, + "properties": { "name": "Bangalore IT Hub", "category": "tech-hub", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [121.4737, 31.2304] }, + "properties": { "name": "Shanghai Tech Center", "category": "tech-hub", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [114.0579, 22.5431] }, + "properties": { "name": "Shenzhen Hardware Hub", "category": "tech-hub", "weight": 8 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [139.6917, 35.6895] }, + "properties": { "name": "Tokyo Tech District", "category": "tech-hub", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [126.9780, 37.5665] }, + "properties": { "name": "Seoul Digital City", "category": "tech-hub", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [103.8198, 1.3521] }, + "properties": { "name": "Singapore Tech Hub", "category": "tech-hub", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [121.5654, 25.0330] }, + "properties": { "name": "Taipei Semiconductor Hub", "category": "semiconductor", "weight": 9 } + } + ] +} diff --git a/data/geo/threat-origins.geojson b/data/geo/threat-origins.geojson new file mode 100644 index 0000000..032a53d --- /dev/null +++ b/data/geo/threat-origins.geojson @@ -0,0 +1,55 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [37.6173, 55.7558] }, + "properties": { "name": "APT28 / APT29 (Russia)", "category": "state-actor", "weight": 10 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [125.7625, 39.0392] }, + "properties": { "name": "Lazarus Group (DPRK)", "category": "state-actor", "weight": 9 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [116.4074, 39.9042] }, + "properties": { "name": "APT40 / APT41 (China)", "category": "state-actor", "weight": 9 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [51.3890, 35.6892] }, + "properties": { "name": "APT33 / APT35 (Iran)", "category": "state-actor", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [30.3351, 59.9343] }, + "properties": { "name": "Fancy Bear / IRA (Russia)", "category": "state-actor", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [104.0668, 30.5728] }, + "properties": { "name": "APT10 (China)", "category": "state-actor", "weight": 6 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [73.0479, 33.6844] }, + "properties": { "name": "Emerging Threat Region", "category": "emerging-threat", "weight": 4 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [3.3792, 6.5244] }, + "properties": { "name": "Cybercrime Hub (Nigeria)", "category": "cybercrime", "weight": 5 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [26.1025, 44.4268] }, + "properties": { "name": "Cybercrime Hub (Romania)", "category": "cybercrime", "weight": 4 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-46.6333, -23.5505] }, + "properties": { "name": "Cybercrime Hub (Brazil)", "category": "cybercrime", "weight": 4 } + } + ] +} diff --git a/data/geo/who-regions.geojson b/data/geo/who-regions.geojson new file mode 100644 index 0000000..48b0900 --- /dev/null +++ b/data/geo/who-regions.geojson @@ -0,0 +1,45 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [6.1432, 46.2044] }, + "properties": { "name": "WHO Headquarters", "category": "headquarters", "weight": 10 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [15.2429, -4.2634] }, + "properties": { "name": "WHO AFRO", "category": "regional-office", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-77.0369, 38.9072] }, + "properties": { "name": "WHO AMRO / PAHO", "category": "regional-office", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [77.2090, 28.6139] }, + "properties": { "name": "WHO SEARO", "category": "regional-office", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [12.5683, 55.6761] }, + "properties": { "name": "WHO EURO", "category": "regional-office", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [31.2357, 30.0444] }, + "properties": { "name": "WHO EMRO", "category": "regional-office", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [120.9842, 14.5995] }, + "properties": { "name": "WHO WPRO", "category": "regional-office", "weight": 7 } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-84.3880, 33.7490] }, + "properties": { "name": "CDC Headquarters", "category": "surveillance-center", "weight": 8 } + } + ] +} diff --git a/forge/bin/forge.ts b/forge/bin/forge.ts index b71c287..3df6509 100644 --- a/forge/bin/forge.ts +++ b/forge/bin/forge.ts @@ -12,6 +12,7 @@ import { registerDeployCommand } from '../src/commands/deploy.js'; import { registerEnvCommands } from '../src/commands/env.js'; import { registerPresetCommands } from '../src/commands/preset.js'; import { registerSetupCommand } from '../src/commands/setup.js'; +import { registerThemeCommands } from '../src/commands/theme.js'; const program = new Command(); @@ -35,5 +36,6 @@ registerDeployCommand(program); registerEnvCommands(program); registerPresetCommands(program); registerSetupCommand(program); +registerThemeCommands(program); program.parse(); diff --git a/forge/src/commands/preset.ts b/forge/src/commands/preset.ts index 8f7b01f..ff5aa44 100644 --- a/forge/src/commands/preset.ts +++ b/forge/src/commands/preset.ts @@ -23,13 +23,16 @@ export function registerPresetCommands(program: Command): void { const files = readdirSync(presetsDir).filter(f => f.endsWith('.json')); const presets = files.map(f => { const content = JSON.parse(readFileSync(resolve(presetsDir, f), 'utf-8')); + const meta = content._meta ?? {}; return { name: basename(f, '.json'), domain: content.monitor?.domain ?? 'general', sources: content.sources?.length ?? 0, layers: content.layers?.length ?? 0, panels: content.panels?.length ?? 0, - description: content.monitor?.description ?? '', + category: meta.category ?? content.monitor?.domain ?? 'general', + difficulty: meta.difficulty ?? '-', + description: meta.preview_description ?? content.monitor?.description ?? '', }; }); diff --git a/forge/src/commands/theme.ts b/forge/src/commands/theme.ts new file mode 100644 index 0000000..6e3e182 --- /dev/null +++ b/forge/src/commands/theme.ts @@ -0,0 +1,108 @@ +import type { Command } from 'commander'; +import { loadConfig, updateConfig } from '../config/loader.js'; +import { ThemeSchema } from '../config/schema.js'; +import { PALETTES, PALETTE_NAMES } from '../theme/palettes.js'; +import { resolveTheme } from '../theme/resolver.js'; +import { formatOutput, success, failure, type OutputFormat } from '../output/format.js'; + +export function registerThemeCommands(program: Command): void { + const theme = program.command('theme').description('Manage dashboard visual theme'); + + theme + .command('set') + .description('Set theme options') + .option('--mode ', 'Theme mode: dark, light, auto') + .option('--palette ', `Palette preset: ${PALETTE_NAMES.join(', ')}`) + .option('--accent ', 'Custom accent color (#hex)') + .option('--accent-hover ', 'Custom accent hover color (#hex)') + .option('--panel-position ', 'Panel position: right, left') + .option('--panel-width ', 'Panel width in pixels (200-800)') + .option('--compact', 'Enable compact mode') + .option('--no-compact', 'Disable compact mode') + .action((opts) => { + const format = (program.opts().format ?? 'table') as OutputFormat; + const dryRun = program.opts().dryRun ?? false; + + try { + const updates: Record = {}; + + if (opts.mode) updates.mode = opts.mode; + if (opts.palette) updates.palette = opts.palette; + if (opts.panelPosition) updates.panelPosition = opts.panelPosition; + if (opts.panelWidth) updates.panelWidth = parseInt(opts.panelWidth, 10); + if (opts.compact !== undefined) updates.compactMode = opts.compact; + + const colors: Record = {}; + if (opts.accent) colors.accent = opts.accent; + if (opts.accentHover) colors.accentHover = opts.accentHover; + if (Object.keys(colors).length > 0) updates.colors = colors; + + if (Object.keys(updates).length === 0) { + console.log(formatOutput(failure('theme set', 'No options provided. Use --mode, --palette, --accent, etc.'), format)); + process.exit(1); + } + + // Validate the partial update + ThemeSchema.partial().parse(updates); + + if (dryRun) { + console.log(formatOutput( + success('theme set --dry-run', updates, { + changes: [{ type: 'modified', file: 'monitor-forge.config.json', description: 'Would update theme settings' }], + }), + format, + )); + return; + } + + const { config, path } = updateConfig(cfg => ({ + ...cfg, + theme: { + ...cfg.theme, + ...updates, + colors: { ...cfg.theme.colors, ...((updates.colors as Record) ?? {}) }, + }, + })); + + console.log(formatOutput( + success('theme set', config.theme, { + changes: [{ type: 'modified', file: path, description: 'Updated theme settings' }], + next_steps: ['forge build', 'forge dev'], + }), + format, + )); + } catch (err) { + console.log(formatOutput(failure('theme set', String(err)), format)); + process.exit(1); + } + }); + + theme + .command('status') + .description('Show current theme configuration and resolved values') + .action(() => { + const format = (program.opts().format ?? 'table') as OutputFormat; + try { + const config = loadConfig(); + const resolved = resolveTheme(config); + + console.log(formatOutput( + success('theme status', { + mode: config.theme.mode, + palette: config.theme.palette, + customColors: config.theme.colors, + panelPosition: config.theme.panelPosition, + panelWidth: config.theme.panelWidth, + compactMode: config.theme.compactMode, + resolvedDarkAccent: resolved.dark.accent, + resolvedLightAccent: resolved.light.accent, + availablePalettes: PALETTE_NAMES, + }), + format, + )); + } catch (err) { + console.log(formatOutput(failure('theme status', String(err)), format)); + process.exit(1); + } + }); +} diff --git a/forge/src/config/defaults.ts b/forge/src/config/defaults.ts index 5928d2a..7eaa3ba 100644 --- a/forge/src/config/defaults.ts +++ b/forge/src/config/defaults.ts @@ -61,5 +61,14 @@ export function createDefaultConfig(overrides?: Partial): Mo outDir: 'dist', ...overrides?.build, }, + theme: { + mode: 'dark' as const, + palette: 'default' as const, + colors: {}, + panelPosition: 'right' as const, + panelWidth: 380, + compactMode: false, + ...overrides?.theme, + }, }; } diff --git a/forge/src/config/loader.test.ts b/forge/src/config/loader.test.ts index f74a45e..d7d5991 100644 --- a/forge/src/config/loader.test.ts +++ b/forge/src/config/loader.test.ts @@ -76,6 +76,7 @@ describe('writeConfig', () => { map: { style: 'https://example.com/style.json', center: [0, 0] as [number, number], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator' as const, dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory' as const, ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, build: { target: 'vercel' as const, outDir: 'dist' }, + theme: { mode: 'dark' as const, palette: 'default' as const, colors: {}, panelPosition: 'right' as const, panelWidth: 380, compactMode: false }, }; writeConfig(config, '/test'); @@ -98,6 +99,7 @@ describe('writeConfig', () => { map: { style: 'https://example.com/style.json', center: [0, 0] as [number, number], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator' as const, dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory' as const, ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, build: { target: 'vercel' as const, outDir: 'dist' }, + theme: { mode: 'dark' as const, palette: 'default' as const, colors: {}, panelPosition: 'right' as const, panelWidth: 380, compactMode: false }, }; const path = writeConfig(config, '/test'); expect(path).toContain('monitor-forge.config.json'); diff --git a/forge/src/config/schema.test.ts b/forge/src/config/schema.test.ts index 91ae796..d874292 100644 --- a/forge/src/config/schema.test.ts +++ b/forge/src/config/schema.test.ts @@ -538,6 +538,7 @@ describe('defineConfig', () => { map: { style: 'https://example.com/style.json', center: [0, 0], zoom: 3, minZoom: 1, maxZoom: 20, projection: 'mercator', dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory', ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, build: { target: 'vercel', outDir: 'dist' }, + theme: { mode: 'dark' as const, palette: 'default' as const, colors: {}, panelPosition: 'right' as const, panelWidth: 380, compactMode: false }, }); expect(config.monitor.name).toBe('Test'); }); diff --git a/forge/src/config/schema.ts b/forge/src/config/schema.ts index 5968170..0fc6973 100644 --- a/forge/src/config/schema.ts +++ b/forge/src/config/schema.ts @@ -137,6 +137,22 @@ export const BrandingSchema = z.object({ favicon: z.string().optional(), }); +// ─── Theme Schema ─────────────────────────────────────────── + +export const ThemeColorsSchema = z.object({ + accent: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + accentHover: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), +}); + +export const ThemeSchema = z.object({ + mode: z.enum(['dark', 'light', 'auto']).default('dark'), + palette: z.enum(['default', 'ocean', 'forest', 'sunset', 'midnight', 'cyberpunk', 'minimal']).default('default'), + colors: ThemeColorsSchema.default({}), + panelPosition: z.enum(['right', 'left']).default('right'), + panelWidth: z.number().int().min(200).max(800).default(380), + compactMode: z.boolean().default(false), +}); + // ─── Monitor (Identity) Schema ────────────────────────────── export const MonitorSchema = z.object({ @@ -159,6 +175,7 @@ export const MonitorForgeConfigSchema = z.object({ map: MapSchema.default({}), backend: BackendSchema.default({}), build: BuildSchema.default({}), + theme: ThemeSchema.default({}), }); export type MonitorForgeConfig = z.infer; diff --git a/forge/src/generators/env-generator.test.ts b/forge/src/generators/env-generator.test.ts index 306ff3e..20d5621 100644 --- a/forge/src/generators/env-generator.test.ts +++ b/forge/src/generators/env-generator.test.ts @@ -10,6 +10,7 @@ function buildConfig(overrides?: Partial): MonitorForgeConfi map: { style: 'https://example.com/style.json', center: [0, 0], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator', dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory', ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, build: { target: 'vercel', outDir: 'dist' }, + theme: { mode: 'dark' as const, palette: 'default' as const, colors: {}, panelPosition: 'right' as const, panelWidth: 380, compactMode: false }, ...overrides, }; } diff --git a/forge/src/generators/manifest-generator.test.ts b/forge/src/generators/manifest-generator.test.ts index 4fc81ee..7a9886a 100644 --- a/forge/src/generators/manifest-generator.test.ts +++ b/forge/src/generators/manifest-generator.test.ts @@ -10,18 +10,20 @@ function buildConfig(overrides?: Partial): MonitorForgeConfi map: { style: 'https://example.com/style.json', center: [0, 0], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator', dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory', ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, build: { target: 'vercel', outDir: 'dist' }, + theme: { mode: 'dark' as const, palette: 'default' as const, colors: {}, panelPosition: 'right' as const, panelWidth: 380, compactMode: false }, ...overrides, }; } describe('generateManifests', () => { - it('returns 4 manifest files', () => { + it('returns 5 manifest files', () => { const result = generateManifests(buildConfig()); expect(Object.keys(result)).toEqual([ 'source-manifest.ts', 'layer-manifest.ts', 'panel-manifest.ts', 'config-resolved.ts', + 'theme-resolved.ts', ]); }); diff --git a/forge/src/generators/manifest-generator.ts b/forge/src/generators/manifest-generator.ts index 002f39d..712bf8d 100644 --- a/forge/src/generators/manifest-generator.ts +++ b/forge/src/generators/manifest-generator.ts @@ -1,4 +1,5 @@ import type { MonitorForgeConfig } from '../config/schema.js'; +import { resolveTheme } from '../theme/resolver.js'; export function generateManifests(config: MonitorForgeConfig): Record { return { @@ -6,6 +7,7 @@ export function generateManifests(config: MonitorForgeConfig): Record): MonitorForgeConfi map: { style: 'https://example.com/style.json', center: [0, 0], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator', dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory', ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, build: { target: 'vercel', outDir: 'dist' }, + theme: { mode: 'dark' as const, palette: 'default' as const, colors: {}, panelPosition: 'right' as const, panelWidth: 380, compactMode: false }, ...overrides, }; } diff --git a/forge/src/theme/__tests__/palettes.test.ts b/forge/src/theme/__tests__/palettes.test.ts new file mode 100644 index 0000000..8062924 --- /dev/null +++ b/forge/src/theme/__tests__/palettes.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { PALETTES, PALETTE_NAMES, type PaletteColors } from '../palettes.js'; + +const HEX_RE = /^#[0-9A-Fa-f]{6}$/; + +describe('palettes', () => { + it('exports 7 palettes', () => { + expect(PALETTE_NAMES).toHaveLength(7); + }); + + it('PALETTE_NAMES matches PALETTES keys', () => { + expect(PALETTE_NAMES).toEqual(Object.keys(PALETTES)); + }); + + it('includes expected palette names', () => { + const expected = ['default', 'ocean', 'forest', 'sunset', 'midnight', 'cyberpunk', 'minimal']; + for (const name of expected) { + expect(PALETTES[name]).toBeDefined(); + } + }); + + describe.each(PALETTE_NAMES)('palette: %s', (name) => { + const palette = PALETTES[name]; + + it('has name and label', () => { + expect(palette.name).toBe(name); + expect(palette.label.length).toBeGreaterThan(0); + }); + + it('has dark and light color sets', () => { + expect(palette.dark).toBeDefined(); + expect(palette.light).toBeDefined(); + }); + + const colorKeys: (keyof PaletteColors)[] = [ + 'fg', 'bg', 'bgSecondary', 'bgPanel', 'border', + 'accent', 'accentHover', 'success', 'danger', 'warning', 'textMuted', + ]; + + for (const mode of ['dark', 'light'] as const) { + it(`${mode} colors are all valid hex`, () => { + for (const key of colorKeys) { + expect(palette[mode][key], `${mode}.${key}`).toMatch(HEX_RE); + } + }); + } + }); + + it('default palette dark colors match base.css values', () => { + const dark = PALETTES.default.dark; + expect(dark.fg).toBe('#e0e0e0'); + expect(dark.bg).toBe('#0a0a0f'); + expect(dark.bgSecondary).toBe('#12121a'); + expect(dark.bgPanel).toBe('#1a1a2e'); + expect(dark.border).toBe('#2a2a3e'); + expect(dark.accent).toBe('#0052CC'); + }); +}); diff --git a/forge/src/theme/__tests__/resolver.test.ts b/forge/src/theme/__tests__/resolver.test.ts new file mode 100644 index 0000000..123dd9d --- /dev/null +++ b/forge/src/theme/__tests__/resolver.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { resolveTheme } from '../resolver.js'; +import { PALETTES } from '../palettes.js'; +import { createDefaultConfig } from '../../config/defaults.js'; +import { MonitorForgeConfigSchema } from '../../config/schema.js'; + +function buildConfig(overrides?: Record) { + return MonitorForgeConfigSchema.parse(createDefaultConfig(overrides as never)); +} + +describe('resolveTheme', () => { + it('resolves default config to default palette dark colors', () => { + const config = buildConfig(); + const resolved = resolveTheme(config); + + expect(resolved.mode).toBe('dark'); + expect(resolved.dark).toEqual(PALETTES.default.dark); + expect(resolved.light).toEqual(PALETTES.default.light); + expect(resolved.panelPosition).toBe('right'); + expect(resolved.panelWidth).toBe(380); + expect(resolved.compactMode).toBe(false); + }); + + it('resolves ocean palette correctly', () => { + const config = buildConfig({ theme: { palette: 'ocean' } }); + const resolved = resolveTheme(config); + + expect(resolved.dark.accent).toBe(PALETTES.ocean.dark.accent); + expect(resolved.light.accent).toBe(PALETTES.ocean.light.accent); + }); + + it('custom accent overrides palette accent', () => { + const config = buildConfig({ theme: { palette: 'ocean', colors: { accent: '#FF0000' } } }); + const resolved = resolveTheme(config); + + expect(resolved.dark.accent).toBe('#FF0000'); + expect(resolved.light.accent).toBe('#FF0000'); + }); + + it('falls back to branding.primaryColor when no custom accent', () => { + const config = buildConfig({ + monitor: { name: 'Test', slug: 'test', domain: 'test', branding: { primaryColor: '#123456' } }, + }); + const resolved = resolveTheme(config); + + expect(resolved.dark.accent).toBe('#123456'); + expect(resolved.light.accent).toBe('#123456'); + }); + + it('does not use branding fallback when primaryColor is default #0052CC', () => { + const config = buildConfig(); + const resolved = resolveTheme(config); + + // Should use palette accent, not branding fallback + expect(resolved.dark.accent).toBe(PALETTES.default.dark.accent); + }); + + it('always emits both dark and light color sets', () => { + const config = buildConfig({ theme: { mode: 'light' } }); + const resolved = resolveTheme(config); + + expect(resolved.mode).toBe('light'); + expect(resolved.dark).toBeDefined(); + expect(resolved.light).toBeDefined(); + }); + + it('preserves panelPosition and panelWidth', () => { + const config = buildConfig({ theme: { panelPosition: 'left', panelWidth: 500 } }); + const resolved = resolveTheme(config); + + expect(resolved.panelPosition).toBe('left'); + expect(resolved.panelWidth).toBe(500); + }); + + it('falls back to default palette for unknown palette name', () => { + // Schema enforces valid names, but resolver handles gracefully + const config = buildConfig(); + config.theme.palette = 'nonexistent' as never; + const resolved = resolveTheme(config); + + expect(resolved.dark).toEqual(PALETTES.default.dark); + }); + + it('non-default palette accent is NOT overridden by branding.primaryColor', () => { + const config = buildConfig({ + monitor: { name: 'Test', slug: 'test', domain: 'test', branding: { primaryColor: '#00FF41' } }, + theme: { palette: 'cyberpunk' }, + }); + const resolved = resolveTheme(config); + + // Palette accent should win when a non-default palette is chosen + expect(resolved.dark.accent).toBe(PALETTES.cyberpunk.dark.accent); + expect(resolved.light.accent).toBe(PALETTES.cyberpunk.light.accent); + }); + + it('branding.primaryColor only applies when palette is default', () => { + const config = buildConfig({ + monitor: { name: 'Test', slug: 'test', domain: 'test', branding: { primaryColor: '#123456' } }, + theme: { palette: 'default' }, + }); + const resolved = resolveTheme(config); + + expect(resolved.dark.accent).toBe('#123456'); + }); +}); diff --git a/forge/src/theme/palettes.ts b/forge/src/theme/palettes.ts new file mode 100644 index 0000000..a09bd09 --- /dev/null +++ b/forge/src/theme/palettes.ts @@ -0,0 +1,235 @@ +export interface PaletteColors { + fg: string; + bg: string; + bgSecondary: string; + bgPanel: string; + border: string; + accent: string; + accentHover: string; + success: string; + danger: string; + warning: string; + textMuted: string; +} + +export interface PaletteDefinition { + name: string; + label: string; + dark: PaletteColors; + light: PaletteColors; +} + +export const PALETTES: Record = { + default: { + name: 'default', + label: 'Default', + dark: { + fg: '#e0e0e0', + bg: '#0a0a0f', + bgSecondary: '#12121a', + bgPanel: '#1a1a2e', + border: '#2a2a3e', + accent: '#0052CC', + accentHover: '#0066ff', + success: '#00b894', + danger: '#ff6b6b', + warning: '#ffc107', + textMuted: '#888888', + }, + light: { + fg: '#1a1a2e', + bg: '#f5f5f7', + bgSecondary: '#eaeaef', + bgPanel: '#ffffff', + border: '#d0d0d8', + accent: '#0052CC', + accentHover: '#003d99', + success: '#00a884', + danger: '#e04545', + warning: '#d49e00', + textMuted: '#666666', + }, + }, + ocean: { + name: 'ocean', + label: 'Ocean', + dark: { + fg: '#ccd6f6', + bg: '#0a192f', + bgSecondary: '#0d1f3c', + bgPanel: '#112240', + border: '#233554', + accent: '#64ffda', + accentHover: '#45e6c0', + success: '#64ffda', + danger: '#ff6b6b', + warning: '#ffd166', + textMuted: '#8892b0', + }, + light: { + fg: '#0a192f', + bg: '#f0f4f8', + bgSecondary: '#e2e8f0', + bgPanel: '#ffffff', + border: '#cbd5e1', + accent: '#0d9488', + accentHover: '#0a7c72', + success: '#0d9488', + danger: '#e04545', + warning: '#d49e00', + textMuted: '#64748b', + }, + }, + forest: { + name: 'forest', + label: 'Forest', + dark: { + fg: '#c9d1d9', + bg: '#0d1117', + bgSecondary: '#111820', + bgPanel: '#161b22', + border: '#30363d', + accent: '#3fb950', + accentHover: '#2ea043', + success: '#3fb950', + danger: '#f85149', + warning: '#d29922', + textMuted: '#8b949e', + }, + light: { + fg: '#1f2328', + bg: '#f6f8fa', + bgSecondary: '#eef1f3', + bgPanel: '#ffffff', + border: '#d0d7de', + accent: '#1a7f37', + accentHover: '#116329', + success: '#1a7f37', + danger: '#cf222e', + warning: '#9a6700', + textMuted: '#656d76', + }, + }, + sunset: { + name: 'sunset', + label: 'Sunset', + dark: { + fg: '#f0e6d3', + bg: '#1a1020', + bgSecondary: '#201428', + bgPanel: '#251830', + border: '#3d2a4a', + accent: '#ff6b6b', + accentHover: '#ff5252', + success: '#66bb6a', + danger: '#ff6b6b', + warning: '#ffca28', + textMuted: '#a08090', + }, + light: { + fg: '#2d1f33', + bg: '#fdf6f0', + bgSecondary: '#f5ece3', + bgPanel: '#ffffff', + border: '#e0d0c0', + accent: '#d84315', + accentHover: '#bf360c', + success: '#2e7d32', + danger: '#d84315', + warning: '#f57f17', + textMuted: '#78606e', + }, + }, + midnight: { + name: 'midnight', + label: 'Midnight', + dark: { + fg: '#f8fafc', + bg: '#020817', + bgSecondary: '#080e1f', + bgPanel: '#0f172a', + border: '#1e293b', + accent: '#818cf8', + accentHover: '#6366f1', + success: '#34d399', + danger: '#f87171', + warning: '#fbbf24', + textMuted: '#94a3b8', + }, + light: { + fg: '#0f172a', + bg: '#f8fafc', + bgSecondary: '#f1f5f9', + bgPanel: '#ffffff', + border: '#e2e8f0', + accent: '#4f46e5', + accentHover: '#4338ca', + success: '#059669', + danger: '#dc2626', + warning: '#d97706', + textMuted: '#64748b', + }, + }, + cyberpunk: { + name: 'cyberpunk', + label: 'Cyberpunk', + dark: { + fg: '#00ff41', + bg: '#0a0a0a', + bgSecondary: '#0f0f0f', + bgPanel: '#111111', + border: '#1a1a1a', + accent: '#ff00ff', + accentHover: '#cc00cc', + success: '#00ff41', + danger: '#ff0040', + warning: '#ffff00', + textMuted: '#666666', + }, + light: { + fg: '#1a0025', + bg: '#f5f0ff', + bgSecondary: '#ece5f5', + bgPanel: '#ffffff', + border: '#d0c0e0', + accent: '#9900cc', + accentHover: '#7700aa', + success: '#008020', + danger: '#cc0033', + warning: '#b38f00', + textMuted: '#665577', + }, + }, + minimal: { + name: 'minimal', + label: 'Minimal', + dark: { + fg: '#d4d4d4', + bg: '#171717', + bgSecondary: '#1c1c1c', + bgPanel: '#212121', + border: '#333333', + accent: '#ffffff', + accentHover: '#e0e0e0', + success: '#4ade80', + danger: '#f87171', + warning: '#facc15', + textMuted: '#737373', + }, + light: { + fg: '#171717', + bg: '#ffffff', + bgSecondary: '#fafafa', + bgPanel: '#ffffff', + border: '#e5e5e5', + accent: '#000000', + accentHover: '#333333', + success: '#16a34a', + danger: '#dc2626', + warning: '#ca8a04', + textMuted: '#737373', + }, + }, +}; + +export const PALETTE_NAMES = Object.keys(PALETTES); diff --git a/forge/src/theme/resolver.ts b/forge/src/theme/resolver.ts new file mode 100644 index 0000000..312f46d --- /dev/null +++ b/forge/src/theme/resolver.ts @@ -0,0 +1,47 @@ +import { PALETTES, type PaletteColors } from './palettes.js'; +import type { MonitorForgeConfig } from '../config/schema.js'; + +export interface ResolvedTheme { + mode: 'dark' | 'light' | 'auto'; + dark: PaletteColors; + light: PaletteColors; + panelPosition: 'right' | 'left'; + panelWidth: number; + compactMode: boolean; +} + +export function resolveTheme(config: MonitorForgeConfig): ResolvedTheme { + const theme = config.theme; + const palette = PALETTES[theme.palette] ?? PALETTES.default; + + // Start with palette colors + const dark = { ...palette.dark }; + const light = { ...palette.light }; + + // Overlay custom accent: explicit theme.colors.accent wins, + // then branding.primaryColor only if palette is 'default' (avoids overriding palette accents) + const customAccent = theme.colors.accent ?? ( + theme.palette === 'default' && config.monitor.branding.primaryColor !== '#0052CC' + ? config.monitor.branding.primaryColor + : undefined + ); + + if (customAccent) { + dark.accent = customAccent; + light.accent = customAccent; + } + + if (theme.colors.accentHover) { + dark.accentHover = theme.colors.accentHover; + light.accentHover = theme.colors.accentHover; + } + + return { + mode: theme.mode, + dark, + light, + panelPosition: theme.panelPosition, + panelWidth: theme.panelWidth, + compactMode: theme.compactMode, + }; +} diff --git a/presets/blank.json b/presets/blank.json index 4cb93af..b54645f 100644 --- a/presets/blank.json +++ b/presets/blank.json @@ -1,4 +1,11 @@ { + "_meta": { + "category": "general", + "difficulty": "beginner", + "requires_api_keys": [], + "optional_api_keys": [], + "preview_description": "Empty dashboard ready for customization" + }, "monitor": { "name": "My Monitor", "slug": "my-monitor", diff --git a/presets/climate-full.json b/presets/climate-full.json new file mode 100644 index 0000000..d482762 --- /dev/null +++ b/presets/climate-full.json @@ -0,0 +1,167 @@ +{ + "_meta": { + "category": "climate", + "difficulty": "intermediate", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": ["OPENROUTER_API_KEY"], + "preview_description": "Comprehensive climate and disaster monitoring with heatmap layers and AI analysis" + }, + "monitor": { + "name": "Climate Intelligence", + "slug": "climate-intelligence", + "description": "Full-featured climate intelligence dashboard with disaster tracking and AI analysis", + "domain": "climate", + "tags": ["climate", "environment", "sustainability", "disasters"], + "branding": { + "primaryColor": "#00AA55" + } + }, + "sources": [ + { + "name": "carbon-brief", + "type": "rss", + "url": "https://www.carbonbrief.org/feed/", + "category": "climate-news", + "tier": 1, + "interval": 900, + "language": "en", + "tags": ["climate", "science"] + }, + { + "name": "phys-org-earth", + "type": "rss", + "url": "https://phys.org/rss-feed/earth-news/", + "category": "environment", + "tier": 2, + "interval": 1800, + "language": "en", + "tags": ["earth", "science"] + }, + { + "name": "guardian-environment", + "type": "rss", + "url": "https://www.theguardian.com/environment/rss", + "category": "climate-news", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["climate", "environment"] + }, + { + "name": "reliefweb-disasters", + "type": "rss", + "url": "https://reliefweb.int/updates/rss.xml", + "category": "disasters", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["disasters", "humanitarian"] + }, + { + "name": "nasa-earth-observatory", + "type": "rss", + "url": "https://earthobservatory.nasa.gov/feeds/earth-observatory.rss", + "category": "earth-observation", + "tier": 2, + "interval": 3600, + "language": "en", + "tags": ["nasa", "satellite"] + }, + { + "name": "noaa-climate", + "type": "rss", + "url": "https://www.climate.gov/news-features/feed", + "category": "climate-data", + "tier": 2, + "interval": 3600, + "language": "en", + "tags": ["noaa", "climate-data"] + } + ], + "layers": [ + { + "name": "disaster-zones", + "type": "heatmap", + "displayName": "Disaster Risk Zones", + "color": "#FF6600", + "data": { + "source": "static", + "path": "data/geo/natural-disasters.geojson" + }, + "defaultVisible": true, + "category": "hazards" + } + ], + "panels": [ + { + "name": "climate-news", + "type": "news-feed", + "displayName": "Climate News", + "position": 0, + "config": { + "categories": ["climate-news", "environment", "disasters"], + "maxItems": 50 + } + }, + { + "name": "ai-brief", + "type": "ai-brief", + "displayName": "Climate Brief", + "position": 1, + "config": { + "refreshInterval": 600 + } + }, + { + "name": "entity-tracker", + "type": "entity-tracker", + "displayName": "Key Organizations", + "position": 2, + "config": { + "entities": ["IPCC", "UNFCCC", "NASA", "NOAA", "WHO", "El Nino", "La Nina", "COP"], + "maxEntities": 20 + } + }, + { + "name": "service-status", + "type": "service-status", + "displayName": "Source Status", + "position": 3, + "config": {} + } + ], + "ai": { + "enabled": true, + "fallbackChain": ["groq", "openrouter"], + "providers": { + "groq": { + "model": "llama-3.3-70b-versatile", + "apiKeyEnv": "GROQ_API_KEY" + }, + "openrouter": { + "model": "anthropic/claude-sonnet-4", + "apiKeyEnv": "OPENROUTER_API_KEY" + } + }, + "analysis": { + "summarization": true, + "entityExtraction": true, + "sentimentAnalysis": true, + "focalPointDetection": true, + "customPrompt": "Focus on climate change developments, extreme weather events, natural disasters, environmental policy, and emissions data. Highlight escalating climate risks and international climate negotiations." + } + }, + "map": { + "style": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + "center": [0, 20], + "zoom": 3, + "projection": "globe", + "dayNightOverlay": true, + "atmosphericGlow": true, + "idleRotation": true, + "idleRotationSpeed": 0.3 + }, + "theme": { + "palette": "forest" + } +} diff --git a/presets/climate-minimal.json b/presets/climate-minimal.json new file mode 100644 index 0000000..1cc64d7 --- /dev/null +++ b/presets/climate-minimal.json @@ -0,0 +1,98 @@ +{ + "_meta": { + "category": "climate", + "difficulty": "beginner", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": [], + "preview_description": "Monitor climate news from Carbon Brief, Phys.org, and The Guardian" + }, + "monitor": { + "name": "Climate Monitor", + "slug": "climate-monitor", + "description": "Minimal climate intelligence dashboard", + "domain": "climate", + "tags": ["climate", "environment", "sustainability"], + "branding": { + "primaryColor": "#00AA55" + } + }, + "sources": [ + { + "name": "carbon-brief", + "type": "rss", + "url": "https://www.carbonbrief.org/feed/", + "category": "climate-news", + "tier": 1, + "interval": 900, + "language": "en", + "tags": ["climate", "science"] + }, + { + "name": "phys-org-earth", + "type": "rss", + "url": "https://phys.org/rss-feed/earth-news/", + "category": "environment", + "tier": 2, + "interval": 1800, + "language": "en", + "tags": ["earth", "science"] + }, + { + "name": "guardian-environment", + "type": "rss", + "url": "https://www.theguardian.com/environment/rss", + "category": "climate-news", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["climate", "environment"] + } + ], + "layers": [], + "panels": [ + { + "name": "climate-news", + "type": "news-feed", + "displayName": "Climate News", + "position": 0, + "config": { + "categories": ["climate-news", "environment"], + "maxItems": 30 + } + }, + { + "name": "ai-brief", + "type": "ai-brief", + "displayName": "Climate Brief", + "position": 1, + "config": { + "refreshInterval": 900 + } + } + ], + "ai": { + "enabled": true, + "fallbackChain": ["groq"], + "providers": { + "groq": { + "model": "llama-3.3-70b-versatile", + "apiKeyEnv": "GROQ_API_KEY" + } + }, + "analysis": { + "summarization": true, + "entityExtraction": true, + "sentimentAnalysis": false + } + }, + "map": { + "style": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + "center": [0, 20], + "zoom": 3, + "projection": "globe", + "atmosphericGlow": true + }, + "theme": { + "palette": "forest" + } +} diff --git a/presets/cyber-full.json b/presets/cyber-full.json new file mode 100644 index 0000000..e1ae91e --- /dev/null +++ b/presets/cyber-full.json @@ -0,0 +1,174 @@ +{ + "_meta": { + "category": "cybersecurity", + "difficulty": "intermediate", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": ["OPENROUTER_API_KEY"], + "preview_description": "Comprehensive cybersecurity dashboard with threat actor tracking, advisories, and AI analysis" + }, + "monitor": { + "name": "Cyber Monitor", + "slug": "cyber-monitor", + "description": "Full-featured cybersecurity intelligence dashboard with threat tracking and AI analysis", + "domain": "cybersecurity", + "tags": ["cyber", "infosec", "threats"], + "branding": { + "primaryColor": "#00FF41" + } + }, + "sources": [ + { + "name": "krebs-on-security", + "type": "rss", + "url": "https://krebsonsecurity.com/feed/", + "category": "cyber-news", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["infosec", "breaches"] + }, + { + "name": "the-hacker-news", + "type": "rss", + "url": "https://feeds.feedburner.com/TheHackersNews", + "category": "cyber-news", + "tier": 1, + "interval": 300, + "language": "en", + "tags": ["cyber", "vulnerabilities"] + }, + { + "name": "bleeping-computer", + "type": "rss", + "url": "https://www.bleepingcomputer.com/feed/", + "category": "cyber-news", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["malware", "ransomware"] + }, + { + "name": "cisa-alerts", + "type": "rss", + "url": "https://www.cisa.gov/cybersecurity-advisories/all.xml", + "category": "advisories", + "tier": 1, + "interval": 1800, + "language": "en", + "tags": ["advisories", "government"] + }, + { + "name": "dark-reading", + "type": "rss", + "url": "https://www.darkreading.com/rss_feed.asp", + "category": "cyber-news", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["cyber", "enterprise"] + }, + { + "name": "schneier-blog", + "type": "rss", + "url": "https://www.schneier.com/feed/", + "category": "analysis", + "tier": 2, + "interval": 3600, + "language": "en", + "tags": ["analysis", "cryptography"] + }, + { + "name": "ars-security", + "type": "rss", + "url": "https://feeds.arstechnica.com/arstechnica/security", + "category": "cyber-news", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["security", "tech"] + } + ], + "layers": [ + { + "name": "threat-origins", + "type": "points", + "displayName": "Threat Actor Origins", + "color": "#FF0040", + "data": { + "source": "static", + "path": "data/geo/threat-origins.geojson" + }, + "defaultVisible": true, + "category": "threats" + } + ], + "panels": [ + { + "name": "cyber-news", + "type": "news-feed", + "displayName": "Cyber News", + "position": 0, + "config": { + "categories": ["cyber-news", "advisories"], + "maxItems": 50 + } + }, + { + "name": "ai-brief", + "type": "ai-brief", + "displayName": "Threat Brief", + "position": 1, + "config": { + "refreshInterval": 600 + } + }, + { + "name": "entity-tracker", + "type": "entity-tracker", + "displayName": "Threat Actors", + "position": 2, + "config": { + "entities": ["APT28", "APT29", "Lazarus Group", "LockBit", "CISA", "Mandiant", "CrowdStrike", "RANSOMWARE"], + "maxEntities": 20 + } + }, + { + "name": "service-status", + "type": "service-status", + "displayName": "Source Status", + "position": 3, + "config": {} + } + ], + "ai": { + "enabled": true, + "fallbackChain": ["groq", "openrouter"], + "providers": { + "groq": { + "model": "llama-3.3-70b-versatile", + "apiKeyEnv": "GROQ_API_KEY" + }, + "openrouter": { + "model": "anthropic/claude-sonnet-4", + "apiKeyEnv": "OPENROUTER_API_KEY" + } + }, + "analysis": { + "summarization": true, + "entityExtraction": true, + "sentimentAnalysis": true, + "focalPointDetection": true, + "customPrompt": "Focus on cyber threats, vulnerability disclosures, ransomware campaigns, APT activity, and critical infrastructure attacks. Highlight emerging threat actors and zero-day exploits." + } + }, + "map": { + "style": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + "center": [0, 30], + "zoom": 3, + "projection": "globe", + "dayNightOverlay": true + }, + "theme": { + "palette": "cyberpunk" + } +} diff --git a/presets/cyber-minimal.json b/presets/cyber-minimal.json new file mode 100644 index 0000000..0afcc52 --- /dev/null +++ b/presets/cyber-minimal.json @@ -0,0 +1,98 @@ +{ + "_meta": { + "category": "cybersecurity", + "difficulty": "beginner", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": [], + "preview_description": "Track cyber threats from Krebs, The Hacker News, and BleepingComputer" + }, + "monitor": { + "name": "Cyber Monitor", + "slug": "cyber-monitor", + "description": "Minimal cybersecurity intelligence dashboard", + "domain": "cybersecurity", + "tags": ["cyber", "infosec", "threats"], + "branding": { + "primaryColor": "#00FF41" + } + }, + "sources": [ + { + "name": "krebs-on-security", + "type": "rss", + "url": "https://krebsonsecurity.com/feed/", + "category": "cyber-news", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["infosec", "breaches"] + }, + { + "name": "the-hacker-news", + "type": "rss", + "url": "https://feeds.feedburner.com/TheHackersNews", + "category": "cyber-news", + "tier": 1, + "interval": 300, + "language": "en", + "tags": ["cyber", "vulnerabilities"] + }, + { + "name": "bleeping-computer", + "type": "rss", + "url": "https://www.bleepingcomputer.com/feed/", + "category": "cyber-news", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["malware", "ransomware"] + } + ], + "layers": [], + "panels": [ + { + "name": "cyber-news", + "type": "news-feed", + "displayName": "Cyber News", + "position": 0, + "config": { + "categories": ["cyber-news"], + "maxItems": 30 + } + }, + { + "name": "ai-brief", + "type": "ai-brief", + "displayName": "Threat Brief", + "position": 1, + "config": { + "refreshInterval": 900 + } + } + ], + "ai": { + "enabled": true, + "fallbackChain": ["groq"], + "providers": { + "groq": { + "model": "llama-3.3-70b-versatile", + "apiKeyEnv": "GROQ_API_KEY" + } + }, + "analysis": { + "summarization": true, + "entityExtraction": true, + "sentimentAnalysis": false, + "focalPointDetection": false + } + }, + "map": { + "style": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + "center": [-95, 38], + "zoom": 4, + "projection": "mercator" + }, + "theme": { + "palette": "cyberpunk" + } +} diff --git a/presets/finance-full.json b/presets/finance-full.json index 6aa04b5..c04ecc1 100644 --- a/presets/finance-full.json +++ b/presets/finance-full.json @@ -1,10 +1,20 @@ { + "_meta": { + "category": "finance", + "difficulty": "intermediate", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": ["OPENROUTER_API_KEY"], + "preview_description": "Full financial intelligence with entity tracking and global exchange mapping" + }, "monitor": { "name": "Finance Intelligence", "slug": "finance-intelligence", "description": "Full-featured financial intelligence dashboard with market tracking and AI analysis", "domain": "finance", - "tags": ["finance", "markets", "crypto", "macro", "commodities"] + "tags": ["finance", "markets", "crypto", "macro", "commodities"], + "branding": { + "primaryColor": "#FFD700" + } }, "sources": [ { @@ -68,7 +78,17 @@ "tags": ["macro", "fed"] } ], - "layers": [], + "layers": [ + { + "name": "financial-centers", + "type": "points", + "displayName": "Financial Centers", + "color": "#FFD700", + "data": { "source": "static", "path": "data/geo/financial-centers.geojson" }, + "defaultVisible": true, + "category": "infrastructure" + } + ], "panels": [ { "name": "market-news", @@ -133,5 +153,8 @@ "center": [0, 30], "zoom": 3, "projection": "mercator" + }, + "theme": { + "palette": "sunset" } } diff --git a/presets/finance-minimal.json b/presets/finance-minimal.json index 1c1438c..93d4da8 100644 --- a/presets/finance-minimal.json +++ b/presets/finance-minimal.json @@ -1,10 +1,20 @@ { + "_meta": { + "category": "finance", + "difficulty": "beginner", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": [], + "preview_description": "Track financial markets from Reuters, CNBC, and CoinDesk" + }, "monitor": { "name": "Finance Monitor", "slug": "finance-monitor", "description": "Minimal financial market intelligence dashboard", "domain": "finance", - "tags": ["finance", "markets", "crypto"] + "tags": ["finance", "markets", "crypto"], + "branding": { + "primaryColor": "#FFD700" + } }, "sources": [ { @@ -80,5 +90,8 @@ "center": [-74, 40.7], "zoom": 5, "projection": "mercator" + }, + "theme": { + "palette": "sunset" } } diff --git a/presets/geopolitics-full.json b/presets/geopolitics-full.json index 9699ce1..257e48e 100644 --- a/presets/geopolitics-full.json +++ b/presets/geopolitics-full.json @@ -1,10 +1,20 @@ { + "_meta": { + "category": "geopolitics", + "difficulty": "intermediate", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": ["OPENROUTER_API_KEY"], + "preview_description": "Full geopolitical intelligence with conflict zone mapping and instability index" + }, "monitor": { "name": "Geopolitical Intelligence", "slug": "geopolitical-intelligence", "description": "Full-featured geopolitical intelligence dashboard with conflict tracking and AI analysis", "domain": "geopolitics", - "tags": ["geopolitics", "conflict", "diplomacy", "security", "osint"] + "tags": ["geopolitics", "conflict", "diplomacy", "security", "osint"], + "branding": { + "primaryColor": "#CC0000" + } }, "sources": [ { @@ -88,7 +98,17 @@ "tags": ["events", "conflict"] } ], - "layers": [], + "layers": [ + { + "name": "conflict-zones", + "type": "heatmap", + "displayName": "Conflict Zones", + "color": "#FF4444", + "data": { "source": "static", "path": "data/geo/conflict-zones.geojson" }, + "defaultVisible": true, + "category": "security" + } + ], "panels": [ { "name": "world-news", @@ -164,5 +184,8 @@ "atmosphericGlow": true, "idleRotation": true, "idleRotationSpeed": 0.3 + }, + "theme": { + "palette": "midnight" } } diff --git a/presets/geopolitics-minimal.json b/presets/geopolitics-minimal.json index 2fcd6bc..cb9fcc2 100644 --- a/presets/geopolitics-minimal.json +++ b/presets/geopolitics-minimal.json @@ -1,10 +1,20 @@ { + "_meta": { + "category": "geopolitics", + "difficulty": "beginner", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": [], + "preview_description": "Track global events from BBC, Reuters, and Al Jazeera" + }, "monitor": { "name": "Geopolitics Monitor", "slug": "geopolitics-monitor", "description": "Minimal geopolitical intelligence dashboard", "domain": "geopolitics", - "tags": ["geopolitics", "conflict", "diplomacy"] + "tags": ["geopolitics", "conflict", "diplomacy"], + "branding": { + "primaryColor": "#CC0000" + } }, "sources": [ { @@ -80,5 +90,8 @@ "center": [30, 30], "zoom": 3, "projection": "globe" + }, + "theme": { + "palette": "midnight" } } diff --git a/presets/health-full.json b/presets/health-full.json new file mode 100644 index 0000000..a49f1a8 --- /dev/null +++ b/presets/health-full.json @@ -0,0 +1,174 @@ +{ + "_meta": { + "category": "health", + "difficulty": "intermediate", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": ["OPENROUTER_API_KEY"], + "preview_description": "Comprehensive global health monitoring with disease surveillance, research feeds, and AI analysis" + }, + "monitor": { + "name": "Health Intelligence", + "slug": "health-intelligence", + "description": "Full-featured global health intelligence dashboard with disease tracking and AI analysis", + "domain": "health", + "tags": ["health", "medicine", "public-health", "surveillance", "research"], + "branding": { + "primaryColor": "#0099DD" + } + }, + "sources": [ + { + "name": "who-news", + "type": "rss", + "url": "https://www.who.int/feeds/entity/news/en/rss.xml", + "category": "health-news", + "tier": 1, + "interval": 1800, + "language": "en", + "tags": ["who", "global-health"] + }, + { + "name": "stat-news", + "type": "rss", + "url": "https://www.statnews.com/feed/", + "category": "health-news", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["medicine", "pharma"] + }, + { + "name": "nih-news", + "type": "rss", + "url": "https://www.nih.gov/news-events/news-releases/feed", + "category": "health-news", + "tier": 1, + "interval": 900, + "language": "en", + "tags": ["research", "nih"] + }, + { + "name": "lancet-latest", + "type": "rss", + "url": "https://www.thelancet.com/rssfeed/lancet_current.xml", + "category": "research", + "tier": 2, + "interval": 3600, + "language": "en", + "tags": ["lancet", "research"] + }, + { + "name": "cdc-newsroom", + "type": "rss", + "url": "https://tools.cdc.gov/api/v2/resources/media/132608.rss", + "category": "surveillance", + "tier": 1, + "interval": 3600, + "language": "en", + "tags": ["cdc", "surveillance"] + }, + { + "name": "biorxiv-microbiology", + "type": "rss", + "url": "https://connect.biorxiv.org/biorxiv_xml.php?subject=microbiology", + "category": "research", + "tier": 3, + "interval": 3600, + "language": "en", + "tags": ["preprint", "microbiology"] + }, + { + "name": "global-health-now", + "type": "rss", + "url": "https://www.globalhealthnow.org/rss.xml", + "category": "health-news", + "tier": 2, + "interval": 1800, + "language": "en", + "tags": ["global-health", "epidemiology"] + } + ], + "layers": [ + { + "name": "who-regions", + "type": "points", + "displayName": "WHO Regional Offices", + "color": "#00AA55", + "data": { + "source": "static", + "path": "data/geo/who-regions.geojson" + }, + "defaultVisible": false, + "category": "infrastructure" + } + ], + "panels": [ + { + "name": "health-news", + "type": "news-feed", + "displayName": "Health News", + "position": 0, + "config": { + "categories": ["health-news", "surveillance"], + "maxItems": 50 + } + }, + { + "name": "ai-brief", + "type": "ai-brief", + "displayName": "Health Brief", + "position": 1, + "config": { + "refreshInterval": 600 + } + }, + { + "name": "entity-tracker", + "type": "entity-tracker", + "displayName": "Key Entities", + "position": 2, + "config": { + "entities": ["WHO", "CDC", "FDA", "EMA", "Pfizer", "Moderna", "H5N1", "MPOX"], + "maxEntities": 20 + } + }, + { + "name": "service-status", + "type": "service-status", + "displayName": "Source Status", + "position": 3, + "config": {} + } + ], + "ai": { + "enabled": true, + "fallbackChain": ["groq", "openrouter"], + "providers": { + "groq": { + "model": "llama-3.3-70b-versatile", + "apiKeyEnv": "GROQ_API_KEY" + }, + "openrouter": { + "model": "anthropic/claude-sonnet-4", + "apiKeyEnv": "OPENROUTER_API_KEY" + } + }, + "analysis": { + "summarization": true, + "entityExtraction": true, + "sentimentAnalysis": true, + "focalPointDetection": true, + "customPrompt": "Focus on disease outbreaks, drug and vaccine approvals, pandemic preparedness, antimicrobial resistance, and public health emergencies. Highlight emerging pathogens and WHO declarations." + } + }, + "map": { + "style": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + "center": [0, 20], + "zoom": 3, + "projection": "globe", + "dayNightOverlay": true + }, + "theme": { + "palette": "ocean" + } +} diff --git a/presets/health-minimal.json b/presets/health-minimal.json new file mode 100644 index 0000000..4d003e4 --- /dev/null +++ b/presets/health-minimal.json @@ -0,0 +1,97 @@ +{ + "_meta": { + "category": "health", + "difficulty": "beginner", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": [], + "preview_description": "Monitor global health news from WHO, STAT News, and NIH" + }, + "monitor": { + "name": "Health Monitor", + "slug": "health-monitor", + "description": "Minimal global health intelligence dashboard", + "domain": "health", + "tags": ["health", "medicine", "public-health"], + "branding": { + "primaryColor": "#0099DD" + } + }, + "sources": [ + { + "name": "who-news", + "type": "rss", + "url": "https://www.who.int/feeds/entity/news/en/rss.xml", + "category": "health-news", + "tier": 1, + "interval": 1800, + "language": "en", + "tags": ["who", "global-health"] + }, + { + "name": "stat-news", + "type": "rss", + "url": "https://www.statnews.com/feed/", + "category": "health-news", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["medicine", "pharma"] + }, + { + "name": "nih-news", + "type": "rss", + "url": "https://www.nih.gov/news-events/news-releases/feed", + "category": "health-news", + "tier": 1, + "interval": 900, + "language": "en", + "tags": ["research", "nih"] + } + ], + "layers": [], + "panels": [ + { + "name": "health-news", + "type": "news-feed", + "displayName": "Health News", + "position": 0, + "config": { + "categories": ["health-news"], + "maxItems": 30 + } + }, + { + "name": "ai-brief", + "type": "ai-brief", + "displayName": "Health Brief", + "position": 1, + "config": { + "refreshInterval": 900 + } + } + ], + "ai": { + "enabled": true, + "fallbackChain": ["groq"], + "providers": { + "groq": { + "model": "llama-3.3-70b-versatile", + "apiKeyEnv": "GROQ_API_KEY" + } + }, + "analysis": { + "summarization": true, + "entityExtraction": true, + "sentimentAnalysis": false + } + }, + "map": { + "style": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + "center": [0, 20], + "zoom": 3, + "projection": "globe" + }, + "theme": { + "palette": "ocean" + } +} diff --git a/presets/korea-full.json b/presets/korea-full.json new file mode 100644 index 0000000..3b1145d --- /dev/null +++ b/presets/korea-full.json @@ -0,0 +1,173 @@ +{ + "_meta": { + "category": "korea", + "difficulty": "intermediate", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": ["OPENROUTER_API_KEY"], + "preview_description": "Comprehensive Korean peninsula monitoring with entity tracking, instability index, and AI analysis" + }, + "monitor": { + "name": "Korea Intelligence", + "slug": "korea-intelligence", + "description": "Full-featured Korean peninsula intelligence dashboard with entity tracking and AI analysis", + "domain": "korea", + "tags": ["korea", "asia", "geopolitics", "dprk", "security"], + "branding": { + "primaryColor": "#003478" + } + }, + "sources": [ + { + "name": "yonhap-english", + "type": "rss", + "url": "https://en.yna.co.kr/RSS/news.xml", + "category": "korea-news", + "tier": 1, + "interval": 300, + "language": "en", + "tags": ["korea", "breaking"] + }, + { + "name": "korea-herald", + "type": "rss", + "url": "https://www.koreaherald.com/common/rss_xml.php?ct=102", + "category": "korea-news", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["korea", "politics"] + }, + { + "name": "bbc-asia", + "type": "rss", + "url": "https://feeds.bbci.co.uk/news/world/asia/rss.xml", + "category": "regional", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["asia", "world"] + }, + { + "name": "nk-news", + "type": "rss", + "url": "https://www.nknews.org/feed/", + "category": "north-korea", + "tier": 1, + "interval": 900, + "language": "en", + "tags": ["dprk", "north-korea"] + }, + { + "name": "hankyoreh-english", + "type": "rss", + "url": "https://english.hani.co.kr/rss/", + "category": "korea-news", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["korea", "progressive"] + }, + { + "name": "reuters-asia", + "type": "rss", + "url": "https://www.reutersagency.com/feed/?taxonomy=best-regions&post_type=best", + "category": "regional", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["asia", "world"] + }, + { + "name": "korea-times", + "type": "rss", + "url": "https://www.koreatimes.co.kr/www/rss/nation.xml", + "category": "korea-news", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["korea", "nation"] + } + ], + "layers": [ + { + "name": "korean-peninsula", + "type": "points", + "displayName": "Key Locations", + "color": "#00BFFF", + "data": { + "source": "static", + "path": "data/geo/korean-peninsula.geojson" + }, + "defaultVisible": true, + "category": "locations" + } + ], + "panels": [ + { + "name": "korea-news", + "type": "news-feed", + "displayName": "Korea News", + "position": 0, + "config": { + "categories": ["korea-news", "regional", "north-korea"], + "maxItems": 50 + } + }, + { + "name": "ai-brief", + "type": "ai-brief", + "displayName": "Korea Brief", + "position": 1, + "config": { + "refreshInterval": 600 + } + }, + { + "name": "entity-tracker", + "type": "entity-tracker", + "displayName": "Key Entities", + "position": 2, + "config": { + "entities": ["ROK", "DPRK", "Kim Jong Un", "Yoon Suk-yeol", "USFK", "Samsung", "Hyundai", "KOSPI"], + "maxEntities": 20 + } + }, + { + "name": "instability-index", + "type": "instability-index", + "displayName": "Instability Index", + "position": 3, + "config": {} + } + ], + "ai": { + "enabled": true, + "fallbackChain": ["groq", "openrouter"], + "providers": { + "groq": { + "model": "llama-3.3-70b-versatile", + "apiKeyEnv": "GROQ_API_KEY" + }, + "openrouter": { + "model": "anthropic/claude-sonnet-4", + "apiKeyEnv": "OPENROUTER_API_KEY" + } + }, + "analysis": { + "summarization": true, + "entityExtraction": true, + "sentimentAnalysis": true, + "focalPointDetection": true, + "customPrompt": "Focus on inter-Korean relations, DPRK nuclear and missile developments, ROK-US alliance dynamics, Korean economic indicators, and regional security. Highlight escalation signals and diplomatic shifts." + } + }, + "map": { + "style": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + "center": [127.5, 37], + "zoom": 6, + "projection": "mercator" + }, + "theme": { + "palette": "midnight" + } +} diff --git a/presets/korea-minimal.json b/presets/korea-minimal.json new file mode 100644 index 0000000..1f5246f --- /dev/null +++ b/presets/korea-minimal.json @@ -0,0 +1,97 @@ +{ + "_meta": { + "category": "korea", + "difficulty": "beginner", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": [], + "preview_description": "Monitor Korean peninsula news from Yonhap, Korea Herald, and BBC Asia" + }, + "monitor": { + "name": "Korea Monitor", + "slug": "korea-monitor", + "description": "Minimal Korean peninsula intelligence dashboard", + "domain": "korea", + "tags": ["korea", "asia", "geopolitics"], + "branding": { + "primaryColor": "#003478" + } + }, + "sources": [ + { + "name": "yonhap-english", + "type": "rss", + "url": "https://en.yna.co.kr/RSS/news.xml", + "category": "korea-news", + "tier": 1, + "interval": 300, + "language": "en", + "tags": ["korea", "breaking"] + }, + { + "name": "korea-herald", + "type": "rss", + "url": "https://www.koreaherald.com/common/rss_xml.php?ct=102", + "category": "korea-news", + "tier": 1, + "interval": 600, + "language": "en", + "tags": ["korea", "politics"] + }, + { + "name": "bbc-asia", + "type": "rss", + "url": "https://feeds.bbci.co.uk/news/world/asia/rss.xml", + "category": "regional", + "tier": 2, + "interval": 600, + "language": "en", + "tags": ["asia", "world"] + } + ], + "layers": [], + "panels": [ + { + "name": "korea-news", + "type": "news-feed", + "displayName": "Korea News", + "position": 0, + "config": { + "categories": ["korea-news", "regional"], + "maxItems": 30 + } + }, + { + "name": "ai-brief", + "type": "ai-brief", + "displayName": "Korea Brief", + "position": 1, + "config": { + "refreshInterval": 900 + } + } + ], + "ai": { + "enabled": true, + "fallbackChain": ["groq"], + "providers": { + "groq": { + "model": "llama-3.3-70b-versatile", + "apiKeyEnv": "GROQ_API_KEY" + } + }, + "analysis": { + "summarization": true, + "entityExtraction": true, + "sentimentAnalysis": false + } + }, + "map": { + "style": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + "center": [127.5, 37], + "zoom": 6, + "projection": "mercator" + }, + "theme": { + "palette": "midnight" + } +} diff --git a/presets/tech-full.json b/presets/tech-full.json index 6fa413a..c7ee858 100644 --- a/presets/tech-full.json +++ b/presets/tech-full.json @@ -1,4 +1,11 @@ { + "_meta": { + "category": "technology", + "difficulty": "intermediate", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": ["OPENROUTER_API_KEY"], + "preview_description": "Full tech intelligence with AI research tracking and tech hub mapping" + }, "monitor": { "name": "Tech Intelligence", "slug": "tech-intelligence", @@ -88,7 +95,17 @@ "tags": ["open-source", "github"] } ], - "layers": [], + "layers": [ + { + "name": "tech-hubs", + "type": "points", + "displayName": "Tech Hubs", + "color": "#00D4FF", + "data": { "source": "static", "path": "data/geo/tech-hubs.geojson" }, + "defaultVisible": true, + "category": "infrastructure" + } + ], "panels": [ { "name": "tech-news", @@ -152,5 +169,8 @@ "center": [-95, 38], "zoom": 4, "projection": "mercator" + }, + "theme": { + "palette": "default" } } diff --git a/presets/tech-minimal.json b/presets/tech-minimal.json index f768ff5..1e37ea2 100644 --- a/presets/tech-minimal.json +++ b/presets/tech-minimal.json @@ -1,4 +1,11 @@ { + "_meta": { + "category": "technology", + "difficulty": "beginner", + "requires_api_keys": ["GROQ_API_KEY"], + "optional_api_keys": [], + "preview_description": "Track tech news from Hacker News, TechCrunch, and Ars Technica" + }, "monitor": { "name": "Tech Monitor", "slug": "tech-monitor", @@ -80,5 +87,8 @@ "center": [-95, 38], "zoom": 4, "projection": "mercator" + }, + "theme": { + "palette": "default" } } diff --git a/src/App.ts b/src/App.ts index 2deacef..5ff3e2a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -23,6 +23,9 @@ export class App { // Load resolved config (generated by forge build) const { resolvedConfig: config } = await import('./generated/config-resolved.js'); + // Apply theme before layout rendering to prevent flash of unstyled content + await this.applyTheme(); + // Build layout this.root.innerHTML = `
@@ -166,6 +169,50 @@ export class App { text.textContent = parts.join(', '); } + private async applyTheme(): Promise { + try { + const { resolvedTheme } = await import('./generated/theme-resolved.js'); + const theme = resolvedTheme as { mode: 'dark' | 'light' | 'auto'; dark: typeof resolvedTheme.dark; light: typeof resolvedTheme.light; panelPosition: string; panelWidth: number; compactMode: boolean }; + const root = document.documentElement; + + // Set data attributes for CSS selectors + root.setAttribute('data-theme', theme.mode); + root.setAttribute('data-compact', String(theme.compactMode)); + root.setAttribute('data-panel-position', theme.panelPosition); + + // Determine effective mode for initial paint + const applyColors = (colors: Record) => { + root.style.setProperty('--fg', colors.fg); + root.style.setProperty('--bg', colors.bg); + root.style.setProperty('--bg-secondary', colors.bgSecondary); + root.style.setProperty('--bg-panel', colors.bgPanel); + root.style.setProperty('--border', colors.border); + root.style.setProperty('--accent', colors.accent); + root.style.setProperty('--accent-hover', colors.accentHover); + root.style.setProperty('--success', colors.success); + root.style.setProperty('--danger', colors.danger); + root.style.setProperty('--warning', colors.warning); + root.style.setProperty('--text-muted', colors.textMuted); + }; + + const effectiveMode = theme.mode === 'auto' + ? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') + : theme.mode; + + applyColors(effectiveMode === 'light' ? theme.light : theme.dark); + root.style.setProperty('--panel-width', `${theme.panelWidth}px`); + + // Listen for OS theme changes when in auto mode + if (theme.mode === 'auto') { + window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => { + applyColors(e.matches ? theme.light : theme.dark); + }); + } + } catch { + // theme-resolved.js may not exist in older builds — use CSS defaults + } + } + destroy(): void { this.sourceManager?.stopAll(); this.panelManager?.destroy(); diff --git a/src/styles/base.css b/src/styles/base.css index 07d4b6b..299c483 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -335,8 +335,82 @@ html, body { ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +/* Light mode */ +[data-theme="light"] { + --fg: #1a1a2e; + --bg: #f5f5f7; + --bg-secondary: #eaeaef; + --bg-panel: #ffffff; + --border: #d0d0d8; + --accent: #0052CC; + --accent-hover: #003d99; + --success: #00a884; + --danger: #e04545; + --warning: #d49e00; + --text-muted: #666666; +} + +[data-theme="light"] .forge-panel-header { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="light"] .forge-health-indicator { + background: rgba(0, 0, 0, 0.05); +} + +/* Auto mode: follow OS preference */ +@media (prefers-color-scheme: light) { + [data-theme="auto"] { + --fg: #1a1a2e; + --bg: #f5f5f7; + --bg-secondary: #eaeaef; + --bg-panel: #ffffff; + --border: #d0d0d8; + --accent: #0052CC; + --accent-hover: #003d99; + --success: #00a884; + --danger: #e04545; + --warning: #d49e00; + --text-muted: #666666; + } + [data-theme="auto"] .forge-panel-header { + background: rgba(0, 0, 0, 0.02); + } +} + +/* Compact mode */ +[data-compact="true"] .forge-panel-body { + padding: 0.25rem 0.5rem; + max-height: 250px; +} + +[data-compact="true"] .forge-header { + height: 36px; + padding: 0.25rem 0.75rem; +} + +[data-compact="true"] .forge-title { + font-size: 0.85rem; +} + +/* Left panel position */ +[data-panel-position="left"] .forge-main { + flex-direction: row-reverse; +} + +[data-panel-position="left"] .forge-sidebar { + border-left: none; + border-right: 1px solid var(--border); +} + +[data-panel-position="left"] .forge-layer-panel { + right: auto; + left: var(--panel-width); +} + /* Responsive */ @media (max-width: 768px) { .forge-sidebar { width: 100%; position: absolute; bottom: 0; height: 40vh; z-index: 150; } .forge-layer-panel { right: 0; } + [data-panel-position="left"] .forge-layer-panel { left: 0; right: auto; } } diff --git a/test/presets-pipeline.test.ts b/test/presets-pipeline.test.ts index 13951c6..090a35c 100644 --- a/test/presets-pipeline.test.ts +++ b/test/presets-pipeline.test.ts @@ -58,13 +58,14 @@ describe.each(presetFiles)('preset pipeline: %s', (filename) => { const config = createDefaultConfig(raw); const validated = MonitorForgeConfigSchema.parse(config); - it('generates all 4 manifests without error', () => { + it('generates all 5 manifests without error', () => { const manifests = generateManifests(validated); expect(Object.keys(manifests)).toEqual([ 'source-manifest.ts', 'layer-manifest.ts', 'panel-manifest.ts', 'config-resolved.ts', + 'theme-resolved.ts', ]); // Every manifest should be a non-empty string for (const [name, content] of Object.entries(manifests)) { diff --git a/test/presets.test.ts b/test/presets.test.ts index 3e283c7..9a24634 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -10,11 +10,15 @@ const presetDir = resolve(__dirname, '../presets'); const presetFiles = readdirSync(presetDir).filter(f => f.endsWith('.json')); describe('presets', () => { - it('has all 7 expected preset files', () => { + it('has all 15 expected preset files', () => { const expectedNames = [ 'blank.json', 'tech-minimal.json', 'tech-full.json', 'finance-minimal.json', 'finance-full.json', 'geopolitics-minimal.json', 'geopolitics-full.json', + 'cyber-minimal.json', 'cyber-full.json', + 'climate-minimal.json', 'climate-full.json', + 'korea-minimal.json', 'korea-full.json', + 'health-minimal.json', 'health-full.json', ]; for (const name of expectedNames) { expect(presetFiles).toContain(name); @@ -57,5 +61,14 @@ describe('presets', () => { const names = (raw.panels ?? []).map((p: { name: string }) => p.name); expect(new Set(names).size).toBe(names.length); }); + + it('has valid _meta block', () => { + expect(raw._meta).toBeDefined(); + expect(raw._meta.category).toBeDefined(); + expect(raw._meta.difficulty).toMatch(/^(beginner|intermediate|advanced)$/); + expect(Array.isArray(raw._meta.requires_api_keys)).toBe(true); + expect(Array.isArray(raw._meta.optional_api_keys)).toBe(true); + expect(typeof raw._meta.preview_description).toBe('string'); + }); }); });