Add crimata-agent and redesign agentic OS UI
- 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>
This commit is contained in:
parent
44f3192b78
commit
31b5e321a1
19 changed files with 1030 additions and 305 deletions
20
agent/package.json
Normal file
20
agent/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
agent/src/agent.ts
Normal file
155
agent/src/agent.ts
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
26
agent/src/canvas.ts
Normal file
26
agent/src/canvas.ts
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
60
agent/src/index.ts
Normal file
60
agent/src/index.ts
Normal file
|
|
@ -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}`)
|
||||||
|
})
|
||||||
66
agent/src/tools.ts
Normal file
66
agent/src/tools.ts
Normal file
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
42
agent/src/types.ts
Normal file
42
agent/src/types.ts
Normal file
|
|
@ -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 } }
|
||||||
14
agent/tsconfig.json
Normal file
14
agent/tsconfig.json
Normal file
|
|
@ -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/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,54 @@
|
||||||
{
|
{
|
||||||
"id": "contacts",
|
"id": "contacts",
|
||||||
"name": "Contacts",
|
"name": "Contacts",
|
||||||
|
"icon": "👤",
|
||||||
"port": 3001,
|
"port": 3001,
|
||||||
|
"defaultComponent": "contacts.list",
|
||||||
|
"components": ["contacts.list", "contacts.card"],
|
||||||
"db": true,
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
#include "apps.h"
|
#include "apps.h"
|
||||||
|
|
||||||
#define MANIFEST_GLOB "/usr/lib/crimata-*/crimata.json"
|
#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)
|
static int json_str(const char *json, const char *key, char *out, size_t out_len)
|
||||||
{
|
{
|
||||||
char search[128];
|
char search[128];
|
||||||
|
|
@ -42,6 +42,51 @@ static int json_int(const char *json, const char *key, int *out)
|
||||||
return 1;
|
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)
|
static int parse_manifest(const char *path, app_t *app)
|
||||||
{
|
{
|
||||||
FILE *f = fopen(path, "r");
|
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, "id", app->id, sizeof(app->id))) return 0;
|
||||||
if (!json_str(buf, "name", app->name, sizeof(app->name))) return 0;
|
if (!json_str(buf, "name", app->name, sizeof(app->name))) return 0;
|
||||||
json_str(buf, "icon", app->icon, sizeof(app->icon));
|
|
||||||
json_int(buf, "port", &app->port);
|
|
||||||
app->running = 0;
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
#define APPS_H
|
#define APPS_H
|
||||||
|
|
||||||
#define MAX_APPS 32
|
#define MAX_APPS 32
|
||||||
#define MAX_STR 128
|
#define MAX_STR 256
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char id[MAX_STR];
|
char id[MAX_STR];
|
||||||
char name[MAX_STR];
|
char name[MAX_STR];
|
||||||
char icon[MAX_STR];
|
char icon[MAX_STR];
|
||||||
int port;
|
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;
|
} app_t;
|
||||||
|
|
||||||
/* Scan /usr/lib/crimata-*/crimata.json — returns app count */
|
/* Scan /usr/lib/crimata-*/crimata.json — returns app count */
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
#include "systemd.h"
|
#include "systemd.h"
|
||||||
|
|
||||||
#define PORT 7701
|
#define PORT 7701
|
||||||
#define BUF_SIZE 16384
|
#define BUF_SIZE 65536
|
||||||
|
|
||||||
/* ── JSON response helper ─────────────────────────────────────────────────── */
|
/* ── JSON response helper ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|
@ -42,12 +42,29 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn)
|
||||||
pos += snprintf(buf + pos, sizeof(buf) - pos, "[");
|
pos += snprintf(buf + pos, sizeof(buf) - pos, "[");
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
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,
|
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||||
"%s{\"id\":\"%s\",\"name\":\"%s\",\"icon\":\"%s\","
|
"%s{"
|
||||||
"\"port\":%d,\"running\":%s}",
|
"\"id\":\"%s\","
|
||||||
|
"\"name\":\"%s\","
|
||||||
|
"\"icon\":\"%s\","
|
||||||
|
"\"port\":%d,"
|
||||||
|
"\"running\":%s,"
|
||||||
|
"\"defaultComponent\":\"%s\","
|
||||||
|
"\"components\":%s,"
|
||||||
|
"\"api\":%s"
|
||||||
|
"}",
|
||||||
i > 0 ? "," : "",
|
i > 0 ? "," : "",
|
||||||
apps[i].id, apps[i].name, apps[i].icon,
|
apps[i].id,
|
||||||
apps[i].port, apps[i].running ? "true" : "false");
|
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, "]");
|
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,
|
static enum MHD_Result handle_action(struct MHD_Connection *conn,
|
||||||
const char *app_id, int start)
|
const char *app_id, int start)
|
||||||
{
|
{
|
||||||
/* Verify app exists */
|
|
||||||
app_t apps[MAX_APPS];
|
app_t apps[MAX_APPS];
|
||||||
int count = apps_scan(apps);
|
int count = apps_scan(apps);
|
||||||
int found = 0;
|
int found = 0;
|
||||||
|
|
@ -108,10 +124,10 @@ static enum MHD_Result handler(void *cls,
|
||||||
char app_id[MAX_STR];
|
char app_id[MAX_STR];
|
||||||
|
|
||||||
if (strcmp(method, "POST") == 0) {
|
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);
|
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);
|
return handle_action(conn, app_id, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
41
ui/components/contacts/card.html
Normal file
41
ui/components/contacts/card.html
Normal file
|
|
@ -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>
|
||||||
47
ui/components/contacts/list.html
Normal file
47
ui/components/contacts/list.html
Normal file
|
|
@ -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>
|
||||||
|
|
@ -6,12 +6,11 @@
|
||||||
<title>Crimata</title>
|
<title>Crimata</title>
|
||||||
<link rel="stylesheet" href="styles/canvas.css" />
|
<link rel="stylesheet" href="styles/canvas.css" />
|
||||||
<link rel="stylesheet" href="styles/window.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/cursor.css" />
|
||||||
<link rel="stylesheet" href="styles/login.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>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="os" @keydown.window="handleKeydown">
|
<body x-data="os" @keydown.window="handleKeydown" @mousemove.window="trackCursor">
|
||||||
|
|
||||||
<!-- Login overlay (shown until authenticated) -->
|
<!-- Login overlay (shown until authenticated) -->
|
||||||
<div id="login-overlay" x-show="!authed" x-cloak>
|
<div id="login-overlay" x-show="!authed" x-cloak>
|
||||||
|
|
@ -37,57 +36,29 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Infinite canvas -->
|
<!-- Infinite canvas — elements placed by the agent -->
|
||||||
<div id="canvas"
|
<div id="canvas"></div>
|
||||||
@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 -->
|
<!-- Cursor dot -->
|
||||||
<div id="cursor-input" x-show="inputMode" @click.stop>
|
<div id="cursor-dot" x-show="authed" x-cloak></div>
|
||||||
|
|
||||||
|
<!-- Input bubble (Cmd+Space) — follows cursor -->
|
||||||
|
<div id="cursor-bubble"
|
||||||
|
x-show="inputMode"
|
||||||
|
x-cloak
|
||||||
|
:style="`left:${cursorX}px; top:${cursorY}px`"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="cursor-input-field"
|
id="cursor-input"
|
||||||
type="text"
|
type="text"
|
||||||
x-model="cursorQuery"
|
x-model="cursorQuery"
|
||||||
@keydown.enter="submitQuery"
|
@keydown.enter="submitQuery"
|
||||||
@keydown.escape="exitInputMode"
|
@keydown.escape="exitInputMode"
|
||||||
placeholder="Type a command..."
|
placeholder="Ask anything…"
|
||||||
x-ref="cursorInput"
|
x-ref="cursorInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<script src="js/os.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
410
ui/js/os.js
410
ui/js/os.js
|
|
@ -1,17 +1,264 @@
|
||||||
|
/* ── 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', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
Alpine.data('os', () => ({
|
Alpine.data('os', () => ({
|
||||||
|
|
||||||
// ── Auth state ────────────────────────────────────────────────────────
|
// Auth
|
||||||
authed: false,
|
authed: false,
|
||||||
currentUser: '',
|
|
||||||
loginUsername: '',
|
loginUsername: '',
|
||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginError: '',
|
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() {
|
async submitLogin() {
|
||||||
this.loginError = ''
|
this.loginError = ''
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth', {
|
const res = await fetch('/auth/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -25,124 +272,18 @@ document.addEventListener('alpine:init', () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sessionStorage.setItem('auth_token', data.token)
|
sessionStorage.setItem('auth_token', data.token)
|
||||||
this.currentUser = data.username
|
|
||||||
this.loginPassword = ''
|
this.loginPassword = ''
|
||||||
this.authed = true
|
this.authed = true
|
||||||
await this.loadApps()
|
connectWS()
|
||||||
const bio = this.installedApps.find(a => a.id === 'bio')
|
} catch (_) {
|
||||||
if (bio) this.openApp(bio)
|
|
||||||
} catch (e) {
|
|
||||||
this.loginError = 'Auth service unreachable'
|
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')
|
|
||||||
}
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
window.addEventListener('mousemove', onMove)
|
|
||||||
window.addEventListener('mouseup', onUp)
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Cursor input mode ─────────────────────────────────────────────────
|
|
||||||
inputMode: false,
|
|
||||||
cursorQuery: '',
|
|
||||||
|
|
||||||
handleKeydown(e) {
|
handleKeydown(e) {
|
||||||
// ⌘+Space or Ctrl+Space toggles input mode
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.code === 'Space') {
|
if ((e.metaKey || e.ctrlKey) && e.code === 'Space') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
this.inputMode ? this.exitInputMode() : this.enterInputMode()
|
this.inputMode ? this.exitInputMode() : this.enterInputMode()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -158,46 +299,15 @@ document.addEventListener('alpine:init', () => {
|
||||||
},
|
},
|
||||||
|
|
||||||
submitQuery() {
|
submitQuery() {
|
||||||
const query = this.cursorQuery.trim().toLowerCase()
|
const text = this.cursorQuery.trim()
|
||||||
|
if (!text) { this.exitInputMode(); return }
|
||||||
// Check if it matches an installed app name
|
sendEvent({
|
||||||
const app = this.installedApps.find(a => a.name.toLowerCase() === query)
|
type: 'cursor.input',
|
||||||
if (app) { this.openApp(app); this.exitInputMode(); return }
|
text,
|
||||||
|
position: { x: this.cursorX, y: this.cursorY },
|
||||||
// Otherwise — send to LLM (placeholder)
|
})
|
||||||
console.log('LLM query:', query)
|
|
||||||
this.exitInputMode()
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,17 @@ html, body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #1a1a1a;
|
background: #e8e8e8;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#canvas {
|
#canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
overflow: hidden;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
will-change: transform;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#canvas.panning {
|
.canvas-element {
|
||||||
cursor: grabbing;
|
position: absolute;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,39 @@
|
||||||
#cursor-input {
|
/* Hide system cursor everywhere */
|
||||||
|
* { cursor: none !important; }
|
||||||
|
|
||||||
|
/* Dot cursor */
|
||||||
|
#cursor-dot {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
width: 8px;
|
||||||
left: 50%;
|
height: 8px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 99999;
|
z-index: 9999;
|
||||||
|
transition: width 0.1s, height 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cursor-input-field {
|
/* Input bubble — anchored to cursor position via inline style */
|
||||||
width: 480px;
|
#cursor-bubble {
|
||||||
padding: 14px 20px;
|
position: fixed;
|
||||||
font-size: 1.1rem;
|
transform: translate(-50%, calc(-100% - 14px));
|
||||||
|
z-index: 9998;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cursor-input {
|
||||||
|
background: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 20px;
|
||||||
background: rgba(255,255,255,0.95);
|
padding: 9px 18px;
|
||||||
backdrop-filter: blur(20px);
|
font-size: 14px;
|
||||||
box-shadow: 0 24px 80px rgba(0,0,0,0.4);
|
color: #1a1a1a;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.14);
|
||||||
outline: none;
|
outline: none;
|
||||||
color: #111;
|
width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cursor-input-field::placeholder {
|
#cursor-input::placeholder {
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,79 +5,73 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(232, 232, 232, 0.85);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(24px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: white;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 40px 48px;
|
padding: 40px 44px;
|
||||||
width: 360px;
|
width: 340px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 14px;
|
||||||
color: #fff;
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card h1 {
|
.login-card h1 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.3rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
color: #1a1a1a;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card input {
|
.login-card input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 12px 14px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
border: 1.5px solid #e0e0e0;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: #fafafa;
|
||||||
color: #fff;
|
color: #1a1a1a;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card input::placeholder {
|
.login-card input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: #bbb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card input:focus {
|
.login-card input:focus {
|
||||||
border-color: rgba(255, 255, 255, 0.55);
|
border-color: #aaa;
|
||||||
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card button {
|
.login-card button {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: none;
|
border: none;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: #1a1a1a;
|
||||||
color: #111;
|
color: white;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card button:hover {
|
.login-card button:hover {
|
||||||
background: #fff;
|
background: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-error {
|
.login-error {
|
||||||
font-size: 0.85rem;
|
font-size: 0.83rem;
|
||||||
color: #ff6b6b;
|
color: #e53e3e;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 1.2em;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,113 @@
|
||||||
.window {
|
/* ── App icons ────────────────────────────────────────────────────────────── */
|
||||||
position: absolute;
|
|
||||||
|
.canvas-element.app-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 6px;
|
||||||
padding: 0 12px;
|
width: 72px;
|
||||||
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;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
}
|
||||||
color: #999;
|
|
||||||
width: 24px;
|
.app-icon .icon-glyph {
|
||||||
height: 24px;
|
width: 56px;
|
||||||
border-radius: 50%;
|
height: 56px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-close:hover {
|
.app-icon:hover .icon-glyph {
|
||||||
background: #ff5f57;
|
transform: scale(1.08);
|
||||||
color: #fff;
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-content {
|
.app-icon .icon-label {
|
||||||
flex: 1;
|
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;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-content iframe {
|
.app-icon .icon-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: #34c759;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 .window-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue