]> Repos - crimata-os/commitdiff
Add crimata-agent and redesign agentic OS UI
authorAndrew Gundersen <adgundersen@gmail.com>
Mon, 2 Mar 2026 20:52:05 +0000 (15:52 -0500)
committerAndrew Gundersen <adgundersen@gmail.com>
Mon, 2 Mar 2026 20:52:05 +0000 (15:52 -0500)
- crimata-agent (TypeScript, port 7702): WebSocket server + HTTP /events
  endpoint, Claude tool-use loop (render/remove/call_api/show_cursor_response),
  session.start fires on first browser connection to seed the canvas
- OS UI redesigned: light grey canvas (#e8e8e8), no dock bar, cursor dot,
  Cmd+Space input bubble above cursor, elements placed by agent
- Canvas elements: app_icon (floating icons with running dot), window (no-chrome
  content cards), cursor_response (text + action pills)
- Component system: HTML fragments fetched from /components/{app}/{name}.html,
  inline scripts re-executed after injection
- contacts/crimata.json: added icon, defaultComponent, components[], api[]
- dock: apps.h/c/main.c extended to parse and serve components + api from
  crimata.json; BUF_SIZE increased for richer JSON output
- ui/components/contacts/list.html + card.html: first component pair
- provision.sh: installs crimata-agent, adds WebSocket + /events + /components
  nginx locations, fixes /auth/ prefix stripping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
19 files changed:
agent/package.json [new file with mode: 0644]
agent/src/agent.ts [new file with mode: 0644]
agent/src/canvas.ts [new file with mode: 0644]
agent/src/index.ts [new file with mode: 0644]
agent/src/tools.ts [new file with mode: 0644]
agent/src/types.ts [new file with mode: 0644]
agent/tsconfig.json [new file with mode: 0644]
apps/contacts/crimata.json
dock/src/apps.c
dock/src/apps.h
dock/src/main.c
ui/components/contacts/card.html [new file with mode: 0644]
ui/components/contacts/list.html [new file with mode: 0644]
ui/index.html
ui/js/os.js
ui/styles/canvas.css
ui/styles/cursor.css
ui/styles/login.css
ui/styles/window.css

diff --git a/agent/package.json b/agent/package.json
new file mode 100644 (file)
index 0000000..eb3a1f3
--- /dev/null
@@ -0,0 +1,20 @@
+{
+  "name": "crimata-agent",
+  "version": "1.0.0",
+  "scripts": {
+    "build": "tsc",
+    "start": "node dist/index.js",
+    "dev": "ts-node src/index.ts"
+  },
+  "dependencies": {
+    "@anthropic-ai/sdk": "^0.39.0",
+    "express": "^4.18.0",
+    "ws": "^8.18.0"
+  },
+  "devDependencies": {
+    "@types/express": "^4.17.0",
+    "@types/node": "^20.0.0",
+    "@types/ws": "^8.5.0",
+    "typescript": "^5.0.0"
+  }
+}
diff --git a/agent/src/agent.ts b/agent/src/agent.ts
new file mode 100644 (file)
index 0000000..478289a
--- /dev/null
@@ -0,0 +1,155 @@
+import Anthropic from '@anthropic-ai/sdk'
+import WebSocket from 'ws'
+import { Canvas } from './canvas'
+import { toolDefinitions } from './tools'
+import { BusEvent, CanvasElement, InstalledApp, WSMessage } from './types'
+
+const SERVICE_PORTS: Record<string, number> = {
+  auth:  7700,
+  dock:  7701,
+  agent: 7702,
+}
+
+async function fetchApps(): Promise<InstalledApp[]> {
+  try {
+    const res = await fetch('http://localhost:7701/apps')
+    return await res.json() as InstalledApp[]
+  } catch {
+    return []
+  }
+}
+
+function appPort(appId: string, apps: InstalledApp[]): number {
+  if (SERVICE_PORTS[appId]) return SERVICE_PORTS[appId]
+  return apps.find(a => a.id === appId)?.port ?? 3000
+}
+
+function buildSystemPrompt(apps: InstalledApp[]): string {
+  const appList = apps.map(a => {
+    const components = a.components?.length ? a.components.join(', ') : 'none'
+    const apiList    = a.api?.map(e => `${e.method} ${e.endpoint} — ${e.description}`).join('; ') ?? ''
+    return `  - ${a.id} (${a.name}, port ${a.port}): components=[${components}] api=[${apiList}]`
+  }).join('\n')
+
+  return `You are the Crimata desktop agent — an agentic window manager for a personal Linux instance.
+You control a spatial canvas UI by calling tools. Think of yourself as both the OS shell and a helpful assistant.
+
+Installed apps:
+${appList || '  (none)'}
+
+Component naming: "app.component", e.g. "contacts.list", "contacts.card"
+App icons use type "app_icon", windows use type "window".
+
+Layout rules:
+- Place app icons spread naturally across the canvas (not all in one corner)
+- Open windows adjacent to their app icon
+- Keep the layout clean and uncluttered
+
+Behaviour:
+- On session.start: render all installed app icons on the canvas, then open each app's defaultComponent
+- On app_icon.clicked: render the app's default window next to the icon
+- On cursor.input: interpret naturally — open apps, answer questions, rearrange canvas, call APIs
+- On button.clicked or api events: decide whether to respond or stay silent
+- Only respond when you have something useful to do — silence is fine`
+}
+
+export async function handleEvent(
+  event: BusEvent,
+  canvas: Canvas,
+  ws: WebSocket | null,
+  apiKey: string,
+): Promise<void> {
+  const apps  = await fetchApps()
+  const model = process.env.CLAUDE_MODEL || 'claude-haiku-4-5-20251001'
+
+  const client = new Anthropic({ apiKey })
+
+  const canvasJson = JSON.stringify(canvas.getState(), null, 2)
+  const userMsg    = `Canvas:\n${canvasJson}\n\nEvent: ${JSON.stringify(event)}`
+
+  const messages: Anthropic.MessageParam[] = [
+    { role: 'user', content: userMsg },
+  ]
+
+  // Agentic loop — keep going until Claude stops calling tools
+  while (true) {
+    const response = await client.messages.create({
+      model,
+      max_tokens: 2048,
+      system:     buildSystemPrompt(apps),
+      tools:      toolDefinitions,
+      messages,
+    })
+
+    const toolUses = response.content.filter(b => b.type === 'tool_use')
+    if (toolUses.length === 0) break
+
+    const toolResults: Anthropic.ToolResultBlockParam[] = []
+
+    for (const block of response.content) {
+      if (block.type !== 'tool_use') continue
+
+      const input = block.input as Record<string, unknown>
+      let   result: unknown = { ok: true }
+
+      switch (block.name) {
+        case 'render': {
+          const el = input as unknown as CanvasElement
+          canvas.render(el)
+          send(ws, { op: 'render', element: el })
+          break
+        }
+
+        case 'remove': {
+          const id = input.id as string
+          canvas.remove(id)
+          send(ws, { op: 'remove', id })
+          break
+        }
+
+        case 'call_api': {
+          try {
+            const port = appPort(input.app as string, apps)
+            const url  = `http://localhost:${port}${input.endpoint as string}`
+            const res  = await fetch(url, {
+              method:  input.method as string,
+              headers: { 'Content-Type': 'application/json' },
+              body:    input.body ? JSON.stringify(input.body) : undefined,
+            })
+            result = await res.json()
+          } catch (e: unknown) {
+            result = { error: String(e) }
+          }
+          break
+        }
+
+        case 'show_cursor_response': {
+          send(ws, {
+            op:       'cursor_response',
+            text:     input.text as string | undefined,
+            pills:    input.pills as string[] | undefined,
+            position: input.position as { x: number; y: number },
+          })
+          break
+        }
+      }
+
+      toolResults.push({
+        type:        'tool_result',
+        tool_use_id: block.id,
+        content:     JSON.stringify(result),
+      })
+    }
+
+    messages.push({ role: 'assistant', content: response.content })
+    messages.push({ role: 'user',      content: toolResults })
+
+    if (response.stop_reason !== 'tool_use') break
+  }
+}
+
+function send(ws: WebSocket | null, msg: WSMessage): void {
+  if (ws && ws.readyState === WebSocket.OPEN) {
+    ws.send(JSON.stringify(msg))
+  }
+}
diff --git a/agent/src/canvas.ts b/agent/src/canvas.ts
new file mode 100644 (file)
index 0000000..a8b9d3d
--- /dev/null
@@ -0,0 +1,26 @@
+import { CanvasElement } from './types'
+
+export class Canvas {
+  private elements = new Map<string, CanvasElement>()
+
+  render(element: CanvasElement): void {
+    this.elements.set(element.id, element)
+  }
+
+  remove(id: string): void {
+    this.elements.delete(id)
+  }
+
+  update(id: string, patch: Partial<CanvasElement>): void {
+    const el = this.elements.get(id)
+    if (el) this.elements.set(id, { ...el, ...patch })
+  }
+
+  getState(): CanvasElement[] {
+    return Array.from(this.elements.values())
+  }
+
+  clear(): void {
+    this.elements.clear()
+  }
+}
diff --git a/agent/src/index.ts b/agent/src/index.ts
new file mode 100644 (file)
index 0000000..e044b3f
--- /dev/null
@@ -0,0 +1,60 @@
+import express from 'express'
+import { WebSocketServer, WebSocket } from 'ws'
+import { createServer } from 'http'
+import { Canvas } from './canvas'
+import { handleEvent } from './agent'
+import { BusEvent } from './types'
+
+const PORT    = 7702
+const API_KEY = process.env.ANTHROPIC_API_KEY ?? ''
+
+const app    = express()
+const server = createServer(app)
+const wss    = new WebSocketServer({ server, path: '/ws' })
+const canvas = new Canvas()
+
+let activeWs:      WebSocket | null = null
+let sessionStarted = false
+
+app.use(express.json())
+
+app.get('/health', (_req, res) => res.json({ status: 'ok' }))
+
+// POST /events — server-side services emit events to the agent
+app.post('/events', async (req, res) => {
+  const event: BusEvent = req.body
+  res.json({ ok: true })
+  handleEvent(event, canvas, activeWs, API_KEY).catch(console.error)
+})
+
+wss.on('connection', (ws) => {
+  activeWs = ws
+  console.log('browser connected')
+
+  // Send current canvas state immediately
+  ws.send(JSON.stringify({ op: 'canvas', elements: canvas.getState() }))
+
+  // Fire session.start on first browser connection to initialise canvas
+  if (!sessionStarted) {
+    sessionStarted = true
+    handleEvent({ type: 'session.start' }, canvas, ws, API_KEY).catch(console.error)
+  }
+
+  ws.on('message', async (raw) => {
+    try {
+      const event: BusEvent = JSON.parse(raw.toString())
+      await handleEvent(event, canvas, ws, API_KEY)
+    } catch (e) {
+      console.error('ws message error:', e)
+    }
+  })
+
+  ws.on('close', () => {
+    if (activeWs === ws) activeWs = null
+    console.log('browser disconnected')
+  })
+})
+
+server.listen(PORT, () => {
+  console.log(`crimata-agent listening on :${PORT}`)
+})
diff --git a/agent/src/tools.ts b/agent/src/tools.ts
new file mode 100644 (file)
index 0000000..2b8ec23
--- /dev/null
@@ -0,0 +1,66 @@
+import Anthropic from '@anthropic-ai/sdk'
+
+export const toolDefinitions: Anthropic.Tool[] = [
+  {
+    name: 'render',
+    description: 'Place or update a component on the canvas. Use stable IDs so re-renders update in place.',
+    input_schema: {
+      type: 'object' as const,
+      properties: {
+        id:        { type: 'string', description: 'Unique stable ID, e.g. "contacts-list", "mail-inbox"' },
+        type:      { type: 'string', enum: ['app_icon', 'window'] },
+        component: { type: 'string', description: 'App component, e.g. "contacts.list", "contacts.card"' },
+        props:     { type: 'object', description: 'Data to pass to the component' },
+        x:         { type: 'number', description: 'Canvas X position in pixels' },
+        y:         { type: 'number', description: 'Canvas Y position in pixels' },
+        running:   { type: 'boolean', description: 'Whether the app is running (for app_icon type)' },
+      },
+      required: ['id', 'type', 'component', 'x', 'y'],
+    },
+  },
+  {
+    name: 'remove',
+    description: 'Remove a component from the canvas.',
+    input_schema: {
+      type: 'object' as const,
+      properties: {
+        id: { type: 'string' },
+      },
+      required: ['id'],
+    },
+  },
+  {
+    name: 'call_api',
+    description: 'Call a local app or service API on this instance. Returns the JSON response.',
+    input_schema: {
+      type: 'object' as const,
+      properties: {
+        app:      { type: 'string', description: 'App ID or service: "contacts", "auth", "dock"' },
+        endpoint: { type: 'string', description: 'Path, e.g. "/contacts" or "/contacts/123"' },
+        method:   { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
+        body:     { type: 'object', description: 'Request body for POST/PUT/PATCH' },
+      },
+      required: ['app', 'endpoint', 'method'],
+    },
+  },
+  {
+    name: 'show_cursor_response',
+    description: 'Show a text response or action pills near the cursor. Use for replying to user input.',
+    input_schema: {
+      type: 'object' as const,
+      properties: {
+        text:     { type: 'string', description: 'Text to display' },
+        pills:    { type: 'array', items: { type: 'string' }, description: 'Action buttons to show' },
+        position: {
+          type: 'object',
+          properties: {
+            x: { type: 'number' },
+            y: { type: 'number' },
+          },
+          required: ['x', 'y'],
+        },
+      },
+      required: ['position'],
+    },
+  },
+]
diff --git a/agent/src/types.ts b/agent/src/types.ts
new file mode 100644 (file)
index 0000000..f08f4fe
--- /dev/null
@@ -0,0 +1,42 @@
+export type CanvasElement = {
+  id:        string
+  type:      'app_icon' | 'window' | 'cursor_response'
+  component: string           // e.g. "contacts", "contacts.list", "generic.form"
+  props?:    Record<string, unknown>
+  x:         number
+  y:         number
+  running?:  boolean
+}
+
+export type BusEvent = {
+  type:      string
+  data?:     Record<string, unknown>
+  position?: { x: number; y: number }
+  text?:     string
+}
+
+export type InstalledApp = {
+  id:               string
+  name:             string
+  icon:             string
+  port:             number
+  running:          boolean
+  defaultComponent: string
+  components:       string[]
+  api:              ApiEndpoint[]
+}
+
+export type ApiEndpoint = {
+  name:        string
+  description: string
+  method:      string
+  endpoint:    string
+  params?:     Record<string, string>
+  body?:       Record<string, string>
+}
+
+export type WSMessage =
+  | { op: 'render';          element: CanvasElement }
+  | { op: 'remove';          id: string }
+  | { op: 'canvas';          elements: CanvasElement[] }
+  | { op: 'cursor_response'; text?: string; pills?: string[]; position: { x: number; y: number } }
diff --git a/agent/tsconfig.json b/agent/tsconfig.json
new file mode 100644 (file)
index 0000000..69c4a3f
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "module": "commonjs",
+    "lib": ["ES2020"],
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true
+  },
+  "include": ["src/**/*"]
+}
index d63cf4b424bbb102926e9c2e0c1804faba89cfc3..7589cd9e5b5037b71278b532bddc2539f0f7cf43 100644 (file)
@@ -1,7 +1,54 @@
 {
   "id": "contacts",
   "name": "Contacts",
+  "icon": "👤",
   "port": 3001,
+  "defaultComponent": "contacts.list",
+  "components": ["contacts.list", "contacts.card"],
   "db": true,
-  "migrate": "npm run migrate"
+  "migrate": "npm run migrate",
+  "api": [
+    {
+      "name": "list_contacts",
+      "description": "Get all contacts ordered by name",
+      "method": "GET",
+      "endpoint": "/contacts"
+    },
+    {
+      "name": "get_contact",
+      "description": "Get a single contact by ID",
+      "method": "GET",
+      "endpoint": "/contacts/:id"
+    },
+    {
+      "name": "create_contact",
+      "description": "Create a new contact (name and domain required)",
+      "method": "POST",
+      "endpoint": "/contacts",
+      "body": {
+        "name": "string",
+        "domain": "string",
+        "avatar_url": "string?",
+        "notes": "string?"
+      }
+    },
+    {
+      "name": "update_contact",
+      "description": "Partially update a contact by ID",
+      "method": "PATCH",
+      "endpoint": "/contacts/:id",
+      "body": {
+        "name": "string?",
+        "domain": "string?",
+        "avatar_url": "string?",
+        "notes": "string?"
+      }
+    },
+    {
+      "name": "delete_contact",
+      "description": "Delete a contact by ID",
+      "method": "DELETE",
+      "endpoint": "/contacts/:id"
+    }
+  ]
 }
