[aidan] ui/workflows: dark mode and design-system cohesion

This commit is contained in:
abccodes
2026-06-23 00:02:09 -07:00
parent a9496fd0f1
commit b8b0e3ec9e
4 changed files with 157 additions and 57 deletions
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { WC } from './uiKit';
import { useWC } from './uiKit';
// Combobox for the run limit: pick a preset OR type any count. Self-rendered
// (no native <select>, no portal) because a native popup over the canvas card
@@ -12,6 +12,7 @@ const OPTIONS: Array<{ label: string; val: number | null }> = [
];
const RepeatField: React.FC<{ value: number | null; onChange: (n: number | null) => void }> = ({ value, onChange }) => {
const WC = useWC();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
@@ -43,7 +44,7 @@ const RepeatField: React.FC<{ value: number | null; onChange: (n: number | null)
return (
<div ref={ref} style={{ position: 'relative', width: 134 }}>
<div style={{ display: 'flex', alignItems: 'center', background: '#FFFFFF', border: `1px solid ${open ? WC.accent : 'rgba(33,30,27,0.12)'}`, borderRadius: 8, height: 32, padding: '0 2px 0 9px' }}>
<div style={{ display: 'flex', alignItems: 'center', background: WC.raised, border: `1px solid ${open ? WC.accent : `rgba(${WC.inkRGB},0.12)`}`, borderRadius: 8, height: 32, padding: '0 2px 0 9px' }}>
<input
value={editing ? draft : display}
placeholder="Forever"
@@ -61,7 +62,7 @@ const RepeatField: React.FC<{ value: number | null; onChange: (n: number | null)
</button>
</div>
{open && (
<div style={{ position: 'absolute', top: 37, left: 0, right: 0, background: '#FFFFFF', border: `1px solid ${WC.line}`, borderRadius: 9, boxShadow: '0 8px 22px rgba(33,30,27,0.16)', padding: 4, zIndex: 40 }}>
<div style={{ position: 'absolute', top: 37, left: 0, right: 0, background: WC.raised, border: `1px solid ${WC.line}`, borderRadius: WC.radius.md, boxShadow: WC.shadow.md, padding: 4, zIndex: 40 }}>
{OPTIONS.map((o) => {
const active = (o.val == null && value == null) || o.val === value;
return (
@@ -1,5 +1,5 @@
import React from 'react';
import { WC } from './uiKit';
import { useWC } from './uiKit';
// Test-first nudge before scheduling: a test run grants the tool access the
// workflow needs, so unattended runs don't stall reaching for them.
@@ -8,29 +8,32 @@ const SaveGuard: React.FC<{
onClose: () => void;
onSaveAnyway: () => void;
onRunTest: () => void;
}> = ({ title, onClose, onSaveAnyway, onRunTest }) => (
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(33,30,27,0.34)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 28 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 430, maxWidth: '100%', background: WC.paper, borderRadius: 16, boxShadow: '0 30px 70px -22px rgba(0,0,0,0.45)', overflow: 'hidden' }}>
<div style={{ padding: '24px 24px 18px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 30, height: 30, borderRadius: 9, background: 'rgba(185,138,46,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2"><path d="M12 3l9 16H3z" /><path d="M12 10v4" /><path d="M12 17h.01" /></svg>
}> = ({ title, onClose, onSaveAnyway, onRunTest }) => {
const WC = useWC();
return (
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: `rgba(${WC.inkRGB},0.34)`, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 28 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 430, maxWidth: '100%', background: WC.paper, borderRadius: WC.radius.lg, boxShadow: WC.shadow.lg, overflow: 'hidden' }}>
<div style={{ padding: '24px 24px 18px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 30, height: 30, borderRadius: 9, background: 'rgba(185,138,46,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2"><path d="M12 3l9 16H3z" /><path d="M12 10v4" /><path d="M12 17h.01" /></svg>
</div>
<h3 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 20, fontWeight: 500, color: WC.ink }}>Test run recommended</h3>
</div>
<h3 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 20, fontWeight: 500, color: WC.ink }}>Test run recommended</h3>
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: WC.ink4 }}>
You havent tested {title} yet. A quick test run confirms the steps work and grants the tool access it needs before it goes on a schedule.
</p>
</div>
<div style={{ padding: '0 24px 22px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onSaveAnyway} style={{ background: 'transparent', border: `1px solid rgba(${WC.inkRGB},0.16)`, borderRadius: 9, padding: '9px 16px', fontSize: 13, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Save anyway</button>
<button onClick={onRunTest} style={{ display: 'flex', alignItems: 'center', gap: 7, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
<div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: '8px solid #fff', flex: 'none' }} />
<span>Run test now</span>
</button>
</div>
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: '#6B655C' }}>
You havent tested {title} yet. A quick test run confirms the steps work and grants the tool access it needs before it goes on a schedule.
</p>
</div>
<div style={{ padding: '0 24px 22px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onSaveAnyway} style={{ background: 'transparent', border: '1px solid rgba(33,30,27,0.16)', borderRadius: 9, padding: '9px 16px', fontSize: 13, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Save anyway</button>
<button onClick={onRunTest} style={{ display: 'flex', alignItems: 'center', gap: 7, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
<div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: '8px solid #fff', flex: 'none' }} />
<span>Run test now</span>
</button>
</div>
</div>
</div>
);
);
};
export default SaveGuard;
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { closeWorkflowsApp, setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
import { WC, FONT_SERIF } from './uiKit';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import { useWC, FONT_SERIF } from './uiKit';
import WorkflowsAppContent from './WorkflowsAppContent';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -53,6 +54,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
isSelected = false, isHighlighted = false, multiDragDelta = null,
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const WC = useWC();
const dispatch = useAppDispatch();
const panRef = useRef({ panX, panY });
@@ -175,7 +177,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
const dw = localResize?.w ?? cardWidth;
const dh = localResize?.h ?? cardHeight;
const border = isHighlighted ? `2px solid ${WC.accent}` : isSelected ? '2px solid #3b82f6' : '1px solid rgba(33,30,27,0.12)';
const border = isHighlighted ? `2px solid ${WC.accent}` : isSelected ? '2px solid #3b82f6' : `1px solid ${WC.border.subtle}`;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
return (
@@ -201,8 +203,8 @@ const WorkflowsAppCard: React.FC<Props> = ({
left: dx, top: dy, width: dw, height: dh,
background: WC.paper,
border,
borderRadius: 15,
boxShadow: '0 30px 80px -24px rgba(33,30,27,0.34), 0 8px 24px -12px rgba(33,30,27,0.18)',
borderRadius: WC.radius.lg,
boxShadow: (isDragging || isResizing) ? WC.shadow.lg : WC.shadow.md,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
@@ -218,11 +220,8 @@ const WorkflowsAppCard: React.FC<Props> = ({
style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={WC.accent} strokeWidth="1.9">
<circle cx="6" cy="6" r="2.5" /><circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="12" r="2.5" />
<path d="M8.2 7.1l7.6 3.8M8.2 16.9l7.6-3.8" />
</svg>
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}>Workflows</span>
<EventRepeatIcon sx={{ fontSize: 18, color: WC.accent, display: 'block' }} />
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em', lineHeight: 1, transform: 'translateY(2.5px)' }}>Workflows</span>
</div>
<div style={{ flex: 1 }} />
<div
@@ -266,7 +265,12 @@ function ensureAssets(): void {
document.head.appendChild(link);
const style = document.createElement('style');
style.id = 'workflows-app-keyframes';
style.textContent = '@keyframes os-spin { to { transform: rotate(360deg); } }';
style.textContent = [
'@keyframes os-spin { to { transform: rotate(360deg); } }',
'@keyframes os-flow { to { stroke-dashoffset: -18; } }',
'@keyframes os-pulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.55); opacity: 0.45; } }',
'@keyframes os-slidein { from { opacity: 0; transform: translateX(-22px) scale(0.97); } to { opacity: 1; transform: none; } }',
].join('\n');
document.head.appendChild(style);
}
+115 -23
View File
@@ -1,20 +1,60 @@
import type { CSSProperties } from 'react';
import { useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
// The Workflows app renders in its own warm-paper visual language (from the
// Claude design), deliberately separate from the MUI theme so the window reads
// as a focused app. All tokens live here so the panes stay consistent.
export const WC = {
// as a focused app. Two palettes (light/dark) keyed to the app theme; grab one
// per component with useWC(). inkRGB is the ink color as a bare "r,g,b" so the
// many rgba(ink, opacity) borders/hovers can flip with the theme too.
export interface WCPalette {
accent: string;
paper: string;
panel: string;
rail: string;
inset: string;
raised: string;
ink: string;
ink2: string;
ink3: string;
ink4: string;
muted: string;
muted2: string;
faint: string;
inkRGB: string;
line: string;
line2: string;
hover: string;
selBg: string;
success: string;
successBg: string;
danger: string;
dangerBg: string;
warn: string;
warnBg: string;
trackOff: string;
// Structural primitives shared with the rest of OpenSwarm (sourced from
// claudeTokens in useWC), so the window stops looking built-separate.
shadow: ClaudeTokens['shadow'];
radius: ClaudeTokens['radius'];
border: ClaudeTokens['border'];
}
// The identity half of the palette: accent, text, status, hairlines. The neutral
// surfaces + structural primitives are blended in from claudeTokens by useWC(),
// so the window is the same material as every other card.
type WCColors = Omit<WCPalette, 'shadow' | 'radius' | 'border' | 'paper' | 'panel' | 'rail' | 'inset' | 'raised'>;
export const WC_LIGHT: WCColors = {
accent: '#C25A36',
paper: '#FBFAF7',
panel: '#F4F2EC',
rail: '#F4F2EC',
inset: '#F0EEE7',
ink: '#211E1B',
ink2: '#2B2722',
ink3: '#4B463E',
muted: '#8C857A',
muted2: '#A39C92',
faint: '#B5AEA3',
ink4: '#6B655C',
muted: '#73726C',
muted2: '#8C857A',
faint: '#A39C92',
inkRGB: '33,30,27',
line: 'rgba(33,30,27,0.07)',
line2: 'rgba(33,30,27,0.12)',
hover: 'rgba(33,30,27,0.045)',
@@ -24,7 +64,53 @@ export const WC = {
danger: '#C2483A',
dangerBg: 'rgba(194,72,58,0.10)',
warn: '#B98A2E',
} as const;
warnBg: 'rgba(185,138,46,0.14)',
trackOff: '#D5D1C8',
};
export const WC_DARK: WCColors = {
accent: '#C25A36',
ink: '#F2EFE9',
ink2: '#E2DDD4',
ink3: '#B3ABA0',
ink4: '#9A9389',
muted: '#938C82',
muted2: '#736D64',
faint: '#6E6860',
inkRGB: '240,236,228',
line: 'rgba(240,236,228,0.07)',
line2: 'rgba(240,236,228,0.12)',
hover: 'rgba(240,236,228,0.045)',
selBg: 'rgba(240,236,228,0.06)',
success: '#2E7D5B',
successBg: 'rgba(46,125,91,0.22)',
danger: '#C2483A',
dangerBg: 'rgba(194,72,58,0.18)',
warn: '#B98A2E',
warnBg: 'rgba(185,138,46,0.18)',
trackOff: 'rgba(240,236,228,0.18)',
};
export function useWC(): WCPalette {
const { mode } = useThemeMode();
const c = useClaudeTokens();
const base = mode === 'dark' ? WC_DARK : WC_LIGHT;
return {
...base,
// Surfaces straight from the app tokens, so the window is the same material
// as every other card in both themes (the warmth lives in accent + text).
// Title bar + sidebar stay on bg.surface (not a darker fill) so the whole
// window reads as one card like the chat card; borders separate the regions.
paper: c.bg.surface,
panel: c.bg.surface,
rail: c.bg.surface,
inset: c.bg.page,
raised: c.bg.elevated,
shadow: c.shadow,
radius: c.radius,
border: c.border,
};
}
export const FONT_SERIF = "'Newsreader', Georgia, serif";
export const FONT_SANS = "'Hanken Grotesk', system-ui, sans-serif";
@@ -44,16 +130,22 @@ export function colorForId(id: string): string {
return WORKFLOW_PALETTE[h % WORKFLOW_PALETTE.length];
}
// Prefer the user's chosen swatch; fall back to the stable id-hash hue when
// they haven't picked one. Single source of truth for every dot/bar.
export function colorForWorkflow(wf: { id: string; color?: string | null }): string {
return wf.color || colorForId(wf.id);
}
export type RunStatus = 'success' | 'failure' | 'ran_late' | 'running' | 'skipped' | 'paused';
export function statusChip(status: RunStatus): CSSProperties {
export function statusChip(status: RunStatus, wc: WCPalette): CSSProperties {
const map: Record<string, [string, string]> = {
success: [WC.success, WC.successBg],
ran_late: [WC.warn, 'rgba(185,138,46,0.14)'],
failure: [WC.danger, 'rgba(194,72,58,0.12)'],
skipped: [WC.muted, 'rgba(33,30,27,0.07)'],
running: [WC.accent, 'rgba(0,0,0,0.04)'],
paused: [WC.muted, 'rgba(33,30,27,0.07)'],
success: [wc.success, wc.successBg],
ran_late: [wc.warn, wc.warnBg],
failure: [wc.danger, wc.dangerBg],
skipped: [wc.muted, `rgba(${wc.inkRGB},0.07)`],
running: [wc.accent, `rgba(${wc.inkRGB},0.06)`],
paused: [wc.muted, `rgba(${wc.inkRGB},0.07)`],
};
const [color, background] = map[status] || map.paused;
return {
@@ -62,17 +154,17 @@ export function statusChip(status: RunStatus): CSSProperties {
};
}
export function statusDot(status: RunStatus): CSSProperties {
export function statusDot(status: RunStatus, wc: WCPalette): CSSProperties {
const map: Record<string, string> = {
success: WC.success, ran_late: WC.warn, failure: WC.danger,
running: WC.accent, skipped: WC.faint, paused: WC.faint,
success: wc.success, ran_late: wc.warn, failure: wc.danger,
running: wc.accent, skipped: wc.faint, paused: wc.faint,
};
return { width: 8, height: 8, borderRadius: '50%', background: map[status] || WC.faint, flex: 'none' };
return { width: 8, height: 8, borderRadius: '50%', background: map[status] || wc.faint, flex: 'none' };
}
export function track(on: boolean): CSSProperties {
export function track(on: boolean, wc: WCPalette): CSSProperties {
return {
width: 34, height: 20, borderRadius: 999, background: on ? WC.accent : '#D5D1C8',
width: 34, height: 20, borderRadius: 999, background: on ? wc.accent : wc.trackOff,
position: 'relative', cursor: 'pointer', transition: 'background .15s', flex: 'none',
};
}