8.4 KiB
Toes - Guide to Writing Apps
Toes discovers and runs web apps from an apps/ directory, each on its own port. Apps are server-rendered TypeScript using Hype (wraps Hono) and Forge (CSS-in-JS). Runtime is Bun.
App vs Tool
An app shows in the sidebar and opens in its own browser tab.
A tool renders as a tab inside the dashboard (in an iframe). It receives ?app=<name> to know the selected app. The only code difference is "toes": { "tool": true } in package.json and some extra imports from @because/toes.
Required Files
Every app needs .npmrc, tsconfig.json, package.json, and index.tsx.
.npmrc -- always this exact content:
registry=https://npm.nose.space
tsconfig.json -- use exactly, do not improvise:
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}
package.json for an app:
{
"name": "my-app",
"private": true,
"module": "index.tsx",
"type": "module",
"scripts": { "toes": "bun run --watch index.tsx" },
"toes": { "icon": "🖥️" },
"dependencies": {
"@because/forge": "*",
"@because/hype": "*"
},
"devDependencies": { "@types/bun": "latest" }
}
For a tool, add @because/toes to dependencies and set "tool": true (or a string for a custom tab label like "tool": ".env"):
{
"toes": { "icon": "🔧", "tool": true },
"dependencies": {
"@because/forge": "*",
"@because/hype": "*",
"@because/toes": "*"
}
}
Hype
Hype wraps Hono. It adds app.defaults (the Bun server export), app.sse() for server-sent events, and Hype.router() for sub-routers. Everything else is standard Hono.
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.html(<h1>Hello</h1>))
app.get('/ok', c => c.text('ok')) // Health check -- required
export default app.defaults
Constructor options: prettyHTML (default true, tools should set false), layout (default true), logging (default true).
SSE -- the one non-Hono addition:
app.sse('/stream', (send, c) => {
send({ hello: 'world' })
const interval = setInterval(() => send({ time: Date.now() }), 1000)
return () => clearInterval(interval) // cleanup on disconnect
})
Sub-routers:
const api = Hype.router()
api.get('/items', c => c.json([]))
app.route('/api', api) // mounts at /api/items
Forge
Forge creates styled JSX components via define(). Properties use camelCase CSS. Numbers auto-convert to px (except flex, opacity, zIndex, fontWeight).
import { define, stylesToCSS } from '@because/forge'
const Box = define('Box', {
padding: 20,
borderRadius: '6px',
})
// <Box>content</Box> renders <div class="Box">content</div>
base -- set the HTML element (default div):
const Button = define('Button', { base: 'button', padding: '8px 16px' })
const Link = define('Link', { base: 'a', textDecoration: 'none' })
states -- pseudo-classes:
const Item = define('Item', {
padding: 12,
states: {
':hover': { backgroundColor: '#eee' },
':last-child': { borderBottom: 'none' },
},
})
selectors -- nested CSS (& = the component):
const List = define('List', {
selectors: {
'& > li:last-child': { borderBottom: 'none' },
},
})
variants -- conditional styles via props:
const Button = define('Button', {
base: 'button',
variants: {
variant: {
primary: { backgroundColor: '#2563eb', color: 'white' },
danger: { backgroundColor: '#dc2626', color: 'white' },
},
},
})
// <Button variant="primary">Save</Button>
Serving CSS -- apps serve stylesToCSS() from a route. Tools prepend baseStyles:
app.get('/styles.css', c =>
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
)
Theme Tokens
Tools import theme from @because/toes/tools. It returns CSS variables that resolve per light/dark mode.
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
const Container = define('Container', {
color: theme('colors-text'),
border: `1px solid ${theme('colors-border')}`,
})
Available tokens:
| Token | Use for |
|---|---|
colors-bg, colors-bgSubtle, colors-bgElement, colors-bgHover |
Backgrounds |
colors-text, colors-textMuted, colors-textFaint |
Text |
colors-border |
Borders |
colors-link |
Links |
colors-primary, colors-primaryText |
Primary actions |
colors-error, colors-dangerBorder, colors-dangerText |
Errors/danger |
colors-success, colors-successBg |
Success states |
colors-statusRunning, colors-statusStopped |
Status indicators |
fonts-sans, fonts-mono |
Font stacks |
spacing-xs (4), spacing-sm (8), spacing-md (12), spacing-lg (16), spacing-xl (24) |
Spacing (px) |
radius-md (6px) |
Border radius |
Writing a Tool
Tools need three extra things vs apps:
<ToolScript />in<body>(handles dark mode + iframe height communication)baseStylesprepended to CSS output- Handle the
?app=query param
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false })
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
function Layout({ title, children }: { title: string; children: Child }) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>{children}</Container>
</body>
</html>
)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c =>
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
)
app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) return c.html(<Layout title="My Tool"><p>No app selected</p></Layout>)
// ... tool logic using join(APPS_DIR, appName, 'current') for file paths
return c.html(<Layout title="My Tool">...</Layout>)
})
export default app.defaults
Environment variables available to tools: APPS_DIR, TOES_URL (base URL of Toes server), PORT, TOES_DIR.
Accessing app files: always use join(APPS_DIR, appName, 'current').
Calling the Toes API: fetch(\${TOES_URL}/api/apps`), fetch(`${TOES_URL}/api/apps/${name}`)`.
Linking between tools: <a href={\${TOES_URL}/tool/code?app=${appName}`}>View Code`.
Patterns
Fire-and-forget with polling -- for long-running ops, don't await in POST. Use <meta http-equiv="refresh" content="2" /> to poll while running.
Inline client JS -- use <script dangerouslySetInnerHTML={{ __html: script }} />.
Data persistence -- use filesystem. DATA_DIR env var points to a per-app data directory.
Cron Jobs
Place .ts files in an app's cron/ directory:
export const schedule = "day"
export default async function() {
console.log("Running at", new Date().toISOString())
}
Valid schedules: "1 minute", "5 minutes", "15 minutes", "30 minutes", "hour", "noon", "midnight", "day", "week", "sunday" through "saturday".
Coding Guidelines
TS file organization order: imports, re-exports, const/lets, enums, interfaces, types, classes, functions, module init. Within each section, exports first (alphabetical), then non-exports (alphabetical).
Single-line functions: const fn = () => {}. Multi-line: function name() {}.