index ce445ff916a2bd8aba67b73b61bf397a096ab644..8d99c5e92663f7fdc5107761c904af93a9b9c5c4 100644 (file)
@@ -5,9 +5,9 @@
 #include "apps.h"
 
 #define MANIFEST_GLOB "/usr/lib/crimata-*/crimata.json"
-#define MAX_FILE_SIZE 4096
+#define MAX_FILE_SIZE 16384
 
-/* Pull a string value out of a flat JSON object (same approach as auth) */
+/* Pull a string value out of a flat JSON object */
 static int json_str(const char *json, const char *key, char *out, size_t out_len)
 {
     char search[128];
@@ -42,6 +42,51 @@ static int json_int(const char *json, const char *key, int *out)
     return 1;
 }
 
+/* Extract a raw JSON array or object value (string-aware bracket matching) */
+static int json_raw_value(const char *json, const char *key, char *out, size_t out_len)
+{
+    char search[128];
+    snprintf(search, sizeof(search), "\"%s\"", key);
+    const char *pos = strstr(json, search);
+    if (!pos) return 0;
+
+    pos += strlen(search);
+    while (*pos == ' ' || *pos == ':') pos++;
+    if (*pos != '[' && *pos != '{') return 0;
+
+    char open  = *pos;
+    char close = (open == '[') ? ']' : '}';
+    int  depth  = 0;
+    int  in_str = 0;
+    size_t i = 0;
+
+    while (*pos && i < out_len - 1) {
+        char c = *pos;
+        out[i++] = c;
+
+        if (in_str) {
+            if (c == '\\' && *(pos + 1)) {
+                /* escaped char — copy next byte too */
+                pos++;
+                if (i < out_len - 1) out[i++] = *pos;
+            } else if (c == '"') {
+                in_str = 0;
+            }
+        } else {
+            if (c == '"')       in_str = 1;
+            else if (c == open)  depth++;
+            else if (c == close) {
+                depth--;
+                if (depth == 0) { pos++; break; }
+            }
+        }
+        pos++;
+    }
+
+    out[i] = '\0';
+    return (depth == 0 && i > 0) ? 1 : 0;
+}
+
 static int parse_manifest(const char *path, app_t *app)
 {
     FILE *f = fopen(path, "r");
@@ -54,10 +99,14 @@ static int parse_manifest(const char *path, app_t *app)
 
     if (!json_str(buf, "id",   app->id,   sizeof(app->id)))   return 0;
     if (!json_str(buf, "name", app->name, sizeof(app->name))) return 0;
-    json_str(buf, "icon", app->icon, sizeof(app->icon));
+
+    json_str(buf, "icon",             app->icon,             sizeof(app->icon));
+    json_str(buf, "defaultComponent", app->default_component, sizeof(app->default_component));
     json_int(buf, "port", &app->port);
-    app->running = 0;
+    json_raw_value(buf, "components", app->components_json, sizeof(app->components_json));
+    json_raw_value(buf, "api",        app->api_json,        sizeof(app->api_json));
 
+    app->running = 0;
     return 1;
 }
 
index e52cee6629ae2c8e240e7de920c5f8075e77488e..427b3b43fa3dd799d811621133db7e4bb4f1e44e 100644 (file)
@@ -2,14 +2,17 @@
 #define APPS_H
 
 #define MAX_APPS  32
-#define MAX_STR   128
+#define MAX_STR   256
 
 typedef struct {
     char id[MAX_STR];
     char name[MAX_STR];
     char icon[MAX_STR];
     int  port;
-    int  running;   /* populated at request time via systemd check */
+    int  running;             /* populated at request time via systemd check */
+    char default_component[MAX_STR];
+    char components_json[2048]; /* raw JSON array, e.g. ["contacts.list","contacts.card"] */
+    char api_json[8192];        /* raw JSON array of ApiEndpoint objects */
 } app_t;
 
 /* Scan /usr/lib/crimata-*/crimata.json — returns app count */
index bec8466b39f205f86bc564312bad7a8ec4e179d4..303c7599019128f58195ea5f29510b45a7f9c5ff 100644 (file)
@@ -6,7 +6,7 @@
 #include "systemd.h"
 
 #define PORT     7701
-#define BUF_SIZE 16384
+#define BUF_SIZE 65536
 
 /* ── JSON response helper ─────────────────────────────────────────────────── */
 
