toes/apps/test123/20260227-072646/CLAUDE.md

8.8 KiB

Toes - Guide to Writing Apps

Toes manages and runs web apps, each on its own port.

Apps are server-rendered TypeScript using Hype (wraps Hono) and Forge (CSS-in-JS).

Runtime is Bun.

Required Components

Every toes app/tool must have:

  1. .npmrc pointing to registry=https://npm.nose.space (the private registry for @because/* packages)
  2. package.json with a scripts.toes entry (this is how toes discovers and runs apps)
  3. HTTP GET /ok returning 200 (health check endpoint — toes polls this every 30s and restarts unresponsive apps)

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:

  1. <ToolScript /> in <body> (handles dark mode + iframe height communication)
  2. baseStyles prepended to CSS output
  3. 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() {}.