]> Repos - crimata-os/commitdiff
Initial commit
authorAndrew Gundersen <adgundersen@gmail.com>
Wed, 25 Feb 2026 20:35:31 +0000 (15:35 -0500)
committerAndrew Gundersen <adgundersen@gmail.com>
Wed, 25 Feb 2026 20:35:31 +0000 (15:35 -0500)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
20 files changed:
.DS_Store [new file with mode: 0644]
apps/.DS_Store [new file with mode: 0644]
apps/contacts/crimata.json [new file with mode: 0644]
apps/contacts/package.json [new file with mode: 0644]
apps/contacts/src/db.ts [new file with mode: 0644]
apps/contacts/src/index.ts [new file with mode: 0644]
apps/contacts/src/migrate.ts [new file with mode: 0644]
apps/contacts/src/routes.ts [new file with mode: 0644]
apps/contacts/src/types.ts [new file with mode: 0644]
apps/contacts/tsconfig.json [new file with mode: 0644]
auth/Makefile [new file with mode: 0644]
auth/src/main.c [new file with mode: 0644]
auth/src/pam_auth.c [new file with mode: 0644]
auth/src/pam_auth.h [new file with mode: 0644]
ui/index.html [new file with mode: 0644]
ui/js/os.js [new file with mode: 0644]
ui/styles/canvas.css [new file with mode: 0644]
ui/styles/cursor.css [new file with mode: 0644]
ui/styles/dock.css [new file with mode: 0644]
ui/styles/window.css [new file with mode: 0644]