@@ -42,12 +42,29 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn)
     pos += snprintf(buf + pos, sizeof(buf) - pos, "[");
 
     for (int i = 0; i < count; i++) {
+        const char *components = apps[i].components_json[0] ? apps[i].components_json : "[]";
+        const char *api        = apps[i].api_json[0]        ? apps[i].api_json        : "[]";
+
         pos += snprintf(buf + pos, sizeof(buf) - pos,
-            "%s{\"id\":\"%s\",\"name\":\"%s\",\"icon\":\"%s\","
-            "\"port\":%d,\"running\":%s}",
+            "%s{"
+            "\"id\":\"%s\","
+            "\"name\":\"%s\","
+            "\"icon\":\"%s\","
+            "\"port\":%d,"
+            "\"running\":%s,"
+            "\"defaultComponent\":\"%s\","
+            "\"components\":%s,"
+            "\"api\":%s"
+            "}",
             i > 0 ? "," : "",
-            apps[i].id, apps[i].name, apps[i].icon,
-            apps[i].port, apps[i].running ? "true" : "false");
+            apps[i].id,
+            apps[i].name,
+            apps[i].icon,
+            apps[i].port,
+            apps[i].running ? "true" : "false",
+            apps[i].default_component,
+            components,
+            api);
     }
 
     pos += snprintf(buf + pos, sizeof(buf) - pos, "]");
