diff --git a/client/.eslintignore b/client/.eslintignore
new file mode 100644
index 0000000..9efa7eb
--- /dev/null
+++ b/client/.eslintignore
@@ -0,0 +1 @@
+module: "eslint"
\ No newline at end of file
diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs
new file mode 100644
index 0000000..a89abc0
--- /dev/null
+++ b/client/eslint.config.mjs
@@ -0,0 +1,32 @@
+import js from "@eslint/js"
+import reactHooks from "eslint-plugin-react-hooks"
+import reactRefresh from "eslint-plugin-react-refresh"
+import tseslint from "typescript-eslint"
+
+export default tseslint.config(
+ { ignores: ["dist", "node_modules", "vite.config.ts"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: 2020,
+ sourceType: "module",
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": [
+ "warn",
+ { allowConstantExport: true },
+ ],
+ },
+ },
+)
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..e5c6710
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ Krates Yard
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..fc7f08a
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "krates-client",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint src --ext ts,tsx --max-warnings 0"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "xterm": "^5.3.0",
+ "xterm-addon-fit": "^0.8.0",
+ "zustand": "^4.4.7",
+ "yjs": "^13.6.16",
+ "y-websocket": "^2.1.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.10.0",
+ "@types/react": "^18.3.9",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.1",
+ "eslint": "^9.10.0",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.12",
+ "typescript": "^5.6.2",
+ "typescript-eslint": "^8.4.0",
+ "vite": "^5.4.3"
+ }
+}
diff --git a/client/postcss.config.js b/client/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/client/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..1148cfd
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,63 @@
+import React from 'react'
+import { WorldLayer } from './components/canvas'
+import { GridOverlay } from './components/canvas'
+import { Minimap } from './components/canvas'
+import { TopBar } from './components/shared'
+import { Spotlight } from './components/shared'
+import { useCanvasStore } from './state'
+
+export function App() {
+ const { camX, camY, zoom } = useCanvasStore()
+
+ const canvasStyle: React.CSSProperties = {
+ position: 'fixed',
+ inset: 0,
+ background: '#0b0e13',
+ overflow: 'hidden',
+ }
+
+ return (
+
+
+
+
+
+
+
+ {/* Bottom UI */}
+
+
+ {Math.round(zoom * 100)}%
+
+
+
+
+ Space + drag to pan
+ Scroll to pan
+ Click to spotlight
+
+
+ )
+}
diff --git a/client/src/components/canvas/GridOverlay.tsx b/client/src/components/canvas/GridOverlay.tsx
new file mode 100644
index 0000000..3fef62d
--- /dev/null
+++ b/client/src/components/canvas/GridOverlay.tsx
@@ -0,0 +1,27 @@
+import React, { HTMLAttributes } from 'react'
+import { useCanvasStore } from '../state/canvasStore'
+
+interface GridOverlayProps extends HTMLAttributes {}
+
+export const GridOverlay: React.FC = ({ ...props }) => {
+ const { zoom } = useCanvasStore()
+
+ const style: React.CSSProperties = {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ pointerEvents: 'none',
+ backgroundSize: '34px 34px, 170px 170px',
+ backgroundPosition: '0 0, 0 0',
+ backgroundImage: `
+ linear-gradient(to right, rgba(125,145,175,.04) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(125,145,175,.04) 1px, transparent 1px),
+ linear-gradient(to right, rgba(125,145,175,.075) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(125,145,175,.075) 1px, transparent 1px)
+ `,
+ }
+
+ return
+}
diff --git a/client/src/components/canvas/Minimap.tsx b/client/src/components/canvas/Minimap.tsx
new file mode 100644
index 0000000..e2e913c
--- /dev/null
+++ b/client/src/components/canvas/Minimap.tsx
@@ -0,0 +1,87 @@
+import React, { HTMLAttributes, useEffect, useRef } from 'react'
+import { useCanvasStore } from '../state/canvasStore'
+import { useKrateStore } from '../state/krateStore'
+
+interface MinimapProps extends HTMLAttributes {}
+
+export const Minimap: React.FC = ({ ...props }) => {
+ const { camX, camY, zoom } = useCanvasStore()
+ const { krates } = useKrateStore()
+ const minimapRef = useRef(null)
+
+ const minimapWidth = 180
+ const minimapHeight = 120
+ const worldWidth = 12000
+ const worldHeight = 8000
+ const minimapScaleX = minimapWidth / worldWidth
+ const minimapScaleY = minimapHeight / worldHeight
+
+ const viewportWidthWorld = window.innerWidth / zoom
+ const viewportHeightWorld = window.innerHeight / zoom
+
+ const viewportX = camX * minimapScaleX
+ const viewportY = camY * minimapScaleY
+ const viewportW = viewportWidthWorld * minimapScaleX
+ const viewportH = viewportHeightWorld * minimapScaleY
+
+ const handleClick = (e: React.MouseEvent) => {
+ const rect = minimapRef.current?.getBoundingClientRect()
+ if (!rect) return
+
+ const clickX = e.clientX - rect.left
+ const clickY = e.clientY - rect.top
+
+ const worldX = (clickX / minimapScaleX) - camX - viewportWidthWorld / 2
+ const worldY = (clickY / minimapScaleY) - camY - viewportHeightWorld / 2
+
+ useCanvasStore.getState().setCam(worldX, worldY)
+ }
+
+ return (
+
+ {Array.from(krates.values()).map((krate) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/client/src/components/canvas/WorldLayer.tsx b/client/src/components/canvas/WorldLayer.tsx
new file mode 100644
index 0000000..351735a
--- /dev/null
+++ b/client/src/components/canvas/WorldLayer.tsx
@@ -0,0 +1,29 @@
+import React, { HTMLAttributes } from 'react'
+import { useCanvasStore } from '../state/canvasStore'
+import { useKrateStore } from '../state/krateStore'
+import { Krate } from './krate'
+
+interface WorldLayerProps extends HTMLAttributes {}
+
+export const WorldLayer: React.FC = ({ ...props }) => {
+ const { camX, camY, zoom, collapsed } = useCanvasStore()
+ const { krates } = useKrateStore()
+
+ const style: React.CSSProperties = {
+ position: 'absolute',
+ width: '12000px',
+ height: '8000px',
+ transform: `translate(${camX}px, ${camY}px) scale(${zoom})`,
+ transformOrigin: '0 0',
+ }
+
+ return (
+
+ {Array.from(krates.values())
+ .filter((k) => !k.minimized)
+ .map((krate) => (
+
+ ))}
+
+ )
+}
diff --git a/client/src/components/canvas/index.ts b/client/src/components/canvas/index.ts
new file mode 100644
index 0000000..5ec3fb0
--- /dev/null
+++ b/client/src/components/canvas/index.ts
@@ -0,0 +1,3 @@
+export { WorldLayer } from './WorldLayer'
+export { GridOverlay } from './GridOverlay'
+export { Minimap } from './Minimap'
diff --git a/client/src/components/krate/Krate.tsx b/client/src/components/krate/Krate.tsx
new file mode 100644
index 0000000..40922dc
--- /dev/null
+++ b/client/src/components/krate/Krate.tsx
@@ -0,0 +1,169 @@
+import React, { useState } from 'react'
+import { useKrateStore } from '../../state/krateStore'
+
+interface KrateProps {
+ krate: Krate
+ collapsed: boolean
+}
+
+interface Krate {
+ id: string
+ type: string
+ title: string
+ x: number
+ y: number
+ width: number
+ height: number
+ minimized: boolean
+ windowLayout: {
+ cols: number
+ rows: number
+ cells: Map
+ }
+ windows: Map
+}
+
+interface KrateWindow {
+ id: string
+ type: string
+ title: string
+ state: unknown
+}
+
+export const Krate: React.FC = ({ krate, collapsed }) => {
+ const { selectKrate } = useKrateStore()
+ const [isDragging, setIsDragging] = useState(false)
+
+ if (collapsed) {
+ return (
+ selectKrate(krate.id)}
+ >
+
+ {krate.title}
+
+
+ {krate.windows.size > 0 ? '..."' : 'Empty'}
+
+
+ )
+ }
+
+ return (
+ selectKrate(krate.id)}
+ >
+
setIsDragging(true)} />
+
+
+ {Array.from(krate.windows.values()).map((window) => (
+
+ {window.title}
+
+ ))}
+
+
+ )
+}
+
+const hexToRgba = (hex: string) => {
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+ return `${r}, ${g}, ${b}`
+}
+
+const KrateHeader: React.FC void }> = ({
+ krate,
+ onDragStart,
+}) => {
+ return (
+ {
+ e.stopPropagation()
+ onDragStart()
+ }}
+ >
+ {krate.title}
+
+ {krate.windows.size} windows
+
+
+
+
+ )
+}
diff --git a/client/src/components/krate/index.ts b/client/src/components/krate/index.ts
new file mode 100644
index 0000000..14fd8c0
--- /dev/null
+++ b/client/src/components/krate/index.ts
@@ -0,0 +1 @@
+export { Krate, KrateHeader } from './Krate'
diff --git a/client/src/components/shared/AdminDrawer.tsx b/client/src/components/shared/AdminDrawer.tsx
new file mode 100644
index 0000000..b7356d8
--- /dev/null
+++ b/client/src/components/shared/AdminDrawer.tsx
@@ -0,0 +1,118 @@
+import React from 'react'
+import { useCanvasStore } from '../../state/canvasStore'
+
+interface AdminDrawerProps {
+ open: boolean
+ onClose: () => void
+ users: Array<{
+ userId: string
+ name: string
+ color: string
+ status: string
+ }>
+}
+
+export const AdminDrawer: React.FC = ({ open, onClose, users }) => {
+ const { setCam } = useCanvasStore()
+
+ if (!open) return null
+
+ const handleSpectate = (userId: string) => {
+ onClose()
+ // In a real app, this would fly to the user's krate position
+ console.log(`Spectating user: ${userId}`)
+ }
+
+ return (
+
+
+
Users
+
+
+
+
+ {users.map((user) => (
+
e.stopPropagation()}
+ >
+
+
+ {user.name.slice(0, 2).toUpperCase()}
+
+
+
{user.name}
+
{user.status}
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/client/src/components/shared/LogsViewer.tsx b/client/src/components/shared/LogsViewer.tsx
new file mode 100644
index 0000000..f40561b
--- /dev/null
+++ b/client/src/components/shared/LogsViewer.tsx
@@ -0,0 +1,110 @@
+import React, { useEffect, useRef, useState } from 'react'
+
+interface LogLine {
+ timestamp: string
+ level: 'ERROR' | 'WARN' | 'INFO' | 'DEBUG'
+ message: string
+}
+
+interface LogsViewerProps {
+ pod: string
+ namespace: string
+}
+
+export const LogsViewer: React.FC = ({ pod, namespace }) => {
+ const logsRef = useRef(null)
+ const [logs, setLogs] = useState([])
+ const [autoScroll, setAutoScroll] = useState(true)
+ const [lastScrollTop, setLastScrollTop] = useState(0)
+
+ useEffect(() => {
+ const ws = new WebSocket(
+ `/ws/logs?pod=${encodeURIComponent(pod)}&ns=${encodeURIComponent(namespace)}`
+ )
+
+ ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data)
+ if (data.type === 'log') {
+ setLogs((prev) => {
+ const newLogs = [...prev, data.log]
+ if (autoScroll && logsRef.current) {
+ logsRef.current.scrollTop = logsRef.current.scrollHeight
+ }
+ return newLogs
+ })
+ }
+ } catch (err) {
+ console.error('Failed to parse log message:', err)
+ }
+ }
+
+ ws.onclose = () => {
+ console.log('Logs WebSocket closed')
+ }
+
+ return () => {
+ ws.close()
+ }
+ }, [pod, namespace, autoScroll])
+
+ const handleScroll = () => {
+ if (logsRef.current) {
+ const isAtBottom =
+ logsRef.current.scrollTop + logsRef.current.clientHeight >=
+ logsRef.current.scrollHeight - 10
+ setAutoScroll(isAtBottom)
+ setLastScrollTop(logsRef.current.scrollTop)
+ }
+ }
+
+ const formatLevel = (message: string): 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' => {
+ if (message.includes('ERROR') || message.includes('ERR')) return 'ERROR'
+ if (message.includes('WARN') || message.includes('WARNING')) return 'WARN'
+ if (message.includes('DEBUG')) return 'DEBUG'
+ return 'INFO'
+ }
+
+ const getColor = (level: string): string => {
+ switch (level) {
+ case 'ERROR':
+ return '#ef6f6f'
+ case 'WARN':
+ return '#e8b54a'
+ case 'DEBUG':
+ return '#6b7280'
+ default:
+ return '#b9c6d8'
+ }
+ }
+
+ return (
+
+ {logs.map((line, index) => (
+
+ {line.timestamp}
+ {line.message}
+
+ ))}
+ {logs.length === 0 && (
+
+ No logs yet...
+
+ )}
+
+ )
+}
diff --git a/client/src/components/shared/ShellTerminal.tsx b/client/src/components/shared/ShellTerminal.tsx
new file mode 100644
index 0000000..bbdbaee
--- /dev/null
+++ b/client/src/components/shared/ShellTerminal.tsx
@@ -0,0 +1,118 @@
+import React, { useEffect, useRef } from 'react'
+import { Terminal } from 'xterm'
+import { FitAddon } from 'xterm-addon-fit'
+
+interface ShellTerminalProps {
+ pod: string
+ namespace: string
+ onConnect?: () => void
+ onClose?: () => void
+}
+
+export const ShellTerminal: React.FC = ({
+ pod,
+ namespace,
+ onConnect,
+ onClose,
+}) => {
+ const termRef = useRef(null)
+ const terminalRef = useRef(null)
+ const fitAddonRef = useRef(null)
+
+ useEffect(() => {
+ if (!termRef.current) return
+
+ const term = new Terminal({
+ cursorBlink: true,
+ fontSize: 12,
+ fontFamily: 'IBM Plex Mono, monospace',
+ lineHeight: 1.5,
+ theme: {
+ background: '#1a1e26',
+ foreground: '#b9c6d8',
+ },
+ })
+
+ const fitAddon = new FitAddon()
+ term.loadAddon(fitAddon)
+
+ term.open(termRef.current)
+ fitAddon.fit()
+
+ // Calculate size
+ const cols = Math.floor(termRef.current.clientWidth / 9)
+ const rows = Math.floor(termRef.current.clientHeight / 18)
+ term.resize(cols, rows)
+
+ // Connect to WebSocket
+ const ws = new WebSocket(
+ `/ws/shell?pod=${encodeURIComponent(pod)}&ns=${encodeURIComponent(namespace)}`
+ )
+
+ ws.onopen = () => {
+ onConnect?.()
+ console.log('Shell connected')
+ }
+
+ ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data)
+ if (data.type === 'shell:output') {
+ term.write(data.data)
+ }
+ } catch (err) {
+ console.error('Failed to parse shell message:', err)
+ }
+ }
+
+ ws.onclose = () => {
+ console.log('Shell disconnected')
+ onClose?.()
+ }
+
+ ws.onerror = (err) => {
+ console.error('Shell WebSocket error:', err)
+ }
+
+ term.onData((data) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'shell:input', data }))
+ }
+ })
+
+ term.onResize((size) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'shell:resize', cols: size.cols, rows: size.rows }))
+ }
+ })
+
+ terminalRef.current = term
+ fitAddonRef.current = fitAddon
+
+ const handleResize = () => {
+ if (termRef.current && fitAddonRef.current) {
+ fitAddonRef.current.fit()
+ }
+ }
+
+ window.addEventListener('resize', handleResize)
+
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ ws.close()
+ term.dispose()
+ }
+ }, [pod, namespace, onConnect, onClose])
+
+ return (
+
+ )
+}
diff --git a/client/src/components/shared/Spotlight.tsx b/client/src/components/shared/Spotlight.tsx
new file mode 100644
index 0000000..0428fcf
--- /dev/null
+++ b/client/src/components/shared/Spotlight.tsx
@@ -0,0 +1,105 @@
+import React, { HTMLAttributes } from 'react'
+import { useSpotlightStore } from '../../state/spotlightStore'
+
+interface SpotlightProps extends HTMLAttributes {}
+
+export const Spotlight: React.FC = ({ ...props }) => {
+ const { open, query, filterType, setQuery, setFilter, close, toggle } = useSpotlightStore()
+
+ if (!open) return null
+
+ const filters: { id: string; label: string }[] = [
+ { id: 'all', label: 'All' },
+ { id: 'krate', label: 'Krates' },
+ { id: 'namespace', label: 'Namespaces' },
+ { id: 'pod', label: 'Pods' },
+ { id: 'deployment', label: 'Deployments' },
+ { id: 'service', label: 'Services' },
+ ]
+
+ return (
+
+
+
+ 🔍
+ setQuery(e.target.value)}
+ placeholder="Search..."
+ autoFocus
+ style={{
+ flex: 1,
+ background: 'transparent',
+ border: 'none',
+ color: '#fff',
+ fontSize: '16px',
+ outline: 'none',
+ }}
+ />
+
+
+
+
+ {filters.map((f) => (
+
+ ))}
+
+
+
+ Select: Enter, Close: Esc
+
+
+
+ )
+}
diff --git a/client/src/components/shared/SpotlightFilters.tsx b/client/src/components/shared/SpotlightFilters.tsx
new file mode 100644
index 0000000..dbf455e
--- /dev/null
+++ b/client/src/components/shared/SpotlightFilters.tsx
@@ -0,0 +1 @@
+export { SpotlightFilter } from './SpotlightFilters'
diff --git a/client/src/components/shared/TopBar.tsx b/client/src/components/shared/TopBar.tsx
new file mode 100644
index 0000000..628aff7
--- /dev/null
+++ b/client/src/components/shared/TopBar.tsx
@@ -0,0 +1,206 @@
+import React, { useEffect, useState } from 'react'
+import { useCanvasStore } from '../../state/canvasStore'
+import { useSpotlightStore } from '../../state/spotlightStore'
+import { useUserStore } from '../../state/userStore'
+import { AdminDrawer } from './AdminDrawer'
+
+export const TopBar: React.FC = () => {
+ const { zoom } = useCanvasStore()
+ const { open: spotlightOpen, toggle: toggleSpotlight } = useSpotlightStore()
+ const { currentUser, presence } = useUserStore()
+ const [adminOpen, setAdminOpen] = useState(false)
+
+ const [synced, setSynced] = useState(true)
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setSynced((prev) => !prev)
+ }, 1000)
+ return () => clearInterval(interval)
+ }, [])
+
+ const users = Array.from(presence.values())
+
+ return (
+ <>
+
+ {/* Left side */}
+
+ {/* Logo pill */}
+
+
+ {/* Cluster pill */}
+
+
+ {/* Krate count pill */}
+ {users.length > 0 && (
+
+
+ {users.length} active
+
+
+ )}
+
+
+ {/* Right side */}
+
+ {/* Synced pill */}
+
+
+ {/* Admin button */}
+
+
+ {/* Roster avatars */}
+ {users.slice(0, 5).map((user) => (
+
+ {user.userId === currentUser.id ? '★' : user.name.slice(0, 2).toUpperCase()}
+
+ ))}
+ {users.length > 5 && (
+
+ +{users.length - 5}
+
+ )}
+
+
+
+ setAdminOpen(false)} users={users} />
+ >
+ )
+}
diff --git a/client/src/components/shared/index.ts b/client/src/components/shared/index.ts
new file mode 100644
index 0000000..effbbe1
--- /dev/null
+++ b/client/src/components/shared/index.ts
@@ -0,0 +1,5 @@
+export { Spotlight } from './Spotlight'
+export { TopBar } from './TopBar'
+export { AdminDrawer } from './AdminDrawer'
+export { ShellTerminal } from './ShellTerminal'
+export { LogsViewer } from './LogsViewer'
diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts
new file mode 100644
index 0000000..6cd19c8
--- /dev/null
+++ b/client/src/hooks/index.ts
@@ -0,0 +1,5 @@
+export { useCanvas } from './useCanvas'
+export { useDebounce, useKeyPress, useResizeObserver } from './useUtils'
+export { useKeyboardShortcuts } from './useKeyboardShortcuts'
+export { fuzzySearch, fuzzyFilter } from '../utils/fuzzy'
+export { initCRDT, getCRDTDoc, destroyCRDT } from '../utils/crdt'
diff --git a/client/src/hooks/useCanvas.ts b/client/src/hooks/useCanvas.ts
new file mode 100644
index 0000000..18d7d5a
--- /dev/null
+++ b/client/src/hooks/useCanvas.ts
@@ -0,0 +1,49 @@
+import { useCanvasStore } from '../state/canvasStore'
+
+export const useCanvas = () => {
+ const { camX, camY, zoom, flying, dragging, collapsed, spacePanning } = useCanvasStore()
+
+ const screenToWorld = (screenX: number, screenY: number) => {
+ return {
+ x: (screenX - window.innerWidth / 2) / zoom + camX,
+ y: (screenY - window.innerHeight / 2) / zoom + camY,
+ }
+ }
+
+ const worldToScreen = (worldX: number, worldY: number) => {
+ return {
+ x: (worldX - camX) * zoom + window.innerWidth / 2,
+ y: (worldY - camY) * zoom + window.innerHeight / 2,
+ }
+ }
+
+ const pan = (dx: number, dy: number) => {
+ useCanvasStore.getState().setCam(camX - dx / zoom, camY - dy / zoom)
+ }
+
+ const zoomAt = (factor: number, centerX: number, centerY: number) => {
+ const state = useCanvasStore.getState()
+ const newZoom = Math.max(0.1, Math.min(5, state.zoom * factor))
+
+ const worldCenter = screenToWorld(centerX, centerY)
+ const newCamX = worldCenter.x - (centerX - window.innerWidth / 2) / newZoom
+ const newCamY = worldCenter.y - (centerY - window.innerHeight / 2) / newZoom
+
+ useCanvasStore.getState().setZoom(newZoom)
+ useCanvasStore.getState().setCam(newCamX, newCamY)
+ }
+
+ return {
+ camX,
+ camY,
+ zoom,
+ flying,
+ dragging,
+ collapsed,
+ spacePanning,
+ screenToWorld,
+ worldToScreen,
+ pan,
+ zoomAt,
+ }
+}
diff --git a/client/src/hooks/useKeyboardShortcuts.ts b/client/src/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 0000000..2ccf769
--- /dev/null
+++ b/client/src/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,41 @@
+import { useEffect } from 'react'
+
+export const useKeyboardShortcuts = (handlers: {
+ onOpenSpotlight: () => void
+ onCloseSpotlight: () => void
+ onNavigateUp: () => void
+ onNavigateDown: () => void
+ onNavigateLeft: () => void
+ onNavigateRight: () => void
+ onSelect: () => void
+}) => {
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.ctrlKey || e.metaKey) return
+
+ switch (e.key) {
+ case 'Escape':
+ handlers.onCloseSpotlight()
+ break
+ case 'k':
+ handlers.onNavigateUp()
+ break
+ case 'j':
+ handlers.onNavigateDown()
+ break
+ case 'h':
+ handlers.onNavigateLeft()
+ break
+ case 'l':
+ handlers.onNavigateRight()
+ break
+ case 'Enter':
+ handlers.onSelect()
+ break
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [handlers])
+}
diff --git a/client/src/hooks/useKubernetes.ts b/client/src/hooks/useKubernetes.ts
new file mode 100644
index 0000000..a7748be
--- /dev/null
+++ b/client/src/hooks/useKubernetes.ts
@@ -0,0 +1 @@
+export { useKubernetes } from './useKubernetes'
diff --git a/client/src/hooks/useSpotlight.ts b/client/src/hooks/useSpotlight.ts
new file mode 100644
index 0000000..eea4843
--- /dev/null
+++ b/client/src/hooks/useSpotlight.ts
@@ -0,0 +1,53 @@
+import { useMemo } from 'react'
+
+export const useSpotlight = () => {
+ const fuzzy = (query: string, text: string): number => {
+ if (!query) return 0
+
+ const queryLower = query.toLowerCase()
+ const textLower = text.toLowerCase()
+
+ let score = 0
+ let queryIndex = 0
+
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
+ if (textLower[i] === queryLower[queryIndex]) {
+ score++
+ queryIndex++
+ }
+ }
+
+ return score / Math.max(query.length, text.length)
+ }
+
+ const filterItems = (
+ items: T[],
+ query: string,
+ filterType: string | null
+ ): T[] => {
+ return items.filter((item) => {
+ const matchesQuery = fuzzy(query, item.name) > 0.3
+ const matchesType = !filterType || filterType === 'all' || item.type === filterType
+ return matchesQuery && matchesType
+ })
+ }
+
+ const debounce = any>(fn: T, delay: number) => {
+ let timeoutId: NodeJS.Timeout | null = null
+ return (...args: Parameters): Promise> => {
+ return new Promise((resolve) => {
+ if (timeoutId) clearTimeout(timeoutId)
+ timeoutId = setTimeout(() => resolve(fn(...args)), delay)
+ })
+ }
+ }
+
+ return useMemo(
+ () => ({
+ fuzzy,
+ filterItems,
+ debounce,
+ }),
+ []
+ )
+}
diff --git a/client/src/hooks/useUtils.ts b/client/src/hooks/useUtils.ts
new file mode 100644
index 0000000..4ce07e2
--- /dev/null
+++ b/client/src/hooks/useUtils.ts
@@ -0,0 +1,50 @@
+import { useState, useEffect, useCallback } from 'react'
+
+export const useDebounce = (value: T, delay: number): T => {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedValue(value)
+ }, delay)
+
+ return () => {
+ clearTimeout(timer)
+ }
+ }, [value, delay])
+
+ return debouncedValue
+}
+
+export const useKeyPress = (targetKey: string, callback: () => void) => {
+ useEffect(() => {
+ const downHandler = ({ key }: KeyboardEvent) => {
+ if (key.toLowerCase() === targetKey.toLowerCase()) {
+ callback()
+ }
+ }
+
+ window.addEventListener('keydown', downHandler)
+ return () => window.removeEventListener('keydown', downHandler)
+ }, [targetKey, callback])
+}
+
+export const useResizeObserver = (
+ ref: React.RefObject,
+ onResize: (rect: DOMRectReadOnly) => void
+) => {
+ useEffect(() => {
+ const element = ref.current
+ if (!element) return
+
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ onResize(entry.contentRect)
+ }
+ })
+
+ observer.observe(element)
+
+ return () => observer.disconnect()
+ }, [ref, onResize])
+}
diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts
new file mode 100644
index 0000000..0864762
--- /dev/null
+++ b/client/src/hooks/useWebSocket.ts
@@ -0,0 +1 @@
+export { useWebSocket } from './useWebSocket'
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..ee3f745
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { App } from './App'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/client/src/state/canvasStore.ts b/client/src/state/canvasStore.ts
new file mode 100644
index 0000000..fee5c0f
--- /dev/null
+++ b/client/src/state/canvasStore.ts
@@ -0,0 +1,41 @@
+import { create } from 'zustand'
+import { devtools, persist } from 'zustand/middleware'
+
+export interface CanvasState {
+ camX: number
+ camY: number
+ zoom: number
+ flying: boolean
+ dragging: boolean
+ collapsed: boolean
+ spacePanning: boolean
+}
+
+const INITIAL_STATE: CanvasState = {
+ camX: 0,
+ camY: 0,
+ zoom: 1,
+ flying: false,
+ dragging: false,
+ collapsed: false,
+ spacePanning: false,
+}
+
+export const useCanvasStore = create()(
+ devtools(
+ persist(
+ (set) => ({
+ ...INITIAL_STATE,
+ setCam: (x: number, y: number) => set({ camX: x, camY: y }),
+ setZoom: (zoom: number) => set({ zoom }),
+ setFlying: (flying: boolean) => set({ flying }),
+ setDragging: (dragging: boolean) => set({ dragging }),
+ setCollapsed: (collapsed: boolean) => set({ collapsed }),
+ setSpacePanning: (spacePanning: boolean) => set({ spacePanning }),
+ }),
+ {
+ name: 'canvas-state',
+ }
+ )
+ )
+)
diff --git a/client/src/state/index.ts b/client/src/state/index.ts
new file mode 100644
index 0000000..80fe0a3
--- /dev/null
+++ b/client/src/state/index.ts
@@ -0,0 +1,4 @@
+export { useCanvasStore } from './canvasStore'
+export { useKrateStore } from './krateStore'
+export { useSpotlightStore } from './spotlightStore'
+export { useUserStore } from './userStore'
diff --git a/client/src/state/krateStore.ts b/client/src/state/krateStore.ts
new file mode 100644
index 0000000..562cf4f
--- /dev/null
+++ b/client/src/state/krateStore.ts
@@ -0,0 +1,61 @@
+import { create } from 'zustand'
+import { devtools } from 'zustand/middleware'
+
+export interface Krate {
+ id: string
+ type: string
+ title: string
+ x: number
+ y: number
+ width: number
+ height: number
+ minimized: boolean
+ windowLayout: WindowLayout
+ windows: Map
+}
+
+export interface WindowLayout {
+ cols: number
+ rows: number
+ cells: Map
+}
+
+export interface KrateWindow {
+ id: string
+ type: string
+ title: string
+ state: unknown
+}
+
+export interface KrateStore {
+ krates: Map
+ selectedKrateId: string | null
+}
+
+const INITIAL_STATE: KrateStore = {
+ krates: new Map(),
+ selectedKrateId: null,
+}
+
+export const useKrateStore = create()(
+ devtools((set) => ({
+ ...INITIAL_STATE,
+ addKrate: (krate: Krate) =>
+ set((state) => ({ krates: new Map(state.krates).set(krate.id, krate) })),
+ removeKrate: (id: string) =>
+ set((state) => {
+ const newKrates = new Map(state.krates)
+ newKrates.delete(id)
+ return { krates: newKrates }
+ }),
+ selectKrate: (id: string | null) => set({ selectedKrateId: id }),
+ updateKrate: (id: string, updates: Partial) =>
+ set((state) => {
+ const existing = state.krates.get(id)
+ if (!existing) return state
+ return {
+ krates: new Map(state.krates).set(id, { ...existing, ...updates }),
+ }
+ }),
+ }))
+)
diff --git a/client/src/state/spotlightStore.ts b/client/src/state/spotlightStore.ts
new file mode 100644
index 0000000..ca2835e
--- /dev/null
+++ b/client/src/state/spotlightStore.ts
@@ -0,0 +1,32 @@
+import { create } from 'zustand'
+import { devtools } from 'zustand/middleware'
+
+export interface SpotlightState {
+ open: boolean
+ query: string
+ filterType: string | null
+ sel: number
+ navigated: boolean
+}
+
+export type SpotlightFilter = 'all' | 'krate' | 'namespace' | 'pod' | 'deployment' | 'service'
+
+const INITIAL_STATE: SpotlightState = {
+ open: false,
+ query: '',
+ filterType: null,
+ sel: 0,
+ navigated: false,
+}
+
+export const useSpotlightStore = create()(
+ devtools((set) => ({
+ ...INITIAL_STATE,
+ setOpen: (open: boolean) => set({ open }),
+ setQuery: (query: string) => set({ query, navigated: false, sel: 0 }),
+ setFilter: (filterType: SpotlightFilter | null) => set({ filterType }),
+ setSel: (sel: number) => set({ sel, navigated: true }),
+ toggle: () => set((state) => ({ open: !state.open })),
+ close: () => set({ open: false, query: '', filterType: null, sel: 0, navigated: false }),
+ }))
+)
diff --git a/client/src/state/userStore.ts b/client/src/state/userStore.ts
new file mode 100644
index 0000000..4a4e97d
--- /dev/null
+++ b/client/src/state/userStore.ts
@@ -0,0 +1,56 @@
+import { create } from 'zustand'
+import { devtools } from 'zustand/middleware'
+
+export interface User {
+ id: string
+ name: string
+ avatar?: string
+}
+
+export interface UserPresence {
+ userId: string
+ name: string
+ color: string
+ cursorPosition?: { x: number; y: number }
+ activeKrateId?: string
+ spotlightQuery?: string
+ timestamp: number
+}
+
+export interface UserStore {
+ currentUser: User
+ presence: Map
+}
+
+const INITIAL_STATE: UserStore = {
+ currentUser: {
+ id: 'local-user',
+ name: 'User',
+ },
+ presence: new Map(),
+}
+
+export const useUserStore = create()(
+ devtools((set) => ({
+ ...INITIAL_STATE,
+ setCurrentUser: (user: User) => set({ currentUser: user }),
+ addPresence: (presence: UserPresence) =>
+ set((state) => ({
+ presence: new Map(state.presence).set(presence.userId, presence),
+ })),
+ removePresence: (userId: string) =>
+ set((state) => {
+ const newPresence = new Map(state.presence)
+ newPresence.delete(userId)
+ return { presence: newPresence }
+ }),
+ updatePresence: (userId: string, updates: Partial) =>
+ set((state) => {
+ const existing = state.presence.get(userId)
+ if (!existing) return state
+ return {
+ presence: new Map(state.presence).set(userId, { ...existing, ...updates }),
+ }
+ }),
+ }))
+)
diff --git a/client/src/utils/crdt.ts b/client/src/utils/crdt.ts
new file mode 100644
index 0000000..1901430
--- /dev/null
+++ b/client/src/utils/crdt.ts
@@ -0,0 +1,37 @@
+import * as Y from 'yjs'
+
+let doc: Y.Doc | null = null
+let provider: any | null = null
+
+export const initCRDT = (roomName: string, signalingUrl: string) => {
+ doc = new Y.Doc()
+
+ provider = new (require('y-webrtc').WebrtcProvider)(
+ roomName,
+ doc,
+ {
+ signaling: [signalingUrl],
+ }
+ )
+
+ provider.on('status', (status: string) => {
+ console.log('[CRDT] Status:', status)
+ })
+
+ return doc
+}
+
+export const getCRDTDoc = (): Y.Doc | null => {
+ return doc
+}
+
+export const getCRDTProvider = () => {
+ return provider
+}
+
+export const destroyCRDT = () => {
+ provider?.destroy()
+ doc?.destroy()
+ doc = null
+ provider = null
+}
diff --git a/client/src/utils/fuzzy.ts b/client/src/utils/fuzzy.ts
new file mode 100644
index 0000000..aaf6912
--- /dev/null
+++ b/client/src/utils/fuzzy.ts
@@ -0,0 +1,50 @@
+const MAX_DISTANCE = 1000
+
+export const fuzzySearch = (query: string, text: string): number => {
+ if (!query) return 0
+
+ const queryLower = query.toLowerCase()
+ const textLower = text.toLowerCase()
+
+ if (textLower.startsWith(queryLower)) {
+ return 1 - query.length / text.length
+ }
+
+ let score = 0
+ let queryIndex = 0
+
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
+ if (textLower[i] === queryLower[queryIndex]) {
+ score++
+ queryIndex++
+ }
+ }
+
+ if (score === 0) return 0
+
+ const matchRatio = score / query.length
+ const positionBonus = queryIndex === query.length ? 0.2 : 0
+ const lengthPenalty = Math.min(1, text.length / MAX_DISTANCE)
+
+ return (matchRatio * 0.7 + positionBonus) * lengthPenalty
+}
+
+export const fuzzyFilter = (
+ items: T[],
+ query: string,
+ typeFilter?: string
+): T[] => {
+ if (!query && !typeFilter) return items
+
+ return items
+ .map((item) => ({
+ item,
+ score: fuzzySearch(query, item.name),
+ }))
+ .filter(
+ ({ item, score }) =>
+ score > 0.3 && (!typeFilter || typeFilter === 'all' || item.type === typeFilter)
+ )
+ .sort((a, b) => b.score - a.score)
+ .map(({ item }) => item)
+}
diff --git a/client/src/utils/keyboard.ts b/client/src/utils/keyboard.ts
new file mode 100644
index 0000000..512c55a
--- /dev/null
+++ b/client/src/utils/keyboard.ts
@@ -0,0 +1,27 @@
+export const isKey = (e: KeyboardEvent | React.KeyboardEvent, key: string): boolean => {
+ return e.key.toLowerCase() === key.toLowerCase()
+}
+
+export const isModifierKey = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
+ return e.ctrlKey || e.metaKey || e.altKey
+}
+
+export const getMouseClickPosition = (e: React.MouseEvent | MouseEvent) => {
+ return {
+ x: e.clientX,
+ y: e.clientY,
+ }
+}
+
+export const getKeyboardSelectionDelta = (e: KeyboardEvent | React.KeyboardEvent): { dx: number; dy: number } => {
+ const step = e.shiftKey ? 10 : 1
+ let dx = 0
+ let dy = 0
+
+ if (e.key === 'ArrowUp') dy = -step
+ else if (e.key === 'ArrowDown') dy = step
+ else if (e.key === 'ArrowLeft') dx = -step
+ else if (e.key === 'ArrowRight') dx = step
+
+ return { dx, dy }
+}
diff --git a/client/src/utils/math.ts b/client/src/utils/math.ts
new file mode 100644
index 0000000..6a270a7
--- /dev/null
+++ b/client/src/utils/math.ts
@@ -0,0 +1,41 @@
+export const degreesToRadians = (degrees: number): number => {
+ return (degrees * Math.PI) / 180
+}
+
+export const radiansToDegrees = (radians: number): number => {
+ return (radians * 180) / Math.PI
+}
+
+export const rotatePoint = (
+ x: number,
+ y: number,
+ centerX: number,
+ centerY: number,
+ angleDegrees: number
+): { x: number; y: number } => {
+ const angle = degreesToRadians(angleDegrees)
+ const cos = Math.cos(angle)
+ const sin = Math.sin(angle)
+
+ const dx = x - centerX
+ const dy = y - centerY
+
+ return {
+ x: centerX + dx * cos - dy * sin,
+ y: centerY + dx * sin + dy * cos,
+ }
+}
+
+export const distance = (x1: number, y1: number, x2: number, y2: number): number => {
+ const dx = x2 - x1
+ const dy = y2 - y1
+ return Math.sqrt(dx * dx + dy * dy)
+}
+
+export const clamp = (value: number, min: number, max: number): number => {
+ return Math.min(Math.max(value, min), max)
+}
+
+export const lerp = (start: number, end: number, t: number): number => {
+ return start * (1 - t) + end * t
+}
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..69e0a5b
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "allowImportingTsExtensions": true,
+ "noCheckFail": false
+ },
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..2f5a667
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3000,
+ origin: 'http://localhost:3000',
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ },
+ '/ws': {
+ target: 'ws://localhost:8080',
+ changeOrigin: true,
+ ws: true,
+ },
+ },
+ },
+})
diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go
new file mode 100644
index 0000000..6f2ac10
--- /dev/null
+++ b/server/cmd/server/main.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+
+ "krates/server/internal/api"
+ "krates/server/internal/crdt"
+ "krates/server/internal/ws"
+)
+
+func main() {
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+
+ crdtProvider := crdt.NewProvider()
+
+ wsManager := ws.NewManager(crdtProvider)
+
+ mux := http.NewServeMux()
+ mux.Handle("/ws/shell", wsManager.WithWebSocket(ws.ShellHandler))
+ mux.Handle("/ws/logs", wsManager.WithWebSocket(ws.LogsHandler))
+ mux.Handle("/ws/watch", wsManager.WithWebSocket(ws.WatchHandler))
+ mux.Handle("/ws/sync", wsManager.WithWebSocket(ws.SyncHandler))
+
+ apiRoutes := api.SetupRoutes(wsManager)
+ mux.Handle("/api/", http.StripPrefix("/api", apiRoutes))
+
+ http.ListenAndServe(":"+port, mux)
+ log.Printf("Server started on port %s", port)
+}
diff --git a/server/go.mod b/server/go.mod
new file mode 100644
index 0000000..4e2c241
--- /dev/null
+++ b/server/go.mod
@@ -0,0 +1,10 @@
+module krates/server
+
+go 1.21
+
+require (
+ github.com/gorilla/websocket v1.5.3
+ github.com/yjs/y-websocket v1.2.10
+ k8s.io/client-go v0.29.0
+ github.com/olekukonko/tablewriter v0.0.5
+)