diff --git a/apps/clock/20260130-000000/TODO.txt b/apps/clock/20260130-000000/TODO.txt
new file mode 100644
index 0000000..50e9c39
--- /dev/null
+++ b/apps/clock/20260130-000000/TODO.txt
@@ -0,0 +1,3 @@
+# clock TODO
+[x] do something
+[ ] do more things
diff --git a/apps/code/20260130-000000/src/server/index.tsx b/apps/code/20260130-000000/src/server/index.tsx
index 4b2d3d7..edafa96 100644
--- a/apps/code/20260130-000000/src/server/index.tsx
+++ b/apps/code/20260130-000000/src/server/index.tsx
@@ -118,12 +118,11 @@ const CodeBlock = define('CodeBlock', {
margin: '20px 0',
border: `1px solid ${theme('border')}`,
borderRadius: '4px',
- overflow: 'auto',
+ overflowX: 'auto',
selectors: {
'& pre': {
margin: 0,
padding: '15px',
- overflow: 'auto',
whiteSpace: 'pre',
backgroundColor: theme('codeBg'),
},
@@ -185,9 +184,17 @@ const initScript = `
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
});
function sendHeight() {
- window.parent.postMessage({ type: 'resize-iframe', height: document.documentElement.scrollHeight }, '*');
+ // Measure the actual content container (skip script tags)
+ var container = document.querySelector('.Container');
+ if (!container) return;
+ var rect = container.getBoundingClientRect();
+ var height = rect.bottom + 20; // Add some padding
+ window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
}
sendHeight();
+ setTimeout(sendHeight, 50);
+ setTimeout(sendHeight, 200);
+ setTimeout(sendHeight, 500);
new ResizeObserver(sendHeight).observe(document.body);
window.addEventListener('load', sendHeight);
})();
@@ -280,6 +287,7 @@ function getLanguage(filename: string): string {
app.get('/', async c => {
const appName = c.req.query('app')
+ const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') || ''
if (!appName) {
@@ -290,14 +298,15 @@ app.get('/', async c => {
)
}
- const appPath = join(APPS_DIR, appName, 'current')
+ const appPath = join(APPS_DIR, appName, version)
+ const versionSuffix = version !== 'current' ? ` @ ${version}` : ''
try {
await stat(appPath)
} catch {
return c.html(
- App "{appName}" not found
+ App "{appName}" (version: {version}) not found
)
}
@@ -309,21 +318,22 @@ app.get('/', async c => {
fileStats = await stat(fullPath)
} catch {
return c.html(
-
+
Path "{filePath}" not found
)
}
const parentPath = filePath.split('/').slice(0, -1).join('/')
- const backUrl = `/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`
+ const versionParam = version !== 'current' ? `&version=${version}` : ''
+ const backUrl = `/?app=${appName}${versionParam}${parentPath ? `&file=${parentPath}` : ''}`
if (fileStats.isFile()) {
const content = readFileSync(fullPath, 'utf-8')
const language = getLanguage(basename(fullPath))
return c.html(
-
+
← Back
{basename(fullPath)}
@@ -336,12 +346,12 @@ app.get('/', async c => {
const files = await listFiles(appPath, filePath)
return c.html(
-
+
{filePath && ← Back}
{files.map(file => (
-
+
{file.isDirectory ? : }
{file.name}
diff --git a/apps/versions/20260130-000000/index.tsx b/apps/versions/20260130-000000/index.tsx
index 5173856..35d8bf6 100644
--- a/apps/versions/20260130-000000/index.tsx
+++ b/apps/versions/20260130-000000/index.tsx
@@ -96,6 +96,7 @@ const VersionLink = define('VersionLink', {
color: theme('link'),
fontFamily: 'monospace',
fontSize: '15px',
+ cursor: 'pointer',
states: {
':hover': {
textDecoration: 'underline',
@@ -230,15 +231,15 @@ app.get('/', async c => {
)
}
- // Get the code tool's port from environment or use a relative path
- const codeToolUrl = process.env.CODE_TOOL_URL || '/tool/code'
-
return c.html(
{versions.map(v => (
-
+
{formatTimestamp(v.name)}
{v.isCurrent && current}
diff --git a/src/client/index.tsx b/src/client/index.tsx
index 7a1782c..9b10094 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -1,8 +1,8 @@
import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components'
-import { apps, selectedApp, selectedTab, setApps, setSelectedApp } from './state'
+import { apps, selectedApp, selectedTab, setApps, setSelectedApp, setSelectedTab } from './state'
import { initModal } from './components/modal'
-import { initToolIframes, updateToolIframes } from './tool-iframes'
+import { initToolIframes, updateToolIframes, setNavigateToolHandler, navigateToTool } from './tool-iframes'
import { initUpdate } from './update'
const render = () => {
@@ -19,6 +19,15 @@ initModal(render)
initUpdate(render)
initToolIframes()
+// Handle tool-to-tool navigation via postMessage
+setNavigateToolHandler((tool, params) => {
+ const tools = apps.filter(a => a.tool)
+ navigateToTool(tool, params, tools, (tab) => {
+ setSelectedTab(tab)
+ render()
+ })
+})
+
// Set theme based on system preference
const setTheme = () => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
diff --git a/src/client/tool-iframes.ts b/src/client/tool-iframes.ts
index 985cdc4..96fdea3 100644
--- a/src/client/tool-iframes.ts
+++ b/src/client/tool-iframes.ts
@@ -2,7 +2,7 @@ import { theme } from './themes'
// Iframe cache - these never get recreated once loaded
// Use a global to survive hot reloads
-const iframes: Map =
+const iframes: Map =
(window as any).__toolIframes ??= new Map()
// Track current state to avoid unnecessary DOM updates
@@ -12,11 +12,51 @@ let currentTool: string | null = (window as any).__currentTool ?? null
// Get the stable container (outside Hono-managed DOM)
const getContainer = () => document.getElementById('tool-iframes')
+// Callback for tool navigation requests
+let onNavigateTool: ((tool: string, params: Record) => void) | null = null
+
+export function setNavigateToolHandler(handler: (tool: string, params: Record) => void) {
+ onNavigateTool = handler
+}
+
+// Listen for messages from iframes
+function setupMessageListener() {
+ if ((window as any).__toolIframeMessageListener) return
+ ; (window as any).__toolIframeMessageListener = true
+
+ window.addEventListener('message', (event) => {
+ const { type } = event.data || {}
+
+ if (type === 'resize-iframe') {
+ const height = event.data.height
+ if (!height || typeof height !== 'number') return
+
+ // Find which iframe sent this message
+ for (const [, cached] of iframes) {
+ if (cached.iframe.contentWindow === event.source) {
+ cached.contentHeight = height
+ cached.iframe.style.height = `100vh`
+ return
+ }
+ }
+ }
+
+ if (type === 'navigate-tool') {
+ const { tool, params } = event.data
+ if (tool && onNavigateTool) {
+ onNavigateTool(tool, params || {})
+ }
+ }
+ })
+}
+
// Initialize the container styles
export function initToolIframes() {
const container = getContainer()
if (!container) return
+ setupMessageListener()
+
// Restore iframe cache from DOM if module was hot-reloaded
if (iframes.size === 0) {
const existingIframes = container.querySelectorAll('iframe')
@@ -38,9 +78,66 @@ export function initToolIframes() {
display: none;
position: fixed;
background: ${theme('colors-bg')};
+ overflow: auto;
`
}
+// Build URL with params
+function buildToolUrl(port: number, params: Record): string {
+ const searchParams = new URLSearchParams(params)
+ const query = searchParams.toString()
+ return query ? `http://localhost:${port}?${query}` : `http://localhost:${port}`
+}
+
+// Build cache key from tool name and params
+function buildCacheKey(toolName: string, params: Record): string {
+ const parts = [toolName]
+ const sortedKeys = Object.keys(params).sort()
+ for (const key of sortedKeys) {
+ parts.push(`${key}=${params[key]}`)
+ }
+ return parts.join(':')
+}
+
+// Navigate to a tool with specific params (called from postMessage handler)
+export function navigateToTool(
+ toolName: string,
+ params: Record,
+ tools: Array<{ name: string; port?: number; state: string }>,
+ onSelectTab: (tab: string) => void
+) {
+ const tool = tools.find(t => t.name === toolName)
+ if (!tool || tool.state !== 'running' || !tool.port) return
+
+ const container = getContainer()
+ if (!container) return
+
+ const cacheKey = buildCacheKey(toolName, params)
+
+ // Create iframe if needed
+ let cached = iframes.get(cacheKey)
+ if (!cached || cached.port !== tool.port) {
+ const iframe = document.createElement('iframe')
+ iframe.src = buildToolUrl(tool.port, params)
+ iframe.dataset.toolName = toolName
+ iframe.dataset.appName = params.app ?? ''
+ iframe.style.cssText = `width: 100%; border: none;`
+ cached = { iframe, port: tool.port }
+ iframes.set(cacheKey, cached)
+ container.appendChild(iframe)
+ }
+
+ // Switch to that tab
+ onSelectTab(toolName)
+
+ // Force show this specific iframe
+ currentTool = cacheKey
+ ;(window as any).__currentTool = cacheKey
+ for (const [key, { iframe }] of iframes) {
+ iframe.style.display = key === cacheKey ? 'block' : 'none'
+ }
+}
+
// Update which iframe is visible based on selected tab and tool state
export function updateToolIframes(
selectedTab: string,
@@ -55,7 +152,6 @@ export function updateToolIframes(
const showIframe = selectedTool?.state === 'running' && selectedTool?.port
if (!showIframe) {
- // Only update if state changed
if (currentTool !== null) {
container.style.display = 'none'
currentTool = null
@@ -66,19 +162,20 @@ export function updateToolIframes(
const tool = selectedTool!
- // Build cache key for tool + app combination
- const cacheKey = selectedApp ? `${tool.name}:${selectedApp}` : tool.name
+ // Build params and cache key
+ const params: Record = {}
+ if (selectedApp) params.app = selectedApp
+ const cacheKey = buildCacheKey(tool.name, params)
// Skip if nothing changed
if (currentTool === cacheKey) {
- // Just update position in case of scroll/resize
const tabContent = document.querySelector('[data-tool-target]')
if (tabContent) {
const rect = tabContent.getBoundingClientRect()
container.style.top = `${rect.top}px`
container.style.left = `${rect.left}px`
container.style.width = `${rect.width}px`
- container.style.height = `${rect.height}px`
+ container.style.height = '100vh'
}
return
}
@@ -98,35 +195,27 @@ export function updateToolIframes(
top: ${rect.top}px;
left: ${rect.left}px;
width: ${rect.width}px;
- height: ${rect.height}px;
+ height: calc(100vh - ${rect.top}px);
background: ${theme('colors-bg')};
z-index: 100;
+ overflow: auto;
`
- // Get or create the iframe for this tool + app combination
+ // Get or create the iframe
let cached = iframes.get(cacheKey)
if (!cached || cached.port !== tool.port) {
- // Create new iframe (first time or port changed)
const iframe = document.createElement('iframe')
- const url = selectedApp
- ? `http://localhost:${tool.port}?app=${encodeURIComponent(selectedApp)}`
- : `http://localhost:${tool.port}`
- iframe.src = url
- iframe.dataset.toolName = tool.name // For hot reload recovery
- iframe.dataset.appName = selectedApp ?? '' // For hot reload recovery
- iframe.style.cssText = `
- width: 100%;
- height: 100%;
- border: none;
- `
+ iframe.src = buildToolUrl(tool.port!, params)
+ iframe.dataset.toolName = tool.name
+ iframe.dataset.appName = selectedApp ?? ''
+ iframe.style.cssText = `width: 100%; border: none;`
cached = { iframe, port: tool.port! }
iframes.set(cacheKey, cached)
- // Add to container
container.appendChild(iframe)
}
- // Show only the selected iframe, hide others
+ // Show only the selected iframe
for (const [key, { iframe }] of iframes) {
const shouldShow = key === cacheKey
if (shouldShow && iframe.parentElement !== container) {
diff --git a/src/server/apps.ts b/src/server/apps.ts
index 3f77a20..97618a2 100644
--- a/src/server/apps.ts
+++ b/src/server/apps.ts
@@ -640,10 +640,15 @@ function watchAppsDir() {
const { pkg, error } = loadApp(dir)
// Update icon, tool, and error from package.json
+ const iconChanged = app.icon !== pkg.toes?.icon
+ const toolChanged = app.tool !== pkg.toes?.tool
app.icon = pkg.toes?.icon
app.tool = pkg.toes?.tool
app.error = error
+ // Broadcast if icon or tool changed
+ if (iconChanged || toolChanged) update()
+
// App became valid - start it if stopped
if (!error && app.state === 'invalid') {
app.state = 'stopped'