diff --git a/STYLE_OVERRIDE.md b/STYLE_OVERRIDE.md new file mode 100644 index 00000000..b96af621 --- /dev/null +++ b/STYLE_OVERRIDE.md @@ -0,0 +1,57 @@ +# Style Override via postMessage + +Simple API for overriding styles when embedding the frontend in an iframe. + +## Usage + +```javascript +// Switch theme +iframe.contentWindow.postMessage({ + type: 'VIBE_STYLE', + theme: 'purple' // 'system', 'light', 'dark', 'purple', 'green', 'blue', 'orange', 'red' +}, 'https://your-app-domain.com'); + +// Override CSS variables (--vibe-* prefix only) +iframe.contentWindow.postMessage({ + type: 'VIBE_STYLE', + css: { + '--vibe-primary': '220 14% 96%', + '--vibe-background': '0 0% 100%' + } +}, 'https://your-app-domain.com'); + +// Both together +iframe.contentWindow.postMessage({ + type: 'VIBE_STYLE', + theme: 'dark', + css: { + '--vibe-accent': '210 100% 50%' + } +}, 'https://your-app-domain.com'); +``` + +## Security + +- Origin validation via `VITE_PARENT_ORIGIN` environment variable +- Only `--vibe-*` prefixed CSS variables can be overridden +- Browser validates CSS values automatically + +## Example + +```html + + +``` diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index d9788e62..d257a886 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -67,9 +67,11 @@ async fn main() -> Result<(), VibeKanbanError> { let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?; let actual_port = listener.local_addr()?.port(); // get → 53427 (example) - // Write port file for discovery (dev builds always, release builds only if enabled) - if cfg!(debug_assertions) || std::env::var_os("ENABLE_PORT_FILE").is_some() { - write_port_file(actual_port).await?; + // Write port file for discovery if prod, warn on fail + if !cfg!(debug_assertions) { + if let Err(e) = write_port_file(actual_port).await { + tracing::warn!("Failed to write port file: {}", e); + } } tracing::info!("Server running on http://{host}:{actual_port}"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 32f5c281..1b6af215 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { configApi } from '@/lib/api'; import * as Sentry from '@sentry/react'; import { Loader } from '@/components/ui/loader'; import { GitHubLoginDialog } from '@/components/GitHubLoginDialog'; +import { AppWithStyleOverride } from '@/utils/style-override'; const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); @@ -136,55 +137,57 @@ function AppContent() { return ( -
- - - - - {showNavbar && } -
- - } /> - } /> - } /> - } - /> - } - /> - } - /> - } - /> - } - /> + +
+ + + + + {showNavbar && } +
+ + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> - } /> - } /> - + } /> + } /> + +
-
+ ); } diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index df071a96..880c707d 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -2,219 +2,206 @@ @tailwind components; @tailwind utilities; +/* 1) THEME TOKENS (underscored): defaults + classes control these */ +@layer base { + /* Light defaults */ + :root { + --_background: 0 0% 100%; + --_foreground: 222.2 84% 4.9%; + --_card: 0 0% 100%; + --_card-foreground: 222.2 84% 4.9%; + --_popover: 0 0% 100%; + --_popover-foreground: 222.2 84% 4.9%; + --_primary: 222.2 47.4% 11.2%; + --_primary-foreground: 210 40% 98%; + --_secondary: 210 40% 96%; + --_secondary-foreground: 222.2 84% 4.9%; + --_muted: 210 40% 96%; + --_muted-foreground: 215.4 16.3% 46.9%; + --_accent: 210 40% 96%; + --_accent-foreground: 222.2 84% 4.9%; + --_destructive: 0 84.2% 60.2%; + --_destructive-foreground: 210 40% 98%; + --_border: 214.3 31.8% 91.4%; + --_input: 214.3 31.8% 91.4%; + --_ring: 222.2 84% 4.9%; + --_radius: 0.5rem; + + /* Status (light) */ + --_success: 142.1 76.2% 36.3%; + --_success-foreground: 138.5 76.5% 96.7%; + --_warning: 32.2 95% 44.1%; + --_warning-foreground: 26 83.3% 14.1%; + --_info: 217.2 91.2% 59.8%; + --_info-foreground: 222.2 84% 4.9%; + --_neutral: 210 40% 96%; + --_neutral-foreground: 222.2 84% 4.9%; + + /* Console (light) */ + --_console-background: 0 0% 100%; + --_console-foreground: 222.2 84% 4.9%; + --_console-success: 138 69% 45%; + --_console-error: 5 100% 69%; + } + + /* Dark defaults (used if no theme class but user prefers dark) */ + @media (prefers-color-scheme: dark) { + :root { + --_background: 222.2 84% 4.9%; + --_foreground: 210 40% 98%; + --_card: 222.2 84% 4.9%; + --_card-foreground: 210 40% 98%; + --_popover: 222.2 84% 4.9%; + --_popover-foreground: 210 40% 98%; + --_primary: 210 40% 98%; + --_primary-foreground: 222.2 47.4% 11.2%; + --_secondary: 217.2 32.6% 17.5%; + --_secondary-foreground: 210 40% 98%; + --_muted: 217.2 32.6% 17.5%; + --_muted-foreground: 215 20.2% 65.1%; + --_accent: 217.2 32.6% 17.5%; + --_accent-foreground: 210 40% 98%; + --_destructive: 0 62.8% 30.6%; + --_destructive-foreground: 210 40% 98%; + --_border: 217.2 32.6% 17.5%; + --_input: 217.2 32.6% 17.5%; + --_ring: 212.7 26.8% 83.9%; + + /* Status (dark) */ + --_success: 138.5 76.5% 47.7%; + --_success-foreground: 138.5 76.5% 96.7%; + --_warning: 32.2 95% 44.1%; + --_warning-foreground: 26 83.3% 14.1%; + --_info: 217.2 91.2% 59.8%; + --_info-foreground: 222.2 84% 4.9%; + --_neutral: 217.2 32.6% 17.5%; + --_neutral-foreground: 210 40% 98%; + + /* Console (dark) */ + --_console-background: 0 0% 0%; + --_console-foreground: 210 40% 98%; + --_console-success: 138.5 76.5% 47.7%; + --_console-error: 0 84.2% 60.2%; + } + } + + /* Your theme classes only set the UNDERSCORED tokens */ + .purple { + --_background: 266 100% 6%; + --_foreground: 266 20% 95%; + --_card: 266 100% 6%; + --_card-foreground: 266 20% 95%; + --_popover: 266 100% 6%; + --_popover-foreground: 266 20% 95%; + --_primary: 266 80% 75%; + --_primary-foreground: 266 100% 6%; + --_secondary: 266 20% 15%; + --_secondary-foreground: 266 20% 95%; + --_muted: 266 20% 15%; + --_muted-foreground: 266 15% 65%; + --_accent: 266 20% 15%; + --_accent-foreground: 266 20% 95%; + --_destructive: 0 62.8% 30.6%; + --_destructive-foreground: 266 20% 95%; + --_border: 266 20% 15%; + --_input: 266 20% 15%; + --_ring: 266 80% 75%; + + /* …status + console underscored, if you want them themed too */ + } + + /* Repeat the same idea for .green, .blue, .orange, .red, .dark etc., + but ONLY set --_* tokens in these classes. */ +} + +/* 2) PUBLIC TOKENS: prefer VS Code, else fall back to theme tokens */ @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96%; - --secondary-foreground: 222.2 84% 4.9%; - --muted: 210 40% 96%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96%; - --accent-foreground: 222.2 84% 4.9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; + --background: var(--vscode-editor-background, var(--_background)); + --foreground: var(--vscode-editor-foreground, var(--_foreground)); - /* Status colors */ - --success: 142.1 76.2% 36.3%; - --success-foreground: 138.5 76.5% 96.7%; - --warning: 32.2 95% 44.1%; - --warning-foreground: 26 83.3% 14.1%; - --info: 217.2 91.2% 59.8%; - --info-foreground: 222.2 84% 4.9%; - --neutral: 210 40% 96%; - --neutral-foreground: 222.2 84% 4.9%; + --card: var(--vscode-editorWidget-background, var(--_card)); + --card-foreground: var( + --vscode-editorWidget-foreground, + var(--_card-foreground) + ); + --popover: var(--vscode-editorWidget-background, var(--_popover)); + --popover-foreground: var( + --vscode-editorWidget-foreground, + var(--_popover-foreground) + ); - /* Status indicator colors */ - --status-init: 210 40% 96%; - --status-init-foreground: 222.2 84% 4.9%; - --status-running: 217.2 91.2% 59.8%; - --status-running-foreground: 222.2 84% 4.9%; - --status-complete: 142.1 76.2% 36.3%; - --status-complete-foreground: 138.5 76.5% 96.7%; - --status-failed: 0 84.2% 60.2%; - --status-failed-foreground: 210 40% 98%; - --status-paused: 32.2 95% 44.1%; - --status-paused-foreground: 26 83.3% 14.1%; + --primary: var(--vscode-button-background, var(--_primary)); + --primary-foreground: var( + --vscode-button-foreground, + var(--_primary-foreground) + ); + --secondary: var(--vscode-input-background, var(--_secondary)); + --secondary-foreground: var( + --vscode-input-foreground, + var(--_secondary-foreground) + ); - /* Console/terminal colors */ - --console-background: 222.2 84% 4.9%; - --console-foreground: 210 40% 98%; - --console-success: 138 69% 45%; - --console-error: 5 100% 69%; - } + --muted: var(--vscode-input-background, var(--_muted)); + --muted-foreground: var( + --vscode-descriptionForeground, + var(--_muted-foreground) + ); + --accent: var(--vscode-focusBorder, var(--_accent)); + --accent-foreground: var( + --vscode-editor-foreground, + var(--_accent-foreground) + ); - .purple { - --background: 266 100% 6%; - --foreground: 266 20% 95%; - --card: 266 100% 6%; - --card-foreground: 266 20% 95%; - --popover: 266 100% 6%; - --popover-foreground: 266 20% 95%; - --primary: 266 80% 75%; - --primary-foreground: 266 100% 6%; - --secondary: 266 20% 15%; - --secondary-foreground: 266 20% 95%; - --muted: 266 20% 15%; - --muted-foreground: 266 15% 65%; - --accent: 266 20% 15%; - --accent-foreground: 266 20% 95%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 266 20% 95%; - --border: 266 20% 15%; - --input: 266 20% 15%; - --ring: 266 80% 75%; - } + --destructive: var(--vscode-errorForeground, var(--_destructive)); + --destructive-foreground: var( + --vscode-button-foreground, + var(--_destructive-foreground) + ); - .green { - --background: 120 100% 6%; - --foreground: 120 20% 95%; - --card: 120 100% 6%; - --card-foreground: 120 20% 95%; - --popover: 120 100% 6%; - --popover-foreground: 120 20% 95%; - --primary: 120 80% 75%; - --primary-foreground: 120 100% 6%; - --secondary: 120 20% 15%; - --secondary-foreground: 120 20% 95%; - --muted: 120 20% 15%; - --muted-foreground: 120 15% 65%; - --accent: 120 20% 15%; - --accent-foreground: 120 20% 95%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 120 20% 95%; - --border: 120 20% 15%; - --input: 120 20% 15%; - --ring: 120 80% 75%; - } + --border: var(--vscode-input-background, var(--_border)); + --input: var(--vscode-input-background, var(--_input)); + --ring: var(--vscode-focusBorder, var(--_ring)); - .blue { - --background: 210 100% 6%; - --foreground: 210 20% 95%; - --card: 210 100% 6%; - --card-foreground: 210 20% 95%; - --popover: 210 100% 6%; - --popover-foreground: 210 20% 95%; - --primary: 210 80% 75%; - --primary-foreground: 210 100% 6%; - --secondary: 210 20% 15%; - --secondary-foreground: 210 20% 95%; - --muted: 210 20% 15%; - --muted-foreground: 210 15% 65%; - --accent: 210 20% 15%; - --accent-foreground: 210 20% 95%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 20% 95%; - --border: 210 20% 15%; - --input: 210 20% 15%; - --ring: 210 80% 75%; - } + --radius: var(--_radius); - .orange { - --background: 30 100% 6%; - --foreground: 30 20% 95%; - --card: 30 100% 6%; - --card-foreground: 30 20% 95%; - --popover: 30 100% 6%; - --popover-foreground: 30 20% 95%; - --primary: 30 80% 75%; - --primary-foreground: 30 100% 6%; - --secondary: 30 20% 15%; - --secondary-foreground: 30 20% 95%; - --muted: 30 20% 15%; - --muted-foreground: 30 15% 65%; - --accent: 30 20% 15%; - --accent-foreground: 30 20% 95%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 30 20% 95%; - --border: 30 20% 15%; - --input: 30 20% 15%; - --ring: 30 80% 75%; - } + /* Status */ + --success: var(--vscode-testing-iconPassed, var(--_success)); + --success-foreground: var( + --vscode-editor-foreground, + var(--_success-foreground) + ); + --warning: var(--vscode-testing-iconQueued, var(--_warning)); + --warning-foreground: var( + --vscode-descriptionForeground, + var(--_warning-foreground) + ); + --info: var(--vscode-focusBorder, var(--_info)); + --info-foreground: var(--vscode-editor-foreground, var(--_info-foreground)); + --neutral: var(--vscode-input-background, var(--_neutral)); + --neutral-foreground: var( + --vscode-editor-foreground, + var(--_neutral-foreground) + ); - .red { - --background: 0 100% 6%; - --foreground: 0 20% 95%; - --card: 0 100% 6%; - --card-foreground: 0 20% 95%; - --popover: 0 100% 6%; - --popover-foreground: 0 20% 95%; - --primary: 0 80% 75%; - --primary-foreground: 0 100% 6%; - --secondary: 0 20% 15%; - --secondary-foreground: 0 20% 95%; - --muted: 0 20% 15%; - --muted-foreground: 0 15% 65%; - --accent: 0 20% 15%; - --accent-foreground: 0 20% 95%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 20% 95%; - --border: 0 20% 15%; - --input: 0 20% 15%; - --ring: 0 80% 75%; - } - - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - - /* Status colors */ - --success: 138.5 76.5% 47.7%; - --success-foreground: 138.5 76.5% 96.7%; - --warning: 32.2 95% 44.1%; - --warning-foreground: 26 83.3% 14.1%; - --info: 217.2 91.2% 59.8%; - --info-foreground: 222.2 84% 4.9%; - --neutral: 217.2 32.6% 17.5%; - --neutral-foreground: 210 40% 98%; - - /* Status indicator colors */ - --status-init: 217.2 32.6% 17.5%; - --status-init-foreground: 210 40% 98%; - --status-running: 217.2 91.2% 59.8%; - --status-running-foreground: 222.2 84% 4.9%; - --status-complete: 138.5 76.5% 47.7%; - --status-complete-foreground: 138.5 76.5% 96.7%; - --status-failed: 0 62.8% 30.6%; - --status-failed-foreground: 210 40% 98%; - --status-paused: 32.2 95% 44.1%; - --status-paused-foreground: 26 83.3% 14.1%; - - /* Console/terminal colors */ - --console-background: 0 0% 0%; - --console-foreground: 138.5 76.5% 47.7%; - --console-success: 138.5 76.5% 47.7%; - --console-error: 0 84.2% 60.2%; + /* Console/terminal */ + --console-background: var( + --vscode-editor-background, + var(--_console-background) + ); + --console-foreground: var( + --vscode-terminal-foreground, + var(--_console-foreground) + ); + --console-success: var( + --vscode-testing-iconPassed, + var(--_console-success) + ); + --console-error: var(--vscode-terminal-ansiRed, var(--_console-error)); } } +/* 3) Usage */ @layer base { * { @apply border-border; diff --git a/frontend/src/utils/style-override.tsx b/frontend/src/utils/style-override.tsx new file mode 100644 index 00000000..ac1816bb --- /dev/null +++ b/frontend/src/utils/style-override.tsx @@ -0,0 +1,82 @@ +import { useEffect } from 'react'; +import { useTheme } from '@/components/theme-provider'; +import { ThemeMode } from 'shared/types'; + +interface VibeStyleOverrideMessage { + type: 'VIBE_STYLE_OVERRIDE'; + payload: + | { + kind: 'cssVars'; + variables: Record; + } + | { + kind: 'theme'; + theme: ThemeMode; + }; +} + +interface VibeIframeReadyMessage { + type: 'VIBE_IFRAME_READY'; +} + +// Component that adds postMessage listener for style overrides +export function AppWithStyleOverride({ + children, +}: { + children: React.ReactNode; +}) { + const { setTheme } = useTheme(); + + useEffect(() => { + function handleStyleMessage(event: MessageEvent) { + if (event.data?.type !== 'VIBE_STYLE_OVERRIDE') return; + + // Origin validation (only if VITE_PARENT_ORIGIN is configured) + const allowedOrigin = import.meta.env.VITE_PARENT_ORIGIN; + if (allowedOrigin && event.origin !== allowedOrigin) { + console.warn( + '[StyleOverride] Message from unauthorized origin:', + event.origin + ); + return; + } + + const message = event.data as VibeStyleOverrideMessage; + + // CSS variable overrides (only --vibe-* prefixed variables) + if ( + message.payload.kind === 'cssVars' && + typeof message.payload.variables === 'object' + ) { + Object.entries(message.payload.variables).forEach(([name, value]) => { + if (typeof value === 'string') { + document.documentElement.style.setProperty(name, value); + } + }); + } else if (message.payload.kind === 'theme') { + setTheme(message.payload.theme); + } + } + + window.addEventListener('message', handleStyleMessage); + return () => window.removeEventListener('message', handleStyleMessage); + }, [setTheme]); + + // Send ready message to parent when component mounts + useEffect(() => { + const allowedOrigin = import.meta.env.VITE_PARENT_ORIGIN; + + // Only send if we're in an iframe and have a parent + if (window.parent && window.parent !== window) { + const readyMessage: VibeIframeReadyMessage = { + type: 'VIBE_IFRAME_READY', + }; + + // Send to specific origin if configured, otherwise send to any origin + const targetOrigin = allowedOrigin || '*'; + window.parent.postMessage(readyMessage, targetOrigin); + } + }, []); + + return <>{children}; +}