themes + emoji

This commit is contained in:
Chris Wanstrath 2026-01-27 22:27:08 -08:00
parent 15775bc022
commit 06bcfc5f35
8 changed files with 216 additions and 77 deletions

View File

@ -27,7 +27,7 @@
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], "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=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],

View File

@ -1,9 +1,10 @@
import { render as renderApp } from 'hono/jsx/dom' import { render as renderApp } from 'hono/jsx/dom'
import { define, Styles } from 'forge' import { define, Styles } from 'forge'
import type { App, AppState } from '../shared/types' import type { App, AppState } from '../shared/types'
import { theme } from './themes'
// UI state (survives re-renders) // UI state (survives re-renders)
let selectedApp: string | null = null let selectedApp: string | null = localStorage.getItem('selectedApp')
// Server state (from SSE) // Server state (from SSE)
let apps: App[] = [] let apps: App[] = []
@ -12,31 +13,34 @@ let apps: App[] = []
const Layout = define('Layout', { const Layout = define('Layout', {
display: 'flex', display: 'flex',
height: '100vh', height: '100vh',
fontFamily: 'system-ui, -apple-system, sans-serif', fontFamily: theme('fonts-sans'),
background: '#0a0a0a', background: theme('colors-bg'),
color: '#e5e5e5', color: theme('colors-text'),
}) })
const Sidebar = define('Sidebar', { const Sidebar = define('Sidebar', {
width: 220, width: 220,
borderRight: '1px solid #333', borderRight: `1px solid ${theme('colors-border')}`,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
flexShrink: 0, flexShrink: 0,
}) })
const Logo = define('Logo', { const Logo = define('Logo', {
padding: '20px 16px', height: 64,
display: 'flex',
alignItems: 'center',
padding: '0 16px',
fontSize: 20, fontSize: 20,
fontWeight: 'bold', fontWeight: 'bold',
borderBottom: '1px solid #333', borderBottom: `1px solid ${theme('colors-border')}`,
}) })
const SectionLabel = define('SectionLabel', { const SectionLabel = define('SectionLabel', {
padding: '16px 16px 8px', padding: '16px 16px 8px',
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
color: '#666', color: theme('colors-textFaint'),
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.05em', letterSpacing: '0.05em',
}) })
@ -48,18 +52,19 @@ const AppList = define('AppList', {
const AppItem = define('AppItem', { const AppItem = define('AppItem', {
display: 'flex', display: 'flex',
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
padding: '8px 16px', padding: '8px 16px',
color: '#999', color: theme('colors-textMuted'),
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, fontSize: 14,
cursor: 'pointer', cursor: 'pointer',
selectors: { selectors: {
'&:hover': { background: '#1a1a1a', color: '#e5e5e5' }, '&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
}, },
variants: { 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, flexShrink: 0,
variants: { variants: {
state: { state: {
invalid: { background: '#ef4444' }, invalid: { background: theme('colors-statusInvalid') },
stopped: { background: '#666' }, stopped: { background: theme('colors-statusStopped') },
starting: { background: '#eab308' }, starting: { background: theme('colors-statusStarting') },
running: { background: '#22c55e' }, running: { background: theme('colors-statusRunning') },
stopping: { background: '#eab308' }, stopping: { background: theme('colors-statusStarting') },
}, },
inline: {
display: 'inline'
}
}, },
}) })
const SidebarFooter = define('SidebarFooter', { const SidebarFooter = define('SidebarFooter', {
padding: 16, padding: 16,
borderTop: '1px solid #333', borderTop: `1px solid ${theme('colors-border')}`,
}) })
const NewAppButton = define('NewAppButton', { const NewAppButton = define('NewAppButton', {
display: 'block', display: 'block',
padding: '8px 12px', padding: '8px 12px',
background: '#1f1f1f', background: theme('colors-bgElement'),
border: '1px solid #333', border: `1px solid ${theme('colors-border')}`,
borderRadius: 6, borderRadius: theme('radius-md'),
color: '#999', color: theme('colors-textMuted'),
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, fontSize: 14,
textAlign: 'center', textAlign: 'center',
cursor: 'pointer', cursor: 'pointer',
selectors: { 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', { const MainHeader = define('MainHeader', {
height: 64,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '16px 24px', padding: '0 24px',
borderBottom: '1px solid #333', borderBottom: `1px solid ${theme('colors-border')}`,
}) })
const MainTitle = define('MainTitle', { const MainTitle = define('MainTitle', {
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 20, fontSize: 20,
fontWeight: 600, fontWeight: 600,
margin: 0, margin: 0,
@ -140,12 +152,12 @@ const Section = define('Section', {
const SectionTitle = define('SectionTitle', { const SectionTitle = define('SectionTitle', {
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
color: '#666', color: theme('colors-textFaint'),
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.05em', letterSpacing: '0.05em',
marginBottom: 12, marginBottom: 12,
paddingBottom: 8, paddingBottom: 8,
borderBottom: '1px solid #333', borderBottom: `1px solid ${theme('colors-border')}`,
}) })
const InfoRow = define('InfoRow', { const InfoRow = define('InfoRow', {
@ -157,13 +169,13 @@ const InfoRow = define('InfoRow', {
}) })
const InfoLabel = define('InfoLabel', { const InfoLabel = define('InfoLabel', {
color: '#666', color: theme('colors-textFaint'),
width: 80, width: 80,
flexShrink: 0, flexShrink: 0,
}) })
const InfoValue = define('InfoValue', { const InfoValue = define('InfoValue', {
color: '#e5e5e5', color: theme('colors-text'),
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
@ -171,7 +183,7 @@ const InfoValue = define('InfoValue', {
const Link = define('Link', { const Link = define('Link', {
base: 'a', base: 'a',
color: '#22d3ee', color: theme('colors-link'),
textDecoration: 'none', textDecoration: 'none',
selectors: { selectors: {
'&:hover': { textDecoration: 'underline' }, '&:hover': { textDecoration: 'underline' },
@ -181,19 +193,19 @@ const Link = define('Link', {
const Button = define('Button', { const Button = define('Button', {
base: 'button', base: 'button',
padding: '6px 12px', padding: '6px 12px',
background: '#1f1f1f', background: theme('colors-bgElement'),
border: '1px solid #333', border: `1px solid ${theme('colors-border')}`,
borderRadius: 6, borderRadius: theme('radius-md'),
color: '#e5e5e5', color: theme('colors-text'),
fontSize: 13, fontSize: 13,
cursor: 'pointer', cursor: 'pointer',
selectors: { selectors: {
'&:hover': { background: '#2a2a2a' }, '&:hover': { background: theme('colors-bgHover') },
}, },
variants: { variants: {
variant: { variant: {
danger: { borderColor: '#7f1d1d', color: '#fca5a5' }, danger: { borderColor: theme('colors-dangerBorder'), color: theme('colors-dangerText') },
primary: { background: '#1d4ed8', borderColor: '#1d4ed8' }, primary: { background: theme('colors-primary'), borderColor: theme('colors-primary'), color: theme('colors-primaryText') },
}, },
}, },
}) })
@ -203,7 +215,7 @@ const ActionBar = define('ActionBar', {
gap: 8, gap: 8,
marginTop: 24, marginTop: 24,
paddingTop: 24, paddingTop: 24,
borderTop: '1px solid #333', borderTop: `1px solid ${theme('colors-border')}`,
}) })
const EmptyState = define('EmptyState', { const EmptyState = define('EmptyState', {
@ -211,17 +223,17 @@ const EmptyState = define('EmptyState', {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: '100%', height: '100%',
color: '#666', color: theme('colors-textFaint'),
fontSize: 14, fontSize: 14,
}) })
const LogsContainer = define('LogsContainer', { const LogsContainer = define('LogsContainer', {
background: '#111', background: theme('colors-bgSubtle'),
borderRadius: 6, borderRadius: theme('radius-md'),
padding: 12, padding: 12,
fontFamily: 'ui-monospace, monospace', fontFamily: theme('fonts-mono'),
fontSize: 12, fontSize: 12,
color: '#888', color: theme('colors-textMuted'),
maxHeight: 200, maxHeight: 200,
overflow: 'auto', overflow: 'auto',
}) })
@ -234,7 +246,7 @@ const LogLine = define('LogLine', {
}) })
const LogTime = define('LogTime', { const LogTime = define('LogTime', {
color: '#555', color: theme('colors-textFaintest'),
marginRight: 12, marginRight: 12,
}) })
@ -253,13 +265,20 @@ const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method
const selectApp = (name: string) => { const selectApp = (name: string) => {
selectedApp = name selectedApp = name
localStorage.setItem('selectedApp', name)
render() render()
} }
const AppDetail = ({ app }: { app: App }) => ( const AppDetail = ({ app }: { app: App }) => (
<> <>
<MainHeader> <MainHeader>
<MainTitle>{app.name}</MainTitle> <MainTitle>
{app.state === 'running' && app.icon ? <>{app.icon}</> : (
<StatusDot state={app.state} />
)}
&nbsp;
{app.name}
</MainTitle>
<HeaderActions> <HeaderActions>
<Button>Settings</Button> <Button>Settings</Button>
<Button variant="danger">Delete</Button> <Button variant="danger">Delete</Button>
@ -292,11 +311,11 @@ const AppDetail = ({ app }: { app: App }) => (
<InfoValue>{new Date(app.started).toLocaleString()}</InfoValue> <InfoValue>{new Date(app.started).toLocaleString()}</InfoValue>
</InfoRow> </InfoRow>
)} )}
{app.state === 'invalid' && ( {app.error && (
<InfoRow> <InfoRow>
<InfoLabel>Error</InfoLabel> <InfoLabel>Error</InfoLabel>
<InfoValue style={{ color: '#f87171' }}> <InfoValue style={{ color: theme('colors-error') }}>
Missing or invalid package.json {app.error}
</InfoValue> </InfoValue>
</InfoRow> </InfoRow>
)} )}
@ -315,7 +334,7 @@ const AppDetail = ({ app }: { app: App }) => (
) : ( ) : (
<LogLine> <LogLine>
<LogTime>--:--:--</LogTime> <LogTime>--:--:--</LogTime>
<span style={{ color: '#666' }}>No logs yet</span> <span style={{ color: theme('colors-textFaint') }}>No logs yet</span>
</LogLine> </LogLine>
)} )}
</LogsContainer> </LogsContainer>
@ -359,7 +378,11 @@ const Dashboard = () => {
onClick={() => selectApp(app.name)} onClick={() => selectApp(app.name)}
selected={app.name === selectedApp ? true : undefined} selected={app.name === selectedApp ? true : undefined}
> >
<StatusDot state={app.state} /> {app.state === 'running' && app.icon ? (
<span style={{ fontSize: 14 }}>{app.icon}</span>
) : (
<StatusDot state={app.state} />
)}
{app.name} {app.name}
</AppItem> </AppItem>
))} ))}
@ -383,10 +406,26 @@ const render = () => {
renderApp(<Dashboard />, document.getElementById('app')!) renderApp(<Dashboard />, 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 // SSE connection
const events = new EventSource('/api/apps/stream') const events = new EventSource('/api/apps/stream')
events.onmessage = e => { events.onmessage = e => {
apps = JSON.parse(e.data) 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() render()
} }

38
src/client/themes/dark.ts Normal file
View File

@ -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

View File

@ -0,0 +1,8 @@
import { createThemes } from 'forge'
import dark from './dark'
import light from './light'
export const theme = createThemes({
dark,
light,
})

View File

@ -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

View File

@ -48,8 +48,10 @@ const getPort = () => NEXT_PORT++
/** Discover all apps and set initial states */ /** Discover all apps and set initial states */
const discoverApps = () => { const discoverApps = () => {
for (const dir of allAppDirs()) { for (const dir of allAppDirs()) {
const state: AppState = isApp(dir) ? 'stopped' : 'invalid' const { pkg, error } = loadApp(dir)
_apps.set(dir, { name: dir, state }) 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 => type LoadResult = { pkg: any; error?: string }
Object.values(loadApp(dir)).length > 0
const loadApp = (dir: string) => { const loadApp = (dir: string): LoadResult => {
try { try {
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8') const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
@ -72,24 +73,30 @@ const loadApp = (dir: string) => {
const json = JSON.parse(file) const json = JSON.parse(file)
if (json.scripts?.toes) { if (json.scripts?.toes) {
return json return { pkg: json }
} else { } else {
err(dir, 'No `bun toes` script in package.json') const error = 'Missing scripts.toes in package.json'
return {} err(dir, error)
return { pkg: json, error }
} }
} catch (e) { } catch (e) {
err(dir, 'Invalid JSON in package.json:', e instanceof Error ? e.message : String(e)) const error = `Invalid JSON in package.json: ${e instanceof Error ? e.message : String(e)}`
return {} err(dir, error)
return { pkg: {}, error }
} }
} catch (e) { } catch (e) {
err(dir, 'No package.json') const error = 'Missing package.json'
return {} err(dir, error)
return { pkg: {}, error }
} }
} }
const isApp = (dir: string): boolean =>
!loadApp(dir).error
const runApp = async (dir: string, port: number) => { const runApp = async (dir: string, port: number) => {
const pkg = loadApp(dir) const { pkg, error } = loadApp(dir)
if (!pkg.scripts?.toes) return if (error) return
const app = _apps.get(dir) const app = _apps.get(dir)
if (!app) return if (!app) return
@ -129,13 +136,14 @@ const runApp = async (dir: string, port: number) => {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
const text = decoder.decode(value).trimEnd() const chunk = decoder.decode(value)
if (text) { const lines = chunk.split('\n').map(l => l.trimEnd()).filter(Boolean)
for (const text of lines) {
log(dir, text) log(dir, text)
const line: LogLine = { time: Date.now(), text } const line: LogLine = { time: Date.now(), text }
app.logs = [...(app.logs ?? []).slice(-(MAX_LOGS - 1)), line] app.logs = [...(app.logs ?? []).slice(-(MAX_LOGS - 1)), line]
update()
} }
if (lines.length) update()
} }
} }
@ -194,10 +202,12 @@ const watchAppsDir = () => {
// Handle new directory appearing // Handle new directory appearing
if (!_apps.has(dir)) { if (!_apps.has(dir)) {
const state: AppState = isApp(dir) ? 'stopped' : 'invalid' const { pkg, error } = loadApp(dir)
_apps.set(dir, { name: dir, state }) const state: AppState = error ? 'invalid' : 'stopped'
const icon = pkg.toes?.icon
_apps.set(dir, { name: dir, state, icon, error })
update() update()
if (state === 'stopped') { if (!error) {
runApp(dir, getPort()) runApp(dir, getPort())
} }
return return
@ -208,26 +218,30 @@ const watchAppsDir = () => {
// Only care about package.json changes for existing apps // Only care about package.json changes for existing apps
if (!filename.endsWith('package.json')) return 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 // App became valid - start it if stopped
if (valid && app.state === 'invalid') { if (!error && app.state === 'invalid') {
app.state = 'stopped' app.state = 'stopped'
runApp(dir, getPort()) runApp(dir, getPort())
} }
// App became invalid - stop it if running // App became invalid - stop it if running
if (!valid && app.state === 'running') { if (error && app.state === 'running') {
app.state = 'invalid' app.state = 'invalid'
app.proc?.kill() app.proc?.kill()
} }
// Update state if already stopped/invalid // Update state if already stopped/invalid
if (!valid && app.state === 'stopped') { if (error && app.state === 'stopped') {
app.state = 'invalid' app.state = 'invalid'
update() update()
} }
if (valid && app.state === 'invalid') { if (!error && app.state === 'invalid') {
app.state = 'stopped' app.state = 'stopped'
update() update()
} }

View File

@ -15,10 +15,11 @@ app.get('/api/apps/stream', c => {
start(controller) { start(controller) {
const send = () => { const send = () => {
// Strip proc field from apps before sending // 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, name,
state, state,
icon, icon,
error,
port, port,
started, started,
logs, logs,

View File

@ -9,6 +9,7 @@ export type App = {
name: string name: string
state: AppState state: AppState
icon?: string icon?: string
error?: string
port?: number port?: number
started?: number started?: number
logs?: LogLine[] logs?: LogLine[]