diff --git a/calm-plugins/vscode/src/webview/canvas/edges/TooltipEdge.tsx b/calm-plugins/vscode/src/webview/canvas/edges/TooltipEdge.tsx index 39da038952..4621aa0624 100644 --- a/calm-plugins/vscode/src/webview/canvas/edges/TooltipEdge.tsx +++ b/calm-plugins/vscode/src/webview/canvas/edges/TooltipEdge.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useCallback } from 'react'; import { BaseEdge, EdgeLabelRenderer, getBezierPath, getSmoothStepPath, getStraightPath, type EdgeProps } from 'reactflow'; +import { theme } from '../theme-colors'; export type EdgeDirection = 'source-to-target' | 'bidirectional' | 'none'; export type EdgeLineStyle = 'solid' | 'dashed' | 'dotted'; @@ -56,8 +57,9 @@ export function TooltipEdge({ markerStart={markerStart} style={{ ...(style as React.CSSProperties), + stroke: hovered ? theme.link : theme.fgMuted, ...(dashArray ? { strokeDasharray: dashArray } : {}), - ...(hovered ? { stroke: '#60a5fa', strokeWidth: 2.5 } : {}), + ...(hovered ? { strokeWidth: 2.5 } : {}), }} /> @@ -82,11 +84,11 @@ export function TooltipEdge({ pointerEvents: 'none', fontSize: '9px', fontWeight: 500, - color: '#64748b', - background: 'rgba(248,250,252,0.9)', + color: theme.fgMuted, + background: theme.bgWidget, padding: '1px 5px', borderRadius: '3px', - border: '1px solid rgba(148,163,184,0.3)', + border: `1px solid ${theme.border}`, }} > {label} @@ -115,7 +117,7 @@ export function TooltipEdge({ {protocol && (
Protocol - {protocol} + {protocol}
)} {description && ( @@ -137,7 +139,7 @@ const tooltipStyle: React.CSSProperties = { border: '1px solid var(--calm-border-input)', borderRadius: '6px', padding: '8px 10px', - boxShadow: '0 4px 12px rgba(0,0,0,0.6)', + boxShadow: `0 4px 12px ${theme.shadow}`, minWidth: '150px', maxWidth: '300px', }; diff --git a/calm-plugins/vscode/src/webview/canvas/nodes/ActorNode.tsx b/calm-plugins/vscode/src/webview/canvas/nodes/ActorNode.tsx index 6c449bddce..7d82aa47ab 100644 --- a/calm-plugins/vscode/src/webview/canvas/nodes/ActorNode.tsx +++ b/calm-plugins/vscode/src/webview/canvas/nodes/ActorNode.tsx @@ -1,17 +1,19 @@ import React from 'react'; import { Handle, Position, NodeResizer, type NodeProps } from 'reactflow'; import { getNodeStyleOverride } from '../../utils/building-block-style'; +import { theme } from '../theme-colors'; import { ValidationBadge } from './ValidationBadge'; export function ActorNode({ id, data, selected }: NodeProps) { const override = getNodeStyleOverride(data as Record); - const fill = override.background ?? '#f5f5f5'; + const fill = override.background ?? theme.bgWidget; + const stroke = selected ? theme.focus : theme.fg; const errorCount = (data as any).validationErrors ?? 0; const warnCount = (data as any).validationWarnings ?? 0; return ( <> - + @@ -20,8 +22,8 @@ export function ActorNode({ id, data, selected }: NodeProps) {
{(data as any).label ?? (data as any).calmId}
@@ -53,4 +55,4 @@ const labelStyle: React.CSSProperties = { whiteSpace: 'nowrap', }; -const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: '#94a3b8', border: '1px solid #64748b' }; +const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: theme.fgMuted, border: `1px solid ${theme.borderWidget}` }; diff --git a/calm-plugins/vscode/src/webview/canvas/nodes/ContainerNode.tsx b/calm-plugins/vscode/src/webview/canvas/nodes/ContainerNode.tsx index cb403a58bd..61a7d4d217 100644 --- a/calm-plugins/vscode/src/webview/canvas/nodes/ContainerNode.tsx +++ b/calm-plugins/vscode/src/webview/canvas/nodes/ContainerNode.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Handle, Position, NodeResizer, type NodeProps } from 'reactflow'; import { getNodeStyleOverride } from '../../utils/building-block-style'; +import { theme, tint } from '../theme-colors'; export function ContainerNode({ id, data, selected }: NodeProps) { const role = ((data as any).containerRole as string) || 'default'; @@ -12,23 +13,23 @@ export function ContainerNode({ id, data, selected }: NodeProps) { isVisible={selected} minWidth={180} minHeight={120} - lineStyle={{ borderColor: '#6366f1' }} + lineStyle={{ borderColor: theme.focus }} /> -
+
- + {(data as any).label ?? (data as any).calmId} - {role === 'region-primary' && ACTIVE} - {role === 'region-secondary' && STANDBY} - {role === 'az' && AZ} - {role === 'solution' && SOLUTION} + {role === 'region-primary' && ACTIVE} + {role === 'region-secondary' && STANDBY} + {role === 'az' && AZ} + {role === 'solution' && SOLUTION}
@@ -38,56 +39,56 @@ export function ContainerNode({ id, data, selected }: NodeProps) { const containerStyle: React.CSSProperties = { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', - border: '1.5px dashed #94a3b8', borderRadius: '8px', + border: `1.5px dashed ${theme.fgMuted}`, borderRadius: '8px', minWidth: '180px', minHeight: '120px', overflow: 'hidden', cursor: 'default', userSelect: 'none', }; function containerRoleStyle(role: string): React.CSSProperties { switch (role) { - case 'region-primary': return { borderColor: '#1a4d8f', borderStyle: 'solid', borderWidth: '2px', background: 'rgba(26,77,143,0.03)' }; - case 'region-secondary': return { borderColor: '#1a4d8f', borderStyle: 'dashed', borderWidth: '2px', background: 'rgba(26,77,143,0.02)' }; - case 'cluster': return { borderColor: '#1a4d8f', borderStyle: 'dashed', borderWidth: '1.5px', background: 'rgba(26,77,143,0.04)' }; - case 'az': return { borderColor: '#64748b', borderStyle: 'dotted', borderWidth: '1.5px', background: 'rgba(100,116,139,0.04)' }; - case 'solution': return { borderColor: '#2d6a4f', borderStyle: 'solid', borderWidth: '3px', borderRadius: '12px', background: 'rgba(45,106,79,0.06)' }; + case 'region-primary': return { borderColor: theme.blue, borderStyle: 'solid', borderWidth: '2px', background: tint(theme.blue, 12) }; + case 'region-secondary': return { borderColor: theme.blue, borderStyle: 'dashed', borderWidth: '2px', background: tint(theme.blue, 8) }; + case 'cluster': return { borderColor: theme.blue, borderStyle: 'dashed', borderWidth: '1.5px', background: tint(theme.blue, 12) }; + case 'az': return { borderColor: theme.fgMuted, borderStyle: 'dotted', borderWidth: '1.5px', background: tint(theme.fgMuted, 10) }; + case 'solution': return { borderColor: theme.green, borderStyle: 'solid', borderWidth: '3px', borderRadius: '12px', background: tint(theme.green, 14) }; default: return {}; } } const headerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '6px', padding: '5px 10px', - background: '#f1f5f9', borderBottom: '1px solid #e2e8f0', + background: theme.bgWidget, borderBottom: `1px solid ${theme.border}`, }; function headerRoleStyle(role: string): React.CSSProperties { switch (role) { - case 'region-primary': return { background: 'rgba(26,77,143,0.08)', borderBottomColor: 'rgba(26,77,143,0.2)' }; - case 'region-secondary': return { background: 'rgba(26,77,143,0.06)', borderBottomColor: 'rgba(26,77,143,0.15)' }; - case 'cluster': return { background: 'rgba(26,77,143,0.08)', borderBottomColor: 'rgba(26,77,143,0.2)' }; - case 'az': return { background: 'rgba(100,116,139,0.06)', borderBottomColor: 'rgba(100,116,139,0.15)' }; - case 'solution': return { background: 'rgba(45,106,79,0.1)', borderBottomColor: 'rgba(45,106,79,0.25)' }; + case 'region-primary': return { background: tint(theme.blue, 18), borderBottomColor: tint(theme.blue, 35) }; + case 'region-secondary': return { background: tint(theme.blue, 14), borderBottomColor: tint(theme.blue, 28) }; + case 'cluster': return { background: tint(theme.blue, 18), borderBottomColor: tint(theme.blue, 35) }; + case 'az': return { background: tint(theme.fgMuted, 14), borderBottomColor: tint(theme.fgMuted, 28) }; + case 'solution': return { background: tint(theme.green, 18), borderBottomColor: tint(theme.green, 35) }; default: return {}; } } -const dotStyle: React.CSSProperties = { width: '6px', height: '6px', borderRadius: '50%', background: '#64748b', opacity: 0.5, flexShrink: 0 }; +const dotStyle: React.CSSProperties = { width: '6px', height: '6px', borderRadius: '50%', background: theme.fgMuted, opacity: 0.5, flexShrink: 0 }; function dotRoleStyle(role: string): React.CSSProperties { switch (role) { - case 'region-primary': return { background: '#1a4d8f', opacity: 1 }; - case 'region-secondary': return { background: '#1a4d8f', opacity: 0.7 }; - case 'cluster': return { background: '#1a4d8f', opacity: 1 }; - case 'az': return { background: '#64748b', opacity: 0.7 }; - case 'solution': return { background: '#2d6a4f', opacity: 1 }; + case 'region-primary': return { background: theme.blue, opacity: 1 }; + case 'region-secondary': return { background: theme.blue, opacity: 0.7 }; + case 'cluster': return { background: theme.blue, opacity: 1 }; + case 'az': return { background: theme.fgMuted, opacity: 0.7 }; + case 'solution': return { background: theme.green, opacity: 1 }; default: return {}; } } const labelBaseStyle: React.CSSProperties = { - fontSize: '10px', fontWeight: 600, color: '#1e293b', + fontSize: '10px', fontWeight: 600, color: theme.fg, maxWidth: '140px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, }; const tagStyle: React.CSSProperties = { fontSize: '8px', fontWeight: 700, letterSpacing: '0.5px', padding: '1px 5px', borderRadius: '3px', flexShrink: 0, }; -const handleStyle: React.CSSProperties = { width: '6px', height: '6px', background: '#94a3b8', border: '1px solid #64748b' }; +const handleStyle: React.CSSProperties = { width: '6px', height: '6px', background: theme.fgMuted, border: `1px solid ${theme.borderWidget}` }; diff --git a/calm-plugins/vscode/src/webview/canvas/nodes/DatabaseNode.tsx b/calm-plugins/vscode/src/webview/canvas/nodes/DatabaseNode.tsx index dcd6d42681..0c460a8a12 100644 --- a/calm-plugins/vscode/src/webview/canvas/nodes/DatabaseNode.tsx +++ b/calm-plugins/vscode/src/webview/canvas/nodes/DatabaseNode.tsx @@ -1,17 +1,19 @@ import React from 'react'; import { Handle, Position, NodeResizer, type NodeProps } from 'reactflow'; import { getNodeStyleOverride } from '../../utils/building-block-style'; +import { onSurface, theme } from '../theme-colors'; import { ValidationBadge } from './ValidationBadge'; export function DatabaseNode({ id, data, selected }: NodeProps) { const override = getNodeStyleOverride(data as Record); - const fill = override.background ?? '#e6f4ea'; + const fill = override.background ?? onSurface(theme.green, 28); + const stroke = selected ? theme.focus : theme.green; const errorCount = (data as any).validationErrors ?? 0; const warnCount = (data as any).validationWarnings ?? 0; return ( <> - + @@ -20,9 +22,9 @@ export function DatabaseNode({ id, data, selected }: NodeProps) {
{(data as any).label ?? (data as any).calmId}
@@ -52,4 +54,4 @@ const labelStyle: React.CSSProperties = { whiteSpace: 'nowrap', }; -const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: '#94a3b8', border: '1px solid #64748b' }; +const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: theme.fgMuted, border: `1px solid ${theme.borderWidget}` }; diff --git a/calm-plugins/vscode/src/webview/canvas/nodes/GenericNode.tsx b/calm-plugins/vscode/src/webview/canvas/nodes/GenericNode.tsx index 439dadee6d..6cbb508f56 100644 --- a/calm-plugins/vscode/src/webview/canvas/nodes/GenericNode.tsx +++ b/calm-plugins/vscode/src/webview/canvas/nodes/GenericNode.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Handle, Position, NodeResizer, type NodeProps } from 'reactflow'; -import { isBuildingBlock, getNodeStyleOverride, CUSTOM_TEXT, CUSTOM_BG, CUSTOM_BORDER } from '../../utils/building-block-style'; +import { isBuildingBlock, getNodeStyleOverride } from '../../utils/building-block-style'; +import { theme } from '../theme-colors'; import { ValidationBadge } from './ValidationBadge'; export function GenericNode({ id, data, selected }: NodeProps) { @@ -16,7 +17,7 @@ export function GenericNode({ id, data, selected }: NodeProps) { - +
- +
@@ -40,19 +41,19 @@ export function GenericNode({ id, data, selected }: NodeProps) { const baseStyle: React.CSSProperties = { position: 'relative', display: 'flex', alignItems: 'center', gap: '7px', width: '100%', height: '100%', padding: '8px 10px', boxSizing: 'border-box', - background: '#f8fafc', border: '1.5px solid #cbd5e1', borderRadius: '10px', + background: theme.bgWidget, border: `1.5px solid ${theme.borderWidget}`, borderRadius: '10px', cursor: 'default', userSelect: 'none', }; const buildingBlockStyle: React.CSSProperties = { - background: CUSTOM_BG, borderColor: CUSTOM_BORDER, borderWidth: '2px', borderRadius: '6px', + background: theme.badgeBg, borderColor: theme.badgeBg, borderWidth: '2px', borderRadius: '6px', }; const selectedStyle: React.CSSProperties = { - borderColor: '#6366f1', boxShadow: '0 0 0 1.5px #6366f1', + borderColor: theme.focus, boxShadow: `0 0 0 1.5px ${theme.focus}`, }; const iconStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', flexShrink: 0 }; const labelStyle: React.CSSProperties = { - fontSize: '10px', fontWeight: 600, color: '#1e293b', + fontSize: '10px', fontWeight: 600, color: theme.fg, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }; -const labelBlockStyle: React.CSSProperties = { color: '#ffffff' }; -const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: '#94a3b8', border: '1px solid #64748b' }; +const labelBlockStyle: React.CSSProperties = { color: theme.badgeFg }; +const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: theme.fgMuted, border: `1px solid ${theme.borderWidget}` }; diff --git a/calm-plugins/vscode/src/webview/canvas/nodes/ServiceNode.tsx b/calm-plugins/vscode/src/webview/canvas/nodes/ServiceNode.tsx index 6043795456..9e27b38b4f 100644 --- a/calm-plugins/vscode/src/webview/canvas/nodes/ServiceNode.tsx +++ b/calm-plugins/vscode/src/webview/canvas/nodes/ServiceNode.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Handle, Position, NodeResizer, type NodeProps } from 'reactflow'; -import { isBuildingBlock, getNodeStyleOverride, CUSTOM_TEXT, CUSTOM_BG, CUSTOM_BORDER } from '../../utils/building-block-style'; +import { isBuildingBlock, getNodeStyleOverride } from '../../utils/building-block-style'; +import { onSurface, theme } from '../theme-colors'; import { ValidationBadge } from './ValidationBadge'; export function ServiceNode({ id, data, selected }: NodeProps) { @@ -16,7 +17,7 @@ export function ServiceNode({ id, data, selected }: NodeProps) { - +
- + @@ -41,19 +42,19 @@ export function ServiceNode({ id, data, selected }: NodeProps) { const baseStyle: React.CSSProperties = { position: 'relative', display: 'flex', alignItems: 'center', gap: '7px', width: '100%', height: '100%', padding: '8px 10px', boxSizing: 'border-box', - background: '#e6f4ea', border: '1.5px solid #1b7340', borderRadius: '10px', + background: onSurface(theme.green, 22), border: `1.5px solid ${theme.green}`, borderRadius: '10px', cursor: 'default', userSelect: 'none', }; const buildingBlockStyle: React.CSSProperties = { - background: CUSTOM_BG, borderColor: CUSTOM_BORDER, borderWidth: '2px', borderRadius: '6px', + background: theme.badgeBg, borderColor: theme.badgeBg, borderWidth: '2px', borderRadius: '6px', }; const selectedStyle: React.CSSProperties = { - borderColor: '#6366f1', boxShadow: '0 0 0 1.5px #6366f1', + borderColor: theme.focus, boxShadow: `0 0 0 1.5px ${theme.focus}`, }; const iconStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', flexShrink: 0 }; const labelStyle: React.CSSProperties = { - fontSize: '10px', fontWeight: 600, color: '#1e293b', + fontSize: '10px', fontWeight: 600, color: theme.fg, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }; -const labelBlockStyle: React.CSSProperties = { color: '#ffffff' }; -const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: '#94a3b8', border: '1px solid #64748b' }; +const labelBlockStyle: React.CSSProperties = { color: theme.badgeFg }; +const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: theme.fgMuted, border: `1px solid ${theme.borderWidget}` }; diff --git a/calm-plugins/vscode/src/webview/canvas/nodes/ValidationBadge.tsx b/calm-plugins/vscode/src/webview/canvas/nodes/ValidationBadge.tsx index 71bde9f2dd..5eef3d424a 100644 --- a/calm-plugins/vscode/src/webview/canvas/nodes/ValidationBadge.tsx +++ b/calm-plugins/vscode/src/webview/canvas/nodes/ValidationBadge.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { theme } from '../theme-colors'; interface ValidationBadgeProps { errorCount: number; @@ -12,12 +13,12 @@ export function ValidationBadge({ errorCount, warnCount, nodeId }: ValidationBad return (
{errorCount > 0 && ( - + {errorCount} )} {warnCount > 0 && ( - + {warnCount} )} diff --git a/calm-plugins/vscode/src/webview/canvas/nodes/WebclientNode.tsx b/calm-plugins/vscode/src/webview/canvas/nodes/WebclientNode.tsx index a8b224315c..dde60ad7aa 100644 --- a/calm-plugins/vscode/src/webview/canvas/nodes/WebclientNode.tsx +++ b/calm-plugins/vscode/src/webview/canvas/nodes/WebclientNode.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Handle, Position, NodeResizer, type NodeProps } from 'reactflow'; -import { isBuildingBlock, getNodeStyleOverride, CUSTOM_TEXT, CUSTOM_BG, CUSTOM_BORDER } from '../../utils/building-block-style'; +import { isBuildingBlock, getNodeStyleOverride } from '../../utils/building-block-style'; +import { onSurface, theme } from '../theme-colors'; import { ValidationBadge } from './ValidationBadge'; export function WebclientNode({ id, data, selected }: NodeProps) { @@ -16,7 +17,7 @@ export function WebclientNode({ id, data, selected }: NodeProps) { - +
- +
@@ -40,19 +41,19 @@ export function WebclientNode({ id, data, selected }: NodeProps) { const baseStyle: React.CSSProperties = { position: 'relative', display: 'flex', alignItems: 'center', gap: '7px', width: '100%', height: '100%', padding: '8px 10px', boxSizing: 'border-box', - background: '#e0f2fe', border: '1.5px solid #0369a1', borderRadius: '10px', + background: onSurface(theme.blue, 22), border: `1.5px solid ${theme.blue}`, borderRadius: '10px', cursor: 'default', userSelect: 'none', }; const buildingBlockStyle: React.CSSProperties = { - background: CUSTOM_BG, borderColor: CUSTOM_BORDER, borderWidth: '2px', borderRadius: '6px', + background: theme.badgeBg, borderColor: theme.badgeBg, borderWidth: '2px', borderRadius: '6px', }; const selectedStyle: React.CSSProperties = { - borderColor: '#6366f1', boxShadow: '0 0 0 1.5px #6366f1', + borderColor: theme.focus, boxShadow: `0 0 0 1.5px ${theme.focus}`, }; const iconStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', flexShrink: 0 }; const labelStyle: React.CSSProperties = { - fontSize: '10px', fontWeight: 600, color: '#1e293b', + fontSize: '10px', fontWeight: 600, color: theme.fg, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }; -const labelBlockStyle: React.CSSProperties = { color: '#ffffff' }; -const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: '#94a3b8', border: '1px solid #64748b' }; +const labelBlockStyle: React.CSSProperties = { color: theme.badgeFg }; +const handleStyle: React.CSSProperties = { width: '8px', height: '8px', background: theme.fgMuted, border: `1px solid ${theme.borderWidget}` }; diff --git a/calm-plugins/vscode/src/webview/canvas/theme-colors.test.ts b/calm-plugins/vscode/src/webview/canvas/theme-colors.test.ts new file mode 100644 index 0000000000..d349cf8ddf --- /dev/null +++ b/calm-plugins/vscode/src/webview/canvas/theme-colors.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + THEME_BG_FALLBACK, + cssColorToHex, + onSurface, + theme, + tint, +} from './theme-colors'; + +describe('theme tokens', () => { + it('points every canvas colour at a VS Code theme variable', () => { + for (const value of Object.values(theme)) { + expect(value).toContain('--vscode-'); + } + }); + + it('mixes theme colours instead of a fixed rgb', () => { + expect(tint(theme.blue, 12)).toBe( + `color-mix(in srgb, ${theme.blue} 12%, transparent)`, + ); + expect(onSurface(theme.green, 22)).toContain('--vscode-charts-green'); + expect(onSurface(theme.green, 22)).toContain('--vscode-editor-background'); + }); +}); + +describe('cssColorToHex', () => { + it('normalises hex colours for the colour input', () => { + expect(cssColorToHex('#1e293b', THEME_BG_FALLBACK)).toBe('#1e293b'); + expect(cssColorToHex(' #F8FAFC ', THEME_BG_FALLBACK)).toBe('#f8fafc'); + expect(cssColorToHex('#AABBCCDD', THEME_BG_FALLBACK)).toBe('#aabbcc'); + expect(cssColorToHex('#abc', THEME_BG_FALLBACK)).toBe('#aabbcc'); + expect(cssColorToHex('#abcd', THEME_BG_FALLBACK)).toBe('#aabbcc'); + }); + + it('converts rgb() and rgba() colours', () => { + expect(cssColorToHex('rgb(30, 41, 59)', THEME_BG_FALLBACK)).toBe('#1e293b'); + expect(cssColorToHex('rgba(255, 128, 0, 0.2)', THEME_BG_FALLBACK)).toBe('#ff8000'); + expect(cssColorToHex('rgb(30 41 59 / 0.5)', THEME_BG_FALLBACK)).toBe('#1e293b'); + expect(cssColorToHex('rgb(30.2, 41.6, 59.4)', THEME_BG_FALLBACK)).toBe('#1e2a3b'); + }); + + it('returns the fallback when the value is not a usable colour', () => { + expect(cssColorToHex('', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + expect(cssColorToHex(' ', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + expect(cssColorToHex('red', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + expect(cssColorToHex('var(--vscode-foreground)', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + expect(cssColorToHex('#gggggg', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + expect(cssColorToHex('#12', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + expect(cssColorToHex('#12345', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + expect(cssColorToHex('rgb(999, 0, 0)', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + expect(cssColorToHex('rgb(-1, 0, 0)', THEME_BG_FALLBACK)).toBe(THEME_BG_FALLBACK); + }); +}); diff --git a/calm-plugins/vscode/src/webview/canvas/theme-colors.ts b/calm-plugins/vscode/src/webview/canvas/theme-colors.ts new file mode 100644 index 0000000000..d220f8ba27 --- /dev/null +++ b/calm-plugins/vscode/src/webview/canvas/theme-colors.ts @@ -0,0 +1,73 @@ +/** + * Colours for the canvas webview, taken from the active VS Code theme. + * Fallbacks match the dark-theme defaults in canvas.css. + */ + +export const THEME_BG_VAR = '--vscode-editorWidget-background'; +export const THEME_TEXT_VAR = '--vscode-foreground'; +export const THEME_BG_FALLBACK = '#252526'; +export const THEME_TEXT_FALLBACK = '#cccccc'; + +export const theme = { + fg: `var(${THEME_TEXT_VAR}, ${THEME_TEXT_FALLBACK})`, + fgMuted: 'var(--vscode-descriptionForeground, #888888)', + bg: 'var(--vscode-editor-background, #1e1e1e)', + bgWidget: `var(${THEME_BG_VAR}, ${THEME_BG_FALLBACK})`, + border: 'var(--vscode-panel-border, #2d2d2d)', + borderWidget: 'var(--vscode-widget-border, #444444)', + borderInput: 'var(--vscode-input-border, #555555)', + focus: 'var(--vscode-focusBorder, #007fd4)', + link: 'var(--vscode-textLink-foreground, #3794ff)', + badgeBg: 'var(--vscode-badge-background, #0e639c)', + badgeFg: 'var(--vscode-badge-foreground, #ffffff)', + shadow: 'var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36))', + errorFg: 'var(--vscode-editorError-foreground, #f14c4c)', + errorBg: 'var(--vscode-inputValidation-errorBackground, #5a1d1d)', + warningFg: 'var(--vscode-editorWarning-foreground, #cca700)', + warningBg: 'var(--vscode-inputValidation-warningBackground, #352a05)', + green: 'var(--vscode-charts-green, #89d185)', + blue: 'var(--vscode-charts-blue, #3794ff)', +} as const; + +/** A translucent wash of a theme colour. Containers stay see-through. */ +export function tint(color: string, percent: number): string { + return `color-mix(in srgb, ${color} ${percent}%, transparent)`; +} + +/** An opaque mix of a theme colour onto the editor background. */ +export function onSurface(color: string, percent: number): string { + return `color-mix(in srgb, ${color} ${percent}%, ${theme.bg})`; +} + +/** + * `` only accepts `#rrggbb`. Theme variables arrive as + * hex or rgb(), so the appearance picker resolves them before display. + */ +export function cssColorToHex(raw: string, fallback: string): string { + const value = raw.trim(); + if (!value) return fallback; + + const hex = /^#([0-9a-f]{3,8})$/i.exec(value); + if (hex) { + const digits = hex[1]; + if (digits.length === 3 || digits.length === 4) { + const [r, g, b] = digits; + return `#${r}${r}${g}${g}${b}${b}`.toLowerCase(); + } + if (digits.length === 6 || digits.length === 8) { + return `#${digits.slice(0, 6).toLowerCase()}`; + } + return fallback; + } + + const rgb = /^rgba?\(\s*([0-9.]+)[,\s]+([0-9.]+)[,\s]+([0-9.]+)/i.exec(value); + if (!rgb) return fallback; + + const channels: string[] = []; + for (const part of [rgb[1], rgb[2], rgb[3]]) { + const n = Number(part); + if (!Number.isFinite(n) || n < 0 || n > 255) return fallback; + channels.push(Math.round(n).toString(16).padStart(2, '0')); + } + return `#${channels.join('')}`; +} diff --git a/calm-plugins/vscode/src/webview/panels/NodeAppearance.tsx b/calm-plugins/vscode/src/webview/panels/NodeAppearance.tsx index 738ed1fa82..661ae811d7 100644 --- a/calm-plugins/vscode/src/webview/panels/NodeAppearance.tsx +++ b/calm-plugins/vscode/src/webview/panels/NodeAppearance.tsx @@ -1,8 +1,16 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import type { NodeStyleOverride } from '../utils/building-block-style'; +import { THEME_BG_FALLBACK, THEME_BG_VAR, THEME_TEXT_FALLBACK, THEME_TEXT_VAR, cssColorToHex } from '../canvas/theme-colors'; -const DEFAULT_BG = '#f8fafc'; -const DEFAULT_TEXT = '#1e293b'; +function useThemeHex(variable: string, fallback: string): string { + const [hex, setHex] = useState(fallback); + useEffect(() => { + if (typeof document === 'undefined') return; + const raw = getComputedStyle(document.documentElement).getPropertyValue(variable); + setHex(cssColorToHex(raw, fallback)); + }, [variable, fallback]); + return hex; +} interface NodeAppearanceProps { /** Current overrides from the node's `metadata['building-block-style']` (undefined = defaults). */ @@ -25,6 +33,8 @@ export function NodeAppearance({ }: NodeAppearanceProps) { const [bg, setBg] = useState(style?.background ?? ''); const [text, setText] = useState(style?.text ?? ''); + const defaultBg = useThemeHex(THEME_BG_VAR, THEME_BG_FALLBACK); + const defaultText = useThemeHex(THEME_TEXT_VAR, THEME_TEXT_FALLBACK); const timer = useRef | null>(null); const push = (nextBg: string, nextText: string) => { @@ -64,7 +74,7 @@ export function NodeAppearance({ onBg(e.target.value)} style={colorSwatch} title="Background colour" @@ -86,7 +96,7 @@ export function NodeAppearance({ onText(e.target.value)} style={colorSwatch} title="Text colour" diff --git a/calm-plugins/vscode/src/webview/panels/NodePalette.tsx b/calm-plugins/vscode/src/webview/panels/NodePalette.tsx index a0bab9b2f2..44d85830de 100644 --- a/calm-plugins/vscode/src/webview/panels/NodePalette.tsx +++ b/calm-plugins/vscode/src/webview/panels/NodePalette.tsx @@ -1,5 +1,6 @@ import React, { useState, useMemo } from 'react'; import { initAllPacks, getAllPacks, type PackDefinition, type NodeTypeEntry } from '../../packs/index.js'; +import { theme, tint } from '../canvas/theme-colors'; initAllPacks(); @@ -215,7 +216,7 @@ export function NodePalette({ buildingBlocks }: NodePaletteProps) { function Section({ title, collapsed, onToggle, badge, borderColor, children }: { title: string; collapsed: boolean; onToggle: () => void; badge?: string; borderColor?: string; children: React.ReactNode }) { const sectionStyle: React.CSSProperties = { marginBottom: '4px', - ...(badge ? { borderLeft: '2px solid #2e7d32', marginLeft: '4px', background: 'rgba(46,125,50,0.04)' } : {}), + ...(badge ? { borderLeft: `2px solid ${theme.green}`, marginLeft: '4px', background: tint(theme.green, 12) } : {}), ...(borderColor && !badge ? { borderLeft: `2px solid ${borderColor}`, marginLeft: '4px' } : {}), }; return ( @@ -247,6 +248,6 @@ const headerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center' const searchInputStyle: React.CSSProperties = { width: '100%', height: '28px', padding: '0 24px 0 8px', fontSize: '11px', color: 'var(--calm-fg)', background: 'var(--calm-bg-input)', border: '1px solid var(--calm-border-input)', borderRadius: '4px', outline: 'none' }; const clearBtnStyle: React.CSSProperties = { position: 'absolute', right: '14px', top: '4px', width: '20px', height: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--calm-fg-muted)', fontSize: '14px' }; const groupNameStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 12px', fontSize: '10px', fontWeight: 700, letterSpacing: '0.3px', color: 'var(--calm-fg-muted)', background: 'none', border: 'none', width: '100%', cursor: 'pointer', textAlign: 'left' }; -const wsBadgeStyle: React.CSSProperties = { fontSize: '8px', fontWeight: 800, padding: '1px 4px', borderRadius: '3px', background: '#2e7d32', color: '#fff', letterSpacing: '0.5px' }; +const wsBadgeStyle: React.CSSProperties = { fontSize: '8px', fontWeight: 800, padding: '1px 4px', borderRadius: '3px', background: theme.green, color: theme.bg, letterSpacing: '0.5px' }; const subgroupStyle: React.CSSProperties = { padding: '3px 12px 3px 20px', fontSize: '9px', fontWeight: 600, color: 'var(--calm-fg-muted)', textTransform: 'capitalize' }; const itemStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 12px', cursor: 'grab', borderRadius: '4px', margin: '1px 6px' }; diff --git a/calm-plugins/vscode/src/webview/panels/StandardsPanel.tsx b/calm-plugins/vscode/src/webview/panels/StandardsPanel.tsx index 2108c58f58..1f49e16293 100644 --- a/calm-plugins/vscode/src/webview/panels/StandardsPanel.tsx +++ b/calm-plugins/vscode/src/webview/panels/StandardsPanel.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { theme } from '../canvas/theme-colors'; interface StandardsPanelProps { requirementUrl: string; @@ -28,7 +29,7 @@ const panelStyle: React.CSSProperties = { position: 'absolute', top: '10%', right: '10%', width: '400px', maxHeight: '60%', display: 'flex', flexDirection: 'column', background: 'var(--calm-bg)', border: '1px solid var(--calm-border-heavy)', borderRadius: '8px', - boxShadow: '0 8px 24px rgba(0,0,0,0.5)', zIndex: 200, + boxShadow: `0 8px 24px ${theme.shadow}`, zIndex: 200, }; const headerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 14px', diff --git a/calm-plugins/vscode/src/webview/panels/ToolbarMenu.tsx b/calm-plugins/vscode/src/webview/panels/ToolbarMenu.tsx index 4b771af509..7907ac7c50 100644 --- a/calm-plugins/vscode/src/webview/panels/ToolbarMenu.tsx +++ b/calm-plugins/vscode/src/webview/panels/ToolbarMenu.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; +import { theme } from '../canvas/theme-colors'; export interface ToolbarMenuItem { label: string; @@ -35,7 +36,7 @@ export function ToolbarMenu({ items }: Readonly<{ items: ToolbarMenuItem[] }>) { aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((o) => !o)} - style={{ padding: '3px 7px', border: '1px solid #555', borderRadius: '4px' }} + style={{ padding: '3px 7px', border: `1px solid ${theme.borderInput}`, borderRadius: '4px' }} > @@ -70,7 +71,7 @@ const menuStyle: React.CSSProperties = { background: 'var(--calm-bg-secondary)', border: '1px solid var(--calm-border-heavy)', borderRadius: '6px', - boxShadow: '0 4px 12px rgba(0,0,0,0.4)', + boxShadow: `0 4px 12px ${theme.shadow}`, minWidth: '170px', zIndex: 1000, padding: '4px', diff --git a/calm-plugins/vscode/src/webview/utils/building-block-style.ts b/calm-plugins/vscode/src/webview/utils/building-block-style.ts index 09d9f000d5..2a02668d9c 100644 --- a/calm-plugins/vscode/src/webview/utils/building-block-style.ts +++ b/calm-plugins/vscode/src/webview/utils/building-block-style.ts @@ -1,7 +1,3 @@ -export const CUSTOM_BG = '#1a4d8f'; -export const CUSTOM_BORDER = '#0f3461'; -export const CUSTOM_TEXT = '#ffffff'; - export function isBuildingBlock(data: Record): boolean { const metadata = data.metadata as Record | undefined; return !!metadata?.['source-building-block'] || !!metadata?.['source-fidelity-node'];