diff --git a/.DS_Store b/.DS_Store
new file mode 100644 (file)
index 0000000..c455380
Binary files /dev/null and b/.DS_Store differ
diff --git a/apps/.DS_Store b/apps/.DS_Store
new file mode 100644 (file)
index 0000000..07cb401
Binary files /dev/null and b/apps/.DS_Store differ
diff --git a/apps/contacts/crimata.json b/apps/contacts/crimata.json
new file mode 100644 (file)
index 0000000..d63cf4b
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "id": "contacts",
+  "name": "Contacts",
+  "port": 3001,
+  "db": true,
+  "migrate": "npm run migrate"
+}
diff --git a/apps/contacts/package.json b/apps/contacts/package.json
new file mode 100644 (file)
index 0000000..89c2482
--- /dev/null
@@ -0,0 +1,22 @@
+{
+  "name": "crimata-contacts",
+  "version": "1.0.0",
+  "private": true,
+  "scripts": {
+    "dev": "tsx watch src/index.ts",
+    "build": "tsc",
+    "start": "node dist/index.js",
+    "migrate": "node -r tsx/cjs src/migrate.ts"
+  },
+  "dependencies": {
+    "express": "^4.18.0",
+    "pg": "^8.11.0"
+  },
+  "devDependencies": {
+    "@types/express": "^4.17.0",
+    "@types/node": "^20.0.0",
+    "@types/pg": "^8.11.0",
+    "tsx": "^4.7.0",
+    "typescript": "^5.4.0"
+  }
+}
diff --git a/apps/contacts/src/db.ts b/apps/contacts/src/db.ts
new file mode 100644 (file)
index 0000000..0f2d4a9
--- /dev/null
@@ -0,0 +1,5 @@
+import { Pool } from "pg"
+
+export const pool = new Pool({
+  connectionString: process.env.DATABASE_URL,
+})
diff --git a/apps/contacts/src/index.ts b/apps/contacts/src/index.ts
new file mode 100644 (file)
index 0000000..b9b23f5
--- /dev/null
@@ -0,0 +1,9 @@
+import express from "express"
+import { router } from "./routes"
+
+const app = express()
+app.use(express.json())
+app.use("/contacts", router)
+
+const port = Number(process.env.PORT ?? 3001)
+app.listen(port, () => console.log(`contacts app listening on :${port}`))
diff --git a/apps/contacts/src/migrate.ts b/apps/contacts/src/migrate.ts
new file mode 100644 (file)
index 0000000..6aa0628
--- /dev/null
@@ -0,0 +1,21 @@
+import { pool } from "./db"
+
+async function migrate() {
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS contacts (
+      id         SERIAL PRIMARY KEY,
+      name       TEXT NOT NULL,
+      domain     TEXT NOT NULL UNIQUE,
+      avatar_url TEXT,
+      notes      TEXT,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+    )
+  `)
+  console.log("contacts: migration complete")
+  await pool.end()
+}
+
+migrate().catch((err) => {
+  console.error("contacts: migration failed", err)
+  process.exit(1)
+})
diff --git a/apps/contacts/src/routes.ts b/apps/contacts/src/routes.ts
new file mode 100644 (file)
index 0000000..3cc0668
--- /dev/null
@@ -0,0 +1,61 @@
+import { Router } from "express"
+import { pool } from "./db"
+import type { CreateContactBody, UpdateContactBody } from "./types"
+
+export const router = Router()
+
+// List all contacts
+router.get("/", async (req, res) => {
+  const result = await pool.query(
+    "SELECT * FROM contacts ORDER BY name ASC"
+  )
+  res.json(result.rows)
+})
+
+// Get a single contact
+router.get("/:id", async (req, res) => {
+  const result = await pool.query(
+    "SELECT * FROM contacts WHERE id = $1",
+    [req.params.id]
+  )
+  if (result.rowCount === 0) return res.status(404).json({ error: "Not found" })
+  res.json(result.rows[0])
+})
+
+// Create a contact
+router.post("/", async (req, res) => {
+  const { name, domain, avatar_url, notes } = req.body as CreateContactBody
+  if (!name || !domain) {
+    return res.status(400).json({ error: "name and domain are required" })
+  }
+  const result = await pool.query(
+    `INSERT INTO contacts (name, domain, avatar_url, notes)
+     VALUES ($1, $2, $3, $4)
+     RETURNING *`,
+    [name, domain, avatar_url ?? null, notes ?? null]
+  )
+  res.status(201).json(result.rows[0])
+})
+
+// Update a contact
+router.patch("/:id", async (req, res) => {
+  const { name, domain, avatar_url, notes } = req.body as UpdateContactBody
+  const result = await pool.query(
+    `UPDATE contacts
+     SET name       = COALESCE($1, name),
+         domain     = COALESCE($2, domain),
+         avatar_url = COALESCE($3, avatar_url),
+         notes      = COALESCE($4, notes)
+     WHERE id = $5
+     RETURNING *`,
+    [name ?? null, domain ?? null, avatar_url ?? null, notes ?? null, req.params.id]
+  )
+  if (result.rowCount === 0) return res.status(404).json({ error: "Not found" })
+  res.json(result.rows[0])
+})
+
+// Delete a contact
+router.delete("/:id", async (req, res) => {
+  await pool.query("DELETE FROM contacts WHERE id = $1", [req.params.id])
+  res.status(204).send()
+})
diff --git a/apps/contacts/src/types.ts b/apps/contacts/src/types.ts
new file mode 100644 (file)
index 0000000..1963e4b
--- /dev/null
@@ -0,0 +1,22 @@
+export interface Contact {
+  id: number
+  name: string
+  domain: string
+  avatar_url: string | null
+  notes: string | null
+  created_at: string
+}
+
+export interface CreateContactBody {
+  name: string
+  domain: string
+  avatar_url?: string
+  notes?: string
+}
+
+export interface UpdateContactBody {
+  name?: string
+  domain?: string
+  avatar_url?: string
+  notes?: string
+}
diff --git a/apps/contacts/tsconfig.json b/apps/contacts/tsconfig.json
new file mode 100644 (file)
index 0000000..daf3709
--- /dev/null
@@ -0,0 +1,13 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "commonjs",
+    "lib": ["ES2022"],
+    "outDir": "dist",
+    "rootDir": "src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true
+  },
+  "include": ["src"]
+}
diff --git a/auth/Makefile b/auth/Makefile
new file mode 100644 (file)
index 0000000..9cc64b9
--- /dev/null
@@ -0,0 +1,13 @@
+CC      = gcc
+CFLAGS  = -Wall -Wextra -O2
+LIBS    = -lpam -lmicrohttpd
+SRC     = src/main.c src/pam_auth.c
+OUT     = crimata-auth
+
+.PHONY: all clean
+
+all:
+       $(CC) $(CFLAGS) $(SRC) $(LIBS) -o $(OUT)
+
+clean:
+       rm -f $(OUT)
diff --git a/auth/src/main.c b/auth/src/main.c
new file mode 100644 (file)
index 0000000..5476a1c
--- /dev/null
@@ -0,0 +1,149 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <microhttpd.h>
+#include "pam_auth.h"
+
+#define PORT       7700
+#define MAX_BODY   4096
+
+/* ── JSON helpers ─────────────────────────────────────────────────────────── */
+
+static const char *json_get(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 NULL;
+
+    pos += strlen(search);
+    while (*pos == ' ' || *pos == ':' || *pos == ' ') pos++;
+    if (*pos != '"') return NULL;
+    pos++;
+
+    size_t i = 0;
+    while (*pos && *pos != '"' && i < out_len - 1)
+        out[i++] = *pos++;
+    out[i] = '\0';
+    return out;
+}
+
+static enum MHD_Result send_json(struct MHD_Connection *conn,
+                                  unsigned int status,
+                                  const char *body)
+{
+    struct MHD_Response *resp = MHD_create_response_from_buffer(
+        strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY);
+    MHD_add_response_header(resp, "Content-Type", "application/json");
+    enum MHD_Result ret = MHD_queue_response(conn, status, resp);
+    MHD_destroy_response(resp);
+    return ret;
+}
+
+/* ── Request context ──────────────────────────────────────────────────────── */
+
+typedef struct {
+    char   body[MAX_BODY];
+    size_t body_len;
+} request_ctx_t;
+
+/* ── Route handlers ───────────────────────────────────────────────────────── */
+
+static enum MHD_Result handle_health(struct MHD_Connection *conn)
+{
+    return send_json(conn, MHD_HTTP_OK, "{\"status\":\"ok\"}");
+}
+
+static enum MHD_Result handle_auth(struct MHD_Connection *conn, const char *body)
+{
+    char username[256] = {0};
+    char password[256] = {0};
+
+    if (!json_get(body, "username", username, sizeof(username)) ||
+        !json_get(body, "password", password, sizeof(password))) {
+        return send_json(conn, MHD_HTTP_BAD_REQUEST,
+                         "{\"success\":false,\"error\":\"username and password required\"}");
+    }
+
+    int result = authenticate(username, password);
+    if (result == 0) {
+        return send_json(conn, MHD_HTTP_OK, "{\"success\":true}");
+    }
+    return send_json(conn, MHD_HTTP_UNAUTHORIZED,
+                     "{\"success\":false,\"error\":\"invalid credentials\"}");
+}
+
+/* ── Main handler ─────────────────────────────────────────────────────────── */
+
+static enum MHD_Result handler(void *cls,
+                                struct MHD_Connection *conn,
+                                const char *url,
+                                const char *method,
+                                const char *version,
+                                const char *upload_data,
+                                size_t *upload_data_size,
+                                void **con_cls)
+{
+    (void)cls; (void)version;
+
+    /* Health check */
+    if (strcmp(url, "/health") == 0 && strcmp(method, "GET") == 0)
+        return handle_health(conn);
+
+    /* Auth — collect body first */
+    if (strcmp(url, "/auth") == 0 && strcmp(method, "POST") == 0) {
+        if (!*con_cls) {
+            request_ctx_t *ctx = calloc(1, sizeof(request_ctx_t));
+            if (!ctx) return MHD_NO;
+            *con_cls = ctx;
+            return MHD_YES;
+        }
+
+        request_ctx_t *ctx = *con_cls;
+
+        if (*upload_data_size > 0) {
+            size_t remaining = MAX_BODY - ctx->body_len - 1;
+            size_t to_copy   = *upload_data_size < remaining ? *upload_data_size : remaining;
+            memcpy(ctx->body + ctx->body_len, upload_data, to_copy);
+            ctx->body_len    += to_copy;
+            *upload_data_size = 0;
+            return MHD_YES;
+        }
+
+        return handle_auth(conn, ctx->body);
+    }
+
+    return send_json(conn, MHD_HTTP_NOT_FOUND, "{\"error\":\"not found\"}");
+}
+
+static void request_completed(void *cls, struct MHD_Connection *conn,
+                               void **con_cls, enum MHD_RequestTerminationCode toe)
+{
+    (void)cls; (void)conn; (void)toe;
+    if (*con_cls) { free(*con_cls); *con_cls = NULL; }
+}
+
+/* ── Entry point ──────────────────────────────────────────────────────────── */
+
+int main(void)
+{
+    struct MHD_Daemon *daemon = MHD_start_daemon(
+        MHD_USE_INTERNAL_POLLING_THREAD,
+        PORT,
+        NULL, NULL,
+        &handler, NULL,
+        MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
+        MHD_OPTION_END
+    );
+
+    if (!daemon) {
+        fprintf(stderr, "failed to start daemon on port %d\n", PORT);
+        return 1;
+    }
+
+    printf("crimata-auth listening on :%d\n", PORT);
+    getchar(); /* block until killed */
+
+    MHD_stop_daemon(daemon);
+    return 0;
+}
diff --git a/auth/src/pam_auth.c b/auth/src/pam_auth.c
new file mode 100644 (file)
index 0000000..e3b4b1d
--- /dev/null
@@ -0,0 +1,50 @@
+#include <stdlib.h>
+#include <string.h>
+#include <security/pam_appl.h>
+#include "pam_auth.h"
+
+typedef struct {
+    const char *password;
+} pam_credentials_t;
+
+static int pam_conversation(int num_msg, const struct pam_message **msg,
+                            struct pam_response **resp, void *appdata_ptr)
+{
+    pam_credentials_t *creds = (pam_credentials_t *)appdata_ptr;
+
+    *resp = calloc(num_msg, sizeof(struct pam_response));
+    if (!*resp) return PAM_BUF_ERR;
+
+    for (int i = 0; i < num_msg; i++) {
+        if (msg[i]->msg_style == PAM_PROMPT_ECHO_OFF ||
+            msg[i]->msg_style == PAM_PROMPT_ECHO_ON) {
+            (*resp)[i].resp = strdup(creds->password);
+            if (!(*resp)[i].resp) {
+                free(*resp);
+                return PAM_BUF_ERR;
+            }
+        }
+    }
+
+    return PAM_SUCCESS;
+}
+
+int authenticate(const char *username, const char *password)
+{
+    pam_credentials_t creds = { .password = password };
+    struct pam_conv conv    = { pam_conversation, &creds };
+    pam_handle_t *pamh      = NULL;
+    int result;
+
+    result = pam_start("login", username, &conv, &pamh);
+    if (result != PAM_SUCCESS) goto done;
+
+    result = pam_authenticate(pamh, PAM_SILENT);
+    if (result != PAM_SUCCESS) goto done;
+
+    result = pam_acct_mgmt(pamh, PAM_SILENT);
+
+done:
+    pam_end(pamh, result);
+    return (result == PAM_SUCCESS) ? 0 : -1;
+}
diff --git a/auth/src/pam_auth.h b/auth/src/pam_auth.h
new file mode 100644 (file)
index 0000000..1d03bcc
--- /dev/null
@@ -0,0 +1,10 @@
+#ifndef PAM_AUTH_H
+#define PAM_AUTH_H
+
+/*
+ * authenticate - verify username/password against PAM.
+ * Returns 0 on success, -1 on failure.
+ */
+int authenticate(const char *username, const char *password);
+
+#endif
diff --git a/ui/index.html b/ui/index.html
new file mode 100644 (file)
index 0000000..6f10d71
--- /dev/null
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <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" />
+  <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">
+
+  <!-- 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>
+
+  <!-- Cursor input overlay -->
+  <div id="cursor-input" x-show="inputMode" @click.stop>
+    <input
+      id="cursor-input-field"
+      type="text"
+      x-model="cursorQuery"
+      @keydown.enter="submitQuery"
+      @keydown.escape="exitInputMode"
+      placeholder="Type a command..."
+      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>
+      </div>
+    </template>
+  </div>
+
+  <script src="js/os.js"></script>
+</body>
+</html>
diff --git a/ui/js/os.js b/ui/js/os.js
new file mode 100644 (file)
index 0000000..d02dae4
--- /dev/null
@@ -0,0 +1,140 @@
+document.addEventListener('alpine:init', () => {
+  Alpine.data('os', () => ({
+
+    // ── 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)
+      }
+      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
+      }
+    },
+
+    enterInputMode() {
+      this.inputMode   = true
+      this.cursorQuery = ''
+      this.$nextTick(() => this.$refs.cursorInput?.focus())
+    },
+
+    exitInputMode() {
+      this.inputMode   = false
+      this.cursorQuery = ''
+    },
+
+    submitQuery() {
+      const query = this.cursorQuery.trim().toLowerCase()
+
+      // Built-in commands
+      if (query === 'login') {
+        this.openApp({ id: 'login', name: 'Login', url: '/login', icon: '🔐' })
+        this.exitInputMode()
+        return
+      }
+
+      // 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)
+      this.exitInputMode()
+    },
+
+    // ── Installed apps ────────────────────────────────────────────────────
+    // Loaded from crimata.json manifests on the server
+    installedApps: [
+      { id: 'bio',      name: 'Bio',      icon: '🪪', url: '/apps/bio' },
+      { id: 'contacts', name: 'Contacts', icon: '👥', url: '/apps/contacts' },
+      { id: 'blog',     name: 'Blog',     icon: '📝', url: '/apps/blog' },
+    ],
+
+    // ── Init ──────────────────────────────────────────────────────────────
+    init() {
+      // Open bio full-screen on load
+      const bio = this.installedApps.find(a => a.id === 'bio')
+      if (bio) this.openApp(bio)
+    }
+
+  }))
+})
diff --git a/ui/styles/canvas.css b/ui/styles/canvas.css
new file mode 100644 (file)
index 0000000..d730ff5
--- /dev/null
@@ -0,0 +1,23 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  background: #1a1a1a;
+  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;
+}
+
+#canvas.panning {
+  cursor: grabbing;
+}
diff --git a/ui/styles/cursor.css b/ui/styles/cursor.css
new file mode 100644 (file)
index 0000000..b40c15c
--- /dev/null
@@ -0,0 +1,24 @@
+#cursor-input {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 99999;
+}
+
+#cursor-input-field {
+  width: 480px;
+  padding: 14px 20px;
+  font-size: 1.1rem;
+  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);
+  outline: none;
+  color: #111;
+}
+
+#cursor-input-field::placeholder {
+  color: #aaa;
+}
diff --git a/ui/styles/dock.css b/ui/styles/dock.css
new file mode 100644 (file)
index 0000000..bcb87c3
--- /dev/null
@@ -0,0 +1,34 @@
+#dock {
+  position: fixed;
+  bottom: 24px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  gap: 12px;
+  background: rgba(255,255,255,0.15);
+  backdrop-filter: blur(20px);
+  -webkit-backdrop-filter: blur(20px);
+  padding: 10px 16px;
+  border-radius: 20px;
+  border: 1px solid rgba(255,255,255,0.2);
+  z-index: 9999;
+}
+
+.dock-icon {
+  width: 52px;
+  height: 52px;
+  border-radius: 12px;
+  background: rgba(255,255,255,0.2);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 1.6rem;
+  cursor: pointer;
+  transition: transform 0.15s, background 0.15s;
+  user-select: none;
+}
+
+.dock-icon:hover {
+  transform: translateY(-8px) scale(1.1);
+  background: rgba(255,255,255,0.35);
+}
diff --git a/ui/styles/window.css b/ui/styles/window.css
new file mode 100644 (file)
index 0000000..d077408
--- /dev/null
@@ -0,0 +1,61 @@
+.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;
+}
+
+.window-titlebar {
+  display: flex;
+  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;
+}
+
+.window-close {
+  background: none;
+  border: none;
+  cursor: pointer;
+  font-size: 0.8rem;
+  color: #999;
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background 0.15s, color 0.15s;
+}
+
+.window-close:hover {
+  background: #ff5f57;
+  color: #fff;
+}
+
+.window-content {
+  flex: 1;
+  overflow: hidden;
+}
+
+.window-content iframe {
+  width: 100%;
+  height: 100%;
+  border: none;
+}