From 06bcfc5f35ea4804bb4c53b64a4edf5009362bfe Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 27 Jan 2026 22:27:08 -0800 Subject: [PATCH] themes + emoji --- bun.lock | 2 +- src/client/index.tsx | 139 ++++++++++++++++++++++++------------- src/client/themes/dark.ts | 38 ++++++++++ src/client/themes/index.ts | 8 +++ src/client/themes/light.ts | 38 ++++++++++ src/server/apps.ts | 64 ++++++++++------- src/server/index.tsx | 3 +- src/shared/types.ts | 1 + 8 files changed, 216 insertions(+), 77 deletions(-) create mode 100644 src/client/themes/dark.ts create mode 100644 src/client/themes/index.ts create mode 100644 src/client/themes/light.ts diff --git a/bun.lock b/bun.lock index 9e1e7d9..bc5cb05 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], - "hype": ["hype@git+https://git.nose.space/defunkt/hype#b9b4e205c9b04cb3897054db2940ff67ce7cfd5b", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "b9b4e205c9b04cb3897054db2940ff67ce7cfd5b"], + "hype": ["hype@git+https://git.nose.space/defunkt/hype#a8d3a8203e145df7a222ea409588c2ea3a1ee4e6", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "a8d3a8203e145df7a222ea409588c2ea3a1ee4e6"], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], diff --git a/src/client/index.tsx b/src/client/index.tsx index b7820ab..b9a22fe 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,9 +1,10 @@ import { render as renderApp } from 'hono/jsx/dom' import { define, Styles } from 'forge' import type { App, AppState } from '../shared/types' +import { theme } from './themes' // UI state (survives re-renders) -let selectedApp: string | null = null +let selectedApp: string | null = localStorage.getItem('selectedApp') // Server state (from SSE) let apps: App[] = [] @@ -12,31 +13,34 @@ let apps: App[] = [] const Layout = define('Layout', { display: 'flex', height: '100vh', - fontFamily: 'system-ui, -apple-system, sans-serif', - background: '#0a0a0a', - color: '#e5e5e5', + fontFamily: theme('fonts-sans'), + background: theme('colors-bg'), + color: theme('colors-text'), }) const Sidebar = define('Sidebar', { width: 220, - borderRight: '1px solid #333', + borderRight: `1px solid ${theme('colors-border')}`, display: 'flex', flexDirection: 'column', flexShrink: 0, }) const Logo = define('Logo', { - padding: '20px 16px', + height: 64, + display: 'flex', + alignItems: 'center', + padding: '0 16px', fontSize: 20, fontWeight: 'bold', - borderBottom: '1px solid #333', + borderBottom: `1px solid ${theme('colors-border')}`, }) const SectionLabel = define('SectionLabel', { padding: '16px 16px 8px', fontSize: 12, fontWeight: 600, - color: '#666', + color: theme('colors-textFaint'), textTransform: 'uppercase', letterSpacing: '0.05em', }) @@ -48,18 +52,19 @@ const AppList = define('AppList', { const AppItem = define('AppItem', { display: 'flex', + flexDirection: 'row', alignItems: 'center', gap: 8, padding: '8px 16px', - color: '#999', + color: theme('colors-textMuted'), textDecoration: 'none', fontSize: 14, cursor: 'pointer', selectors: { - '&:hover': { background: '#1a1a1a', color: '#e5e5e5' }, + '&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') }, }, variants: { - selected: { background: '#1f1f1f', color: '#fff', fontWeight: 500 }, + selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 }, }, }) @@ -70,33 +75,36 @@ const StatusDot = define('StatusDot', { flexShrink: 0, variants: { state: { - invalid: { background: '#ef4444' }, - stopped: { background: '#666' }, - starting: { background: '#eab308' }, - running: { background: '#22c55e' }, - stopping: { background: '#eab308' }, + invalid: { background: theme('colors-statusInvalid') }, + stopped: { background: theme('colors-statusStopped') }, + starting: { background: theme('colors-statusStarting') }, + running: { background: theme('colors-statusRunning') }, + stopping: { background: theme('colors-statusStarting') }, }, + inline: { + display: 'inline' + } }, }) const SidebarFooter = define('SidebarFooter', { padding: 16, - borderTop: '1px solid #333', + borderTop: `1px solid ${theme('colors-border')}`, }) const NewAppButton = define('NewAppButton', { display: 'block', padding: '8px 12px', - background: '#1f1f1f', - border: '1px solid #333', - borderRadius: 6, - color: '#999', + background: theme('colors-bgElement'), + border: `1px solid ${theme('colors-border')}`, + borderRadius: theme('radius-md'), + color: theme('colors-textMuted'), textDecoration: 'none', fontSize: 14, textAlign: 'center', cursor: 'pointer', selectors: { - '&:hover': { background: '#2a2a2a', color: '#e5e5e5' }, + '&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') }, }, }) @@ -109,14 +117,18 @@ const Main = define('Main', { }) const MainHeader = define('MainHeader', { + height: 64, display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '16px 24px', - borderBottom: '1px solid #333', + padding: '0 24px', + borderBottom: `1px solid ${theme('colors-border')}`, }) const MainTitle = define('MainTitle', { + display: 'flex', + alignItems: 'center', + gap: 8, fontSize: 20, fontWeight: 600, margin: 0, @@ -140,12 +152,12 @@ const Section = define('Section', { const SectionTitle = define('SectionTitle', { fontSize: 12, fontWeight: 600, - color: '#666', + color: theme('colors-textFaint'), textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12, paddingBottom: 8, - borderBottom: '1px solid #333', + borderBottom: `1px solid ${theme('colors-border')}`, }) const InfoRow = define('InfoRow', { @@ -157,13 +169,13 @@ const InfoRow = define('InfoRow', { }) const InfoLabel = define('InfoLabel', { - color: '#666', + color: theme('colors-textFaint'), width: 80, flexShrink: 0, }) const InfoValue = define('InfoValue', { - color: '#e5e5e5', + color: theme('colors-text'), display: 'flex', alignItems: 'center', gap: 8, @@ -171,7 +183,7 @@ const InfoValue = define('InfoValue', { const Link = define('Link', { base: 'a', - color: '#22d3ee', + color: theme('colors-link'), textDecoration: 'none', selectors: { '&:hover': { textDecoration: 'underline' }, @@ -181,19 +193,19 @@ const Link = define('Link', { const Button = define('Button', { base: 'button', padding: '6px 12px', - background: '#1f1f1f', - border: '1px solid #333', - borderRadius: 6, - color: '#e5e5e5', + background: theme('colors-bgElement'), + border: `1px solid ${theme('colors-border')}`, + borderRadius: theme('radius-md'), + color: theme('colors-text'), fontSize: 13, cursor: 'pointer', selectors: { - '&:hover': { background: '#2a2a2a' }, + '&:hover': { background: theme('colors-bgHover') }, }, variants: { variant: { - danger: { borderColor: '#7f1d1d', color: '#fca5a5' }, - primary: { background: '#1d4ed8', borderColor: '#1d4ed8' }, + danger: { borderColor: theme('colors-dangerBorder'), color: theme('colors-dangerText') }, + primary: { background: theme('colors-primary'), borderColor: theme('colors-primary'), color: theme('colors-primaryText') }, }, }, }) @@ -203,7 +215,7 @@ const ActionBar = define('ActionBar', { gap: 8, marginTop: 24, paddingTop: 24, - borderTop: '1px solid #333', + borderTop: `1px solid ${theme('colors-border')}`, }) const EmptyState = define('EmptyState', { @@ -211,17 +223,17 @@ const EmptyState = define('EmptyState', { alignItems: 'center', justifyContent: 'center', height: '100%', - color: '#666', + color: theme('colors-textFaint'), fontSize: 14, }) const LogsContainer = define('LogsContainer', { - background: '#111', - borderRadius: 6, + background: theme('colors-bgSubtle'), + borderRadius: theme('radius-md'), padding: 12, - fontFamily: 'ui-monospace, monospace', + fontFamily: theme('fonts-mono'), fontSize: 12, - color: '#888', + color: theme('colors-textMuted'), maxHeight: 200, overflow: 'auto', }) @@ -234,7 +246,7 @@ const LogLine = define('LogLine', { }) const LogTime = define('LogTime', { - color: '#555', + color: theme('colors-textFaintest'), marginRight: 12, }) @@ -253,13 +265,20 @@ const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method const selectApp = (name: string) => { selectedApp = name + localStorage.setItem('selectedApp', name) render() } const AppDetail = ({ app }: { app: App }) => ( <> - {app.name} + + {app.state === 'running' && app.icon ? <>{app.icon} : ( + + )} +   + {app.name} + @@ -292,11 +311,11 @@ const AppDetail = ({ app }: { app: App }) => ( {new Date(app.started).toLocaleString()} )} - {app.state === 'invalid' && ( + {app.error && ( Error - - Missing or invalid package.json + + {app.error} )} @@ -315,7 +334,7 @@ const AppDetail = ({ app }: { app: App }) => ( ) : ( --:--:-- - No logs yet + No logs yet )} @@ -359,7 +378,11 @@ const Dashboard = () => { onClick={() => selectApp(app.name)} selected={app.name === selectedApp ? true : undefined} > - + {app.state === 'running' && app.icon ? ( + {app.icon} + ) : ( + + )} {app.name} ))} @@ -383,10 +406,26 @@ const render = () => { renderApp(, document.getElementById('app')!) } +// Set theme based on system preference +const setTheme = () => { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light') +} + +// Listen for system theme changes +window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + setTheme() + render() +}) + +// Set initial theme +setTheme() + // SSE connection const events = new EventSource('/api/apps/stream') events.onmessage = e => { apps = JSON.parse(e.data) - if (!selectedApp && apps.length) selectedApp = apps[0]!.name + const valid = selectedApp && apps.some(a => a.name === selectedApp) + if (!valid && apps.length) selectedApp = apps[0]!.name render() } diff --git a/src/client/themes/dark.ts b/src/client/themes/dark.ts new file mode 100644 index 0000000..87ac8fa --- /dev/null +++ b/src/client/themes/dark.ts @@ -0,0 +1,38 @@ +export default { + 'colors-bg': '#0a0a0a', + 'colors-bgSubtle': '#111', + 'colors-bgElement': '#1f1f1f', + 'colors-bgHover': '#2a2a2a', + 'colors-bgSelected': '#1f1f1f', + + 'colors-text': '#e5e5e5', + 'colors-textMuted': '#999', + 'colors-textFaint': '#666', + 'colors-textFaintest': '#555', + + 'colors-border': '#333', + 'colors-link': '#22d3ee', + + 'colors-primary': '#1d4ed8', + 'colors-primaryText': '#e5e5e5', + + 'colors-dangerBorder': '#7f1d1d', + 'colors-dangerText': '#fca5a5', + 'colors-error': '#f87171', + + 'colors-statusRunning': '#22c55e', + 'colors-statusStopped': '#666', + 'colors-statusStarting': '#eab308', + 'colors-statusInvalid': '#ef4444', + + 'fonts-sans': 'system-ui, -apple-system, sans-serif', + 'fonts-mono': 'ui-monospace, monospace', + + 'spacing-xs': '4px', + 'spacing-sm': '8px', + 'spacing-md': '12px', + 'spacing-lg': '16px', + 'spacing-xl': '24px', + + 'radius-md': '6px', +} as const diff --git a/src/client/themes/index.ts b/src/client/themes/index.ts new file mode 100644 index 0000000..555ded5 --- /dev/null +++ b/src/client/themes/index.ts @@ -0,0 +1,8 @@ +import { createThemes } from 'forge' +import dark from './dark' +import light from './light' + +export const theme = createThemes({ + dark, + light, +}) diff --git a/src/client/themes/light.ts b/src/client/themes/light.ts new file mode 100644 index 0000000..fb5c9a1 --- /dev/null +++ b/src/client/themes/light.ts @@ -0,0 +1,38 @@ +export default { + 'colors-bg': '#f9fafb', + 'colors-bgSubtle': '#f3f4f6', + 'colors-bgElement': '#e5e7eb', + 'colors-bgHover': '#d1d5db', + 'colors-bgSelected': '#dbeafe', + + 'colors-text': '#111827', + 'colors-textMuted': '#6b7280', + 'colors-textFaint': '#9ca3af', + 'colors-textFaintest': '#9ca3af', + + 'colors-border': '#d1d5db', + 'colors-link': '#0891b2', + + 'colors-primary': '#2563eb', + 'colors-primaryText': '#fff', + + 'colors-dangerBorder': '#fecaca', + 'colors-dangerText': '#dc2626', + 'colors-error': '#dc2626', + + 'colors-statusRunning': '#16a34a', + 'colors-statusStopped': '#9ca3af', + 'colors-statusStarting': '#ca8a04', + 'colors-statusInvalid': '#dc2626', + + 'fonts-sans': 'system-ui, -apple-system, sans-serif', + 'fonts-mono': 'ui-monospace, monospace', + + 'spacing-xs': '4px', + 'spacing-sm': '8px', + 'spacing-md': '12px', + 'spacing-lg': '16px', + 'spacing-xl': '24px', + + 'radius-md': '6px', +} as const diff --git a/src/server/apps.ts b/src/server/apps.ts index 0aaa575..a650b07 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -48,8 +48,10 @@ const getPort = () => NEXT_PORT++ /** Discover all apps and set initial states */ const discoverApps = () => { for (const dir of allAppDirs()) { - const state: AppState = isApp(dir) ? 'stopped' : 'invalid' - _apps.set(dir, { name: dir, state }) + const { pkg, error } = loadApp(dir) + const state: AppState = error ? 'invalid' : 'stopped' + const icon = pkg.toes?.icon + _apps.set(dir, { name: dir, state, icon, error }) } } @@ -61,10 +63,9 @@ export const runApps = () => { } } -const isApp = (dir: string): boolean => - Object.values(loadApp(dir)).length > 0 +type LoadResult = { pkg: any; error?: string } -const loadApp = (dir: string) => { +const loadApp = (dir: string): LoadResult => { try { const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8') @@ -72,24 +73,30 @@ const loadApp = (dir: string) => { const json = JSON.parse(file) if (json.scripts?.toes) { - return json + return { pkg: json } } else { - err(dir, 'No `bun toes` script in package.json') - return {} + const error = 'Missing scripts.toes in package.json' + err(dir, error) + return { pkg: json, error } } } catch (e) { - err(dir, 'Invalid JSON in package.json:', e instanceof Error ? e.message : String(e)) - return {} + const error = `Invalid JSON in package.json: ${e instanceof Error ? e.message : String(e)}` + err(dir, error) + return { pkg: {}, error } } } catch (e) { - err(dir, 'No package.json') - return {} + const error = 'Missing package.json' + err(dir, error) + return { pkg: {}, error } } } +const isApp = (dir: string): boolean => + !loadApp(dir).error + const runApp = async (dir: string, port: number) => { - const pkg = loadApp(dir) - if (!pkg.scripts?.toes) return + const { pkg, error } = loadApp(dir) + if (error) return const app = _apps.get(dir) if (!app) return @@ -129,13 +136,14 @@ const runApp = async (dir: string, port: number) => { while (true) { const { done, value } = await reader.read() if (done) break - const text = decoder.decode(value).trimEnd() - if (text) { + const chunk = decoder.decode(value) + const lines = chunk.split('\n').map(l => l.trimEnd()).filter(Boolean) + for (const text of lines) { log(dir, text) const line: LogLine = { time: Date.now(), text } app.logs = [...(app.logs ?? []).slice(-(MAX_LOGS - 1)), line] - update() } + if (lines.length) update() } } @@ -194,10 +202,12 @@ const watchAppsDir = () => { // Handle new directory appearing if (!_apps.has(dir)) { - const state: AppState = isApp(dir) ? 'stopped' : 'invalid' - _apps.set(dir, { name: dir, state }) + const { pkg, error } = loadApp(dir) + const state: AppState = error ? 'invalid' : 'stopped' + const icon = pkg.toes?.icon + _apps.set(dir, { name: dir, state, icon, error }) update() - if (state === 'stopped') { + if (!error) { runApp(dir, getPort()) } return @@ -208,26 +218,30 @@ const watchAppsDir = () => { // Only care about package.json changes for existing apps if (!filename.endsWith('package.json')) return - const valid = isApp(dir) + const { pkg, error } = loadApp(dir) + + // Update icon and error from package.json + app.icon = pkg.toes?.icon + app.error = error // App became valid - start it if stopped - if (valid && app.state === 'invalid') { + if (!error && app.state === 'invalid') { app.state = 'stopped' runApp(dir, getPort()) } // App became invalid - stop it if running - if (!valid && app.state === 'running') { + if (error && app.state === 'running') { app.state = 'invalid' app.proc?.kill() } // Update state if already stopped/invalid - if (!valid && app.state === 'stopped') { + if (error && app.state === 'stopped') { app.state = 'invalid' update() } - if (valid && app.state === 'invalid') { + if (!error && app.state === 'invalid') { app.state = 'stopped' update() } diff --git a/src/server/index.tsx b/src/server/index.tsx index 4af416a..032eb32 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -15,10 +15,11 @@ app.get('/api/apps/stream', c => { start(controller) { const send = () => { // Strip proc field from apps before sending - const apps: SharedApp[] = allApps().map(({ name, state, icon, port, started, logs }) => ({ + const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({ name, state, icon, + error, port, started, logs, diff --git a/src/shared/types.ts b/src/shared/types.ts index c9f54a0..e51cb23 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -9,6 +9,7 @@ export type App = { name: string state: AppState icon?: string + error?: string port?: number started?: number logs?: LogLine[]