@@ -60,7 +77,6 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn)
 static enum MHD_Result handle_action(struct MHD_Connection *conn,
                                       const char *app_id, int start)
 {
-    /* Verify app exists */
     app_t apps[MAX_APPS];
     int   count = apps_scan(apps);
     int   found = 0;
@@ -108,10 +124,10 @@ static enum MHD_Result handler(void *cls,
     char app_id[MAX_STR];
 
     if (strcmp(method, "POST") == 0) {
-        if (sscanf(url, "/apps/%127[^/]/start", app_id) == 1)
+        if (sscanf(url, "/apps/%255[^/]/start", app_id) == 1)
             return handle_action(conn, app_id, 1);
 
-        if (sscanf(url, "/apps/%127[^/]/stop", app_id) == 1)
+        if (sscanf(url, "/apps/%255[^/]/stop", app_id) == 1)
             return handle_action(conn, app_id, 0);
     }
 
diff --git a/ui/components/contacts/card.html b/ui/components/contacts/card.html
new file mode 100644 (file)
index 0000000..0f8e329
--- /dev/null
@@ -0,0 +1,41 @@
+<div class="contact-card">
+  <div class="cc-avatar" id="cc-avatar">?</div>
+  <h2 id="cc-name">—</h2>
+  <div class="cc-fields" id="cc-fields"></div>
+</div>
+
+<style>
+.contact-card { padding: 28px 24px; font-family: -apple-system, sans-serif; text-align: center; width: 280px; }
+.cc-avatar { width: 64px; height: 64px; background: #e8e8e8; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 26px; font-weight: 600; color: #666; margin: 0 auto 14px; }
+.contact-card h2 { font-size: 18px; font-weight: 600; color: #1a1a1a; margin-bottom: 14px; }
+.cc-fields { text-align: left; display: flex; flex-direction: column; gap: 8px; }
+.cc-field { font-size: 13px; color: #888; }
+.cc-field span { font-weight: 500; color: #333; }
+.cc-empty { color: #aaa; font-size: 13px; }
+</style>
+
+<script>
+(function () {
+  const props = window.__componentProps || {}
+  const id    = props.id || props.contactId
+  if (!id) {
+    document.getElementById('cc-name').textContent = 'No contact selected'
+    return
+  }
+  fetch(`/apps/contacts/${id}`)
+    .then(r => r.ok ? r.json() : Promise.reject())
+    .then(c => {
+      document.getElementById('cc-avatar').textContent = (c.name || '?')[0].toUpperCase()
+      document.getElementById('cc-name').textContent   = c.name || '—'
+      const fields = document.getElementById('cc-fields')
+      fields.innerHTML = [
+        c.domain ? `<div class="cc-field">Domain: <span>${c.domain}</span></div>` : '',
+        c.notes  ? `<div class="cc-field">Notes: <span>${c.notes}</span></div>`   : '',
+      ].join('')
+      if (!fields.innerHTML) fields.innerHTML = '<div class="cc-empty">No details.</div>'
+    })
+    .catch(() => {
+      document.getElementById('cc-name').textContent = 'Contact not found'
+    })
+})()
+</script>
diff --git a/ui/components/contacts/list.html b/ui/components/contacts/list.html
new file mode 100644 (file)
index 0000000..64ecbac
--- /dev/null
@@ -0,0 +1,47 @@
+<div class="contacts-list">
+  <div class="cl-header">
+    <h2>Contacts</h2>
+    <button data-event="new_contact">+ New</button>
+  </div>
+  <div class="cl-items" id="cl-items">
+    <div class="cl-empty">Loading…</div>
+  </div>
+</div>
+
+<style>
+.contacts-list { padding: 20px 24px; font-family: -apple-system, sans-serif; width: 360px; }
+.cl-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.cl-header h2 { font-size: 17px; font-weight: 600; color: #1a1a1a; }
+.cl-header button { background: #1a1a1a; color: white; border: none; border-radius: 8px; padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer; }
+.cl-header button:hover { background: #333; }
+.contact-row { display: flex; align-items: center; gap: 12px; padding: 9px 10px; border-radius: 10px; cursor: pointer; transition: background 0.1s; }
+.contact-row:hover { background: #f5f5f5; }
+.contact-avatar { width: 34px; height: 34px; background: #e8e8e8; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; color: #666; flex-shrink: 0; }
+.contact-name { font-size: 14px; font-weight: 500; color: #1a1a1a; }
+.contact-domain { font-size: 12px; color: #999; }
+.cl-empty { color: #aaa; font-size: 13px; padding: 12px 0; }
+</style>
+
+<script>
+(function () {
+  const list = document.getElementById('cl-items')
+  fetch('/apps/contacts/')
+    .then(r => r.json())
+    .then(contacts => {
+      if (!contacts.length) {
+        list.innerHTML = '<div class="cl-empty">No contacts yet.</div>'
+        return
+      }
+      list.innerHTML = contacts.map(c => `
+        <div class="contact-row" data-id="${c.id}">
+          <div class="contact-avatar">${(c.name || '?')[0].toUpperCase()}</div>
+          <div>
+            <div class="contact-name">${c.name || '—'}</div>
+            <div class="contact-domain">${c.domain || ''}</div>
+          </div>
+        </div>
+      `).join('')
+    })
+    .catch(() => { list.innerHTML = '<div class="cl-empty">Could not load contacts.</div>' })
+})()
+</script>
index 526c231ae9957a47ec8fc191ac45eed6e089dfa2..930be721d9bf351c92d12954ef96af07b5ba9b71 100644 (file)
@@ -6,12 +6,11 @@
   <title>Crimata</title>
   <link rel="stylesheet" href="styles/canvas.css" />
   <link rel="stylesheet" href="styles/window.css" />
-  <link rel="stylesheet" href="styles/dock.css" />
   <link rel="stylesheet" href="styles/cursor.css" />
   <link rel="stylesheet" href="styles/login.css" />
   <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 </head>
-<body x-data="os" @keydown.window="handleKeydown">
+<body x-data="os" @keydown.window="handleKeydown" @mousemove.window="trackCursor">
 
   <!-- Login overlay (shown until authenticated) -->
   <div id="login-overlay" x-show="!authed" x-cloak>
     </div>
   </div>
 
-  <!-- Infinite canvas -->
-  <div id="canvas"
-    @mousedown="startPan"
-    @mousemove="pan"
-    @mouseup="endPan"
-    @mouseleave="endPan"
-    :style="`transform: translate(${offset.x}px, ${offset.y}px)`"
-  >
-    <!-- Windows render here -->
-    <template x-for="win in windows" :key="win.id">
-      <div class="window"
-        :style="`left:${win.x}px; top:${win.y}px; width:${win.w}px; height:${win.h}px; z-index:${win.z}`"
-        @mousedown.stop="focusWindow(win)"
-      >
-        <div class="window-titlebar"
-          @mousedown.stop="startDrag($event, win)"
-        >
-          <span class="window-title" x-text="win.title"></span>
-          <button class="window-close" @click.stop="closeWindow(win)">✕</button>
-        </div>
-        <div class="window-content">
-          <iframe :src="win.url" frameborder="0"></iframe>
-        </div>
-      </div>
-    </template>
-  </div>
+  <!-- Infinite canvas — elements placed by the agent -->
+  <div id="canvas"></div>
+
+  <!-- Cursor dot -->
+  <div id="cursor-dot" x-show="authed" x-cloak></div>
 
-  <!-- Cursor input overlay -->
-  <div id="cursor-input" x-show="inputMode" @click.stop>
+  <!-- Input bubble (Cmd+Space) — follows cursor -->
+  <div id="cursor-bubble"
+    x-show="inputMode"
+    x-cloak
+    :style="`left:${cursorX}px; top:${cursorY}px`"
+  >
     <input
-      id="cursor-input-field"
+      id="cursor-input"
       type="text"
       x-model="cursorQuery"
       @keydown.enter="submitQuery"
       @keydown.escape="exitInputMode"
-      placeholder="Type a command..."
+      placeholder="Ask anything…"
       x-ref="cursorInput"
     />
   </div>
 
-  <!-- Dock -->
-  <div id="dock">
-    <template x-for="app in installedApps" :key="app.id">
-      <div class="dock-icon" @click="openApp(app)" :title="app.name">
-        <span x-text="app.icon"></span>
-        <span class="dock-dot" x-show="app.running"></span>
-      </div>
-    </template>
-    <div id="dock-user" x-show="currentUser" x-text="currentUser"></div>
-  </div>
-
   <script src="js/os.js"></script>
 </body>
 </html>
index cbbe10422dd03d67de996d5f4ffb2ae1970a7bf0..08266ca73ed023642aa789d2cd471ba2cd9cb2a8 100644 (file)
+/* ── Canvas / WebSocket layer ───────────────────────────────────────────────
+   Manages the agent WebSocket connection and renders canvas elements.
+   This layer is pure vanilla JS — Alpine handles auth + cursor input.
+   ─────────────────────────────────────────────────────────────────────────── */
+
+let ws         = null
+let canvasDiv  = null
+const elMap    = {}   // id → DOM element
+
+function connectWS() {
+  const proto = location.protocol === 'https:' ? 'wss' : 'ws'
+  ws = new WebSocket(`${proto}://${location.host}/ws`)
+
+  ws.onopen = () => console.log('agent connected')
+
+  ws.onmessage = (e) => {
+    const msg = JSON.parse(e.data)
+    switch (msg.op) {
+      case 'render':          renderEl(msg.element);              break
+      case 'remove':          removeEl(msg.id);                   break
+      case 'canvas':          msg.elements.forEach(renderEl);     break
+      case 'cursor_response': showCursorResponse(msg);            break
+    }
+  }
+
+  ws.onclose = () => {
+    console.log('agent disconnected — reconnecting in 2s')
+    setTimeout(connectWS, 2000)
+  }
+}
+
+function sendEvent(event) {
+  if (ws && ws.readyState === WebSocket.OPEN)
+    ws.send(JSON.stringify(event))
+}
+
+/* ── Element lifecycle ────────────────────────────────────────────────────── */
+
+function removeEl(id) {
+  const div = elMap[id]
+  if (div) { div.remove(); delete elMap[id] }
+}
+
+function renderEl(el) {
+  removeEl(el.id)
+
+  const div = document.createElement('div')
+  div.dataset.elId = el.id
+  div.classList.add('canvas-element', el.type.replace(/_/g, '-'))
+  div.style.left = el.x + 'px'
+  div.style.top  = el.y + 'px'
+
+  if (el.type === 'app_icon') {
+    buildAppIcon(div, el)
+  } else if (el.type === 'window') {
+    buildWindow(div, el)
+  }
+
+  canvasDiv.appendChild(div)
+  elMap[el.id] = div
+  makeDraggable(div, el)
+}
+
+/* ── App icon ─────────────────────────────────────────────────────────────── */
+
+function buildAppIcon(div, el) {
+  const icon  = el.props?.icon  || '📦'
+  const label = el.props?.label || el.props?.name || el.component
+  const dot   = el.running ? '<div class="icon-dot"></div>' : ''
+
+  div.innerHTML = `
+    <div class="icon-glyph">${icon}</div>
+    <div class="icon-label">${label}</div>
+    ${dot}
+  `
+
+  div.addEventListener('click', () => {
+    sendEvent({
+      type:     'app_icon.clicked',
+      data:     { id: el.id, component: el.component },
+      position: { x: el.x, y: el.y },
+    })
+  })
+}
+
+/* ── Window (content card) ────────────────────────────────────────────────── */
+
+function buildWindow(div, el) {
+  const content = document.createElement('div')
+  content.className = 'window-content'
+  content.innerHTML = '<div class="loading">Loading…</div>'
+  div.appendChild(content)
+
+  // Apply explicit size if provided
+  if (el.props?.width)  div.style.width  = el.props.width  + 'px'
+  if (el.props?.height) div.style.height = el.props.height + 'px'
+
+  const parts   = el.component.split('.')
+  const appId   = parts[0]
+  const compName = parts[1] || parts[0]
+  const url     = `/components/${appId}/${compName}.html`
+
+  fetch(url)
+    .then(r => r.ok ? r.text() : Promise.reject(r.status))
+    .then(html => {
+      content.innerHTML = html
+      // Make props available to component scripts via window scope
+      window.__componentProps = el.props || {}
+      // Re-execute inline scripts (innerHTML doesn't run scripts)
+      content.querySelectorAll('script').forEach(old => {
+        const s = document.createElement('script')
+        s.textContent = old.textContent
+        old.replaceWith(s)
+      })
+    })
+    .catch(() => {
+      content.innerHTML = `<div class="loading">${el.component}</div>`
+    })
+
+  // Forward data-event button clicks to agent
+  div.addEventListener('click', (e) => {
+    const btn = e.target.closest('button[data-event]')
+    if (btn) {
+      sendEvent({
+        type: 'button.clicked',
+        data: { window: el.id, event: btn.dataset.event, value: btn.dataset.value },
+      })
+    }
+  })
+}
+
+/* ── Cursor response ──────────────────────────────────────────────────────── */
+
+function showCursorResponse(msg) {
+  removeEl('cursor_response')
+
+  const div = document.createElement('div')
+  div.dataset.elId = 'cursor_response'
+  div.classList.add('canvas-element', 'cursor-response')
+  div.style.left = msg.position.x + 'px'
+  div.style.top  = msg.position.y + 'px'
+
+  let html = ''
+  if (msg.text) html += `<p>${msg.text}</p>`
+  if (msg.pills?.length) {
+    html += '<div class="response-pills">'
+    msg.pills.forEach(p => { html += `<button data-pill="${p}">${p}</button>` })
+    html += '</div>'
+  }
+  div.innerHTML = html
+
+  div.querySelectorAll('[data-pill]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      sendEvent({ type: 'pill.clicked', data: { text: btn.dataset.pill } })
+      removeEl('cursor_response')
+    })
+  })
+
+  // Dismiss on outside click
+  const dismiss = (e) => {
+    if (!div.contains(e.target)) {
+      removeEl('cursor_response')
+      document.removeEventListener('click', dismiss)
+    }
+  }
+  setTimeout(() => document.addEventListener('click', dismiss), 50)
+
+  canvasDiv.appendChild(div)
+  elMap['cursor_response'] = div
+}
+
+/* ── Drag ─────────────────────────────────────────────────────────────────── */
+
+function makeDraggable(div, el) {
+  let startX, startY, startL, startT
+
+  const handle = div  // drag from anywhere on element
+
+  handle.addEventListener('mousedown', (e) => {
+    // Don't initiate drag from interactive elements inside windows
+    if (el.type === 'window' && (
+      e.target.tagName === 'BUTTON' ||
+      e.target.tagName === 'INPUT'  ||
+      e.target.tagName === 'A'      ||
+      e.target.tagName === 'SELECT'
+    )) return
+
+    e.preventDefault()
+    startX = e.clientX
+    startY = e.clientY
+    startL = parseInt(div.style.left) || 0
+    startT = parseInt(div.style.top)  || 0
+
+    const onMove = (e) => {
+      div.style.left = (startL + e.clientX - startX) + 'px'
+      div.style.top  = (startT + e.clientY - startY) + 'px'
+    }
+    const onUp = () => {
+      window.removeEventListener('mousemove', onMove)
+      window.removeEventListener('mouseup',   onUp)
+    }
+    window.addEventListener('mousemove', onMove)
+    window.addEventListener('mouseup',   onUp)
+  })
+}
+
+/* ── Alpine component (auth + cursor input) ─────────────────────────────────
+   Keeps Alpine scope minimal — just auth state and cursor input.
+   ─────────────────────────────────────────────────────────────────────────── */
+
+const cursorDot = document.getElementById('cursor-dot')
+
 document.addEventListener('alpine:init', () => {
   Alpine.data('os', () => ({
 
-    // ── Auth state ────────────────────────────────────────────────────────
+    // Auth
     authed:        false,
-    currentUser:   '',
     loginUsername: '',
     loginPassword: '',
     loginError:    '',
 
+    // Cursor input
+    inputMode:   false,
+    cursorQuery: '',
+    cursorX:     0,
+    cursorY:     0,
+
+    trackCursor(e) {
+      this.cursorX = e.clientX
+      this.cursorY = e.clientY
+      if (cursorDot) {
+        cursorDot.style.left = e.clientX + 'px'
+        cursorDot.style.top  = e.clientY + 'px'
+      }
+    },
+
+    async init() {
+      canvasDiv = document.getElementById('canvas')
+      await this.checkAuth()
+      if (this.authed) connectWS()
+    },
+
+    async checkAuth() {
+      const token = sessionStorage.getItem('auth_token')
+      if (!token) return
+      try {
+        const res = await fetch('/auth/me', {
+          headers: { Authorization: `Bearer ${token}` },
+        })
+        if (res.ok) {
+          this.authed = true
+        } else {
+          sessionStorage.removeItem('auth_token')
+        }
+      } catch (_) { /* auth service down */ }
+    },
+
     async submitLogin() {
       this.loginError = ''
       try {
-        const res  = await fetch('/auth', {
+        const res  = await fetch('/auth/auth', {
           method:  'POST',
           headers: { 'Content-Type': 'application/json' },
           body:    JSON.stringify({
@@ -25,124 +272,18 @@ document.addEventListener('alpine:init', () => {
           return
         }
         sessionStorage.setItem('auth_token', data.token)
-        this.currentUser   = data.username
         this.loginPassword = ''
-        this.authed        = true
-        await this.loadApps()
-        const bio = this.installedApps.find(a => a.id === 'bio')
-        if (bio) this.openApp(bio)
-      } catch (e) {
-        this.loginError = 'Auth service unreachable'
-      }
-    },
-
-    async checkAuth() {
-      const token = sessionStorage.getItem('auth_token')
-      if (!token) return
-
-      try {
-        const res  = await fetch('/me', {
-          headers: { 'Authorization': `Bearer ${token}` },
-        })
-        if (res.ok) {
-          const data     = await res.json()
-          this.currentUser = data.username
-          this.authed      = true
-        } else {
-          sessionStorage.removeItem('auth_token')
-        }
+        this.authed = true
+        connectWS()
       } catch (_) {
-        /* auth service down — leave authed=false */
-      }
-    },
-
-    // ── Canvas pan state ──────────────────────────────────────────────────
-    offset:   { x: 0, y: 0 },
-    isPanning: false,
-    panStart: { x: 0, y: 0 },
-
-    startPan(e) {
-      if (e.target !== document.getElementById('canvas') &&
-          !e.target.closest('#canvas') ||
-          e.target.closest('.window')) return
-      this.isPanning = true
-      this.panStart = { x: e.clientX - this.offset.x, y: e.clientY - this.offset.y }
-      document.getElementById('canvas').classList.add('panning')
-    },
-
-    pan(e) {
-      if (!this.isPanning) return
-      this.offset = { x: e.clientX - this.panStart.x, y: e.clientY - this.panStart.y }
-    },
-
-    endPan() {
-      this.isPanning = false
-      document.getElementById('canvas').classList.remove('panning')
-    },
-
-    // ── Windows ───────────────────────────────────────────────────────────
-    windows:   [],
-    zCounter:  100,
-
-    openApp(app) {
-      // Bring to front if already open
-      const existing = this.windows.find(w => w.appId === app.id)
-      if (existing) { this.focusWindow(existing); return }
-
-      this.windows.push({
-        id:    Date.now(),
-        appId: app.id,
-        title: app.name,
-        url:   app.url,
-        x:     120 + Math.random() * 200,
-        y:     80  + Math.random() * 100,
-        w:     900,
-        h:     600,
-        z:     ++this.zCounter,
-      })
-    },
-
-    focusWindow(win) {
-      win.z = ++this.zCounter
-    },
-
-    closeWindow(win) {
-      this.windows = this.windows.filter(w => w.id !== win.id)
-    },
-
-    // ── Window drag ───────────────────────────────────────────────────────
-    dragging:  null,
-    dragStart: { x: 0, y: 0 },
-
-    startDrag(e, win) {
-      this.focusWindow(win)
-      this.dragging  = win
-      this.dragStart = { x: e.clientX - win.x, y: e.clientY - win.y }
-
-      const onMove = (e) => {
-        if (!this.dragging) return
-        this.dragging.x = e.clientX - this.dragStart.x
-        this.dragging.y = e.clientY - this.dragStart.y
-      }
-      const onUp = () => {
-        this.dragging = null
-        window.removeEventListener('mousemove', onMove)
-        window.removeEventListener('mouseup', onUp)
+        this.loginError = 'Auth service unreachable'
       }
-      window.addEventListener('mousemove', onMove)
-      window.addEventListener('mouseup', onUp)
     },
 
-    // ── Cursor input mode ─────────────────────────────────────────────────
-    inputMode:   false,
-    cursorQuery: '',
-
     handleKeydown(e) {
-      // ⌘+Space or Ctrl+Space toggles input mode
       if ((e.metaKey || e.ctrlKey) && e.code === 'Space') {
         e.preventDefault()
         this.inputMode ? this.exitInputMode() : this.enterInputMode()
-        return
       }
     },
 
@@ -158,46 +299,15 @@ document.addEventListener('alpine:init', () => {
     },
 
     submitQuery() {
-      const query = this.cursorQuery.trim().toLowerCase()
-
-      // Check if it matches an installed app name
-      const app = this.installedApps.find(a => a.name.toLowerCase() === query)
-      if (app) { this.openApp(app); this.exitInputMode(); return }
-
-      // Otherwise — send to LLM (placeholder)
-      console.log('LLM query:', query)
+      const text = this.cursorQuery.trim()
+      if (!text) { this.exitInputMode(); return }
+      sendEvent({
+        type:     'cursor.input',
+        text,
+        position: { x: this.cursorX, y: this.cursorY },
+      })
       this.exitInputMode()
     },
 
-    // ── Installed apps ────────────────────────────────────────────────────
-    installedApps: [],
-
-    async loadApps() {
-      try {
-        const res  = await fetch('/dock/apps')
-        const data = await res.json()
-        this.installedApps = data.map(app => ({
-          ...app,
-          url: `/apps/${app.id}`,
-        }))
-      } catch (e) {
-        console.error('dock unreachable:', e)
-      }
-    },
-
-    // ── Init ──────────────────────────────────────────────────────────────
-    async init() {
-      await this.checkAuth()
-
-      if (this.authed) {
-        await this.loadApps()
-        // Refresh running status every 10s
-        setInterval(() => this.loadApps(), 10_000)
-        // Open bio full-screen on load
-        const bio = this.installedApps.find(a => a.id === 'bio')
-        if (bio) this.openApp(bio)
-      }
-    }
-
   }))
 })
index 58782b854ee738b3134361370d4eea338f731778..cb55a7a5900e3072c5dd8c408ba66c7779f60f35 100644 (file)
@@ -5,20 +5,17 @@ html, body {
   width: 100%;
   height: 100%;
   overflow: hidden;
-  background: #1a1a1a;
+  background: #e8e8e8;
   font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
 }
 
 #canvas {
   position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  will-change: transform;
-  cursor: grab;
+  inset: 0;
+  overflow: hidden;
 }
 
-#canvas.panning {
-  cursor: grabbing;
+.canvas-element {
+  position: absolute;
+  user-select: none;
 }
index b40c15c8ae55e0ec3d3b9f2194f7c950952d9946..d2f1fe56fd0ee338e0b052fb7d9fabc5d7d10f94 100644 (file)
@@ -1,24 +1,39 @@
-#cursor-input {
+/* Hide system cursor everywhere */
+* { cursor: none !important; }
+
+/* Dot cursor */
+#cursor-dot {
   position: fixed;
-  top: 50%;
-  left: 50%;
+  width: 8px;
+  height: 8px;
+  background: #1a1a1a;
+  border-radius: 50%;
+  pointer-events: none;
   transform: translate(-50%, -50%);
-  z-index: 99999;
+  z-index: 9999;
+  transition: width 0.1s, height 0.1s;
+}
+
+/* Input bubble — anchored to cursor position via inline style */
+#cursor-bubble {
+  position: fixed;
+  transform: translate(-50%, calc(-100% - 14px));
+  z-index: 9998;
+  pointer-events: auto;
 }
 
-#cursor-input-field {
-  width: 480px;
-  padding: 14px 20px;
-  font-size: 1.1rem;
+#cursor-input {
+  background: white;
   border: none;
-  border-radius: 12px;
-  background: rgba(255,255,255,0.95);
-  backdrop-filter: blur(20px);
-  box-shadow: 0 24px 80px rgba(0,0,0,0.4);
+  border-radius: 20px;
+  padding: 9px 18px;
+  font-size: 14px;
+  color: #1a1a1a;
+  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.14);
   outline: none;
-  color: #111;
+  width: 280px;
 }
 
-#cursor-input-field::placeholder {
+#cursor-input::placeholder {
   color: #aaa;
 }
index 1afd86cf27506beb108c3e76386de220fc99f2c8..54d3aec9169d5d4dc9a9276eef323040e772f457 100644 (file)
@@ -5,79 +5,73 @@
   display: flex;
   align-items: center;
   justify-content: center;
-  background: rgba(0, 0, 0, 0.6);
-  backdrop-filter: blur(24px);
-  -webkit-backdrop-filter: blur(24px);
+  background: rgba(232, 232, 232, 0.85);
+  backdrop-filter: blur(12px);
+  -webkit-backdrop-filter: blur(12px);
 }
 
 .login-card {
-  background: rgba(255, 255, 255, 0.1);
-  border: 1px solid rgba(255, 255, 255, 0.2);
+  background: white;
   border-radius: 20px;
-  padding: 40px 48px;
-  width: 360px;
+  padding: 40px 44px;
+  width: 340px;
   display: flex;
   flex-direction: column;
-  gap: 20px;
-  color: #fff;
+  gap: 14px;
+  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
 }
 
 .login-card h1 {
-  font-size: 1.4rem;
+  font-size: 1.3rem;
   font-weight: 600;
-  text-align: center;
+  color: #1a1a1a;
   letter-spacing: -0.02em;
+  text-align: center;
+  margin-bottom: 6px;
 }
 
 .login-card input {
   width: 100%;
-  padding: 12px 16px;
+  padding: 12px 14px;
   border-radius: 10px;
-  border: 1px solid rgba(255, 255, 255, 0.25);
-  background: rgba(255, 255, 255, 0.08);
-  color: #fff;
+  border: 1.5px solid #e0e0e0;
+  background: #fafafa;
+  color: #1a1a1a;
   font-size: 0.95rem;
   outline: none;
   transition: border-color 0.15s;
+  cursor: text;
 }
 
 .login-card input::placeholder {
-  color: rgba(255, 255, 255, 0.4);
+  color: #bbb;
 }
 
 .login-card input:focus {
-  border-color: rgba(255, 255, 255, 0.55);
+  border-color: #aaa;
+  background: white;
 }
 
 .login-card button {
   padding: 12px;
   border-radius: 10px;
   border: none;
-  background: rgba(255, 255, 255, 0.9);
-  color: #111;
+  background: #1a1a1a;
+  color: white;
   font-size: 0.95rem;
   font-weight: 600;
   cursor: pointer;
   transition: background 0.15s;
+  margin-top: 4px;
 }
 
 .login-card button:hover {
-  background: #fff;
+  background: #333;
 }
 
 .login-error {
-  font-size: 0.85rem;
-  color: #ff6b6b;
+  font-size: 0.83rem;
+  color: #e53e3e;
   text-align: center;
   min-height: 1.2em;
 }
-
-#dock-user {
-  font-size: 0.75rem;
-  color: rgba(255, 255, 255, 0.7);
-  display: flex;
-  align-items: center;
-  padding: 0 8px;
-  cursor: default;
-  user-select: none;
-}
index d077408e16f6a994d519b04b4cb654613ebabf1a..3347110df4340bd8b22bcdb09bdd3ebffd457968 100644 (file)
-.window {
-  position: absolute;
-  display: flex;
-  flex-direction: column;
-  background: #fff;
-  border-radius: 12px;
-  box-shadow: 0 24px 80px rgba(0,0,0,0.5);
-  overflow: hidden;
-  min-width: 300px;
-  min-height: 200px;
-}
+/* ── App icons ────────────────────────────────────────────────────────────── */
 
-.window-titlebar {
+.canvas-element.app-icon {
   display: flex;
+  flex-direction: column;
   align-items: center;
-  justify-content: space-between;
-  padding: 0 12px;
-  height: 40px;
-  background: #f5f5f5;
-  border-bottom: 1px solid #e0e0e0;
-  cursor: move;
-  user-select: none;
-  flex-shrink: 0;
-}
-
-.window-title {
-  font-size: 0.85rem;
-  font-weight: 600;
-  color: #333;
+  gap: 6px;
+  width: 72px;
+  cursor: pointer;
 }
 
-.window-close {
-  background: none;
-  border: none;
-  cursor: pointer;
-  font-size: 0.8rem;
-  color: #999;
-  width: 24px;
-  height: 24px;
-  border-radius: 50%;
+.app-icon .icon-glyph {
+  width: 56px;
+  height: 56px;
+  background: white;
+  border-radius: 14px;
   display: flex;
   align-items: center;
   justify-content: center;
-  transition: background 0.15s, color 0.15s;
+  font-size: 26px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10);
+  transition: transform 0.12s ease, box-shadow 0.12s ease;
+}
+
+.app-icon:hover .icon-glyph {
+  transform: scale(1.08);
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
+}
+
+.app-icon .icon-label {
+  font-size: 11px;
+  font-weight: 500;
+  color: #444;
+  background: rgba(255, 255, 255, 0.72);
+  padding: 2px 8px;
+  border-radius: 10px;
+  backdrop-filter: blur(6px);
+  white-space: nowrap;
+  max-width: 88px;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
-.window-close:hover {
-  background: #ff5f57;
-  color: #fff;
+.app-icon .icon-dot {
+  width: 5px;
+  height: 5px;
+  background: #34c759;
+  border-radius: 50%;
+  margin-top: -3px;
 }
 
-.window-content {
-  flex: 1;
+/* ── Windows — no chrome, just content cards ──────────────────────────────── */
+
+.canvas-element.window {
+  background: white;
+  border-radius: 16px;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
   overflow: hidden;
+  min-width: 320px;
+  min-height: 240px;
 }
 
-.window-content iframe {
+.window .window-content {
   width: 100%;
   height: 100%;
+  overflow: auto;
+}
+
+.window .loading {
+  padding: 24px;
+  color: #aaa;
+  font-size: 13px;
+}
+
+/* ── Cursor response bubble ───────────────────────────────────────────────── */
+
+.canvas-element.cursor-response {
+  background: white;
+  border-radius: 14px;
+  padding: 12px 16px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
+  max-width: 320px;
+  font-size: 14px;
+  color: #333;
+  line-height: 1.5;
+}
+
+.cursor-response p {
+  margin: 0;
+}
+
+.response-pills {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  margin-top: 10px;
+}
+
+.response-pills button {
+  background: #f0f0f0;
   border: none;
+  border-radius: 20px;
+  padding: 5px 14px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #333;
+  cursor: pointer;
+  transition: background 0.12s;
+}
+
+.response-pills button:hover {
+  background: #e0e0e0;
 }