From cbea2657756cbf95add478b32d540a9a29ac7b3d Mon Sep 17 00:00:00 2001 From: Andrew Gundersen Date: Wed, 25 Feb 2026 15:35:31 -0500 Subject: [PATCH 1/1] Initial commit Co-Authored-By: Claude Sonnet 4.6 --- .DS_Store | Bin 0 -> 6148 bytes apps/.DS_Store | Bin 0 -> 6148 bytes apps/contacts/crimata.json | 7 ++ apps/contacts/package.json | 22 ++++++ apps/contacts/src/db.ts | 5 ++ apps/contacts/src/index.ts | 9 +++ apps/contacts/src/migrate.ts | 21 +++++ apps/contacts/src/routes.ts | 61 ++++++++++++++ apps/contacts/src/types.ts | 22 ++++++ apps/contacts/tsconfig.json | 13 +++ auth/Makefile | 13 +++ auth/src/main.c | 149 +++++++++++++++++++++++++++++++++++ auth/src/pam_auth.c | 50 ++++++++++++ auth/src/pam_auth.h | 10 +++ ui/index.html | 66 ++++++++++++++++ ui/js/os.js | 140 ++++++++++++++++++++++++++++++++ ui/styles/canvas.css | 23 ++++++ ui/styles/cursor.css | 24 ++++++ ui/styles/dock.css | 34 ++++++++ ui/styles/window.css | 61 ++++++++++++++ 20 files changed, 730 insertions(+) create mode 100644 .DS_Store create mode 100644 apps/.DS_Store create mode 100644 apps/contacts/crimata.json create mode 100644 apps/contacts/package.json create mode 100644 apps/contacts/src/db.ts create mode 100644 apps/contacts/src/index.ts create mode 100644 apps/contacts/src/migrate.ts create mode 100644 apps/contacts/src/routes.ts create mode 100644 apps/contacts/src/types.ts create mode 100644 apps/contacts/tsconfig.json create mode 100644 auth/Makefile create mode 100644 auth/src/main.c create mode 100644 auth/src/pam_auth.c create mode 100644 auth/src/pam_auth.h create mode 100644 ui/index.html create mode 100644 ui/js/os.js create mode 100644 ui/styles/canvas.css create mode 100644 ui/styles/cursor.css create mode 100644 ui/styles/dock.css create mode 100644 ui/styles/window.css diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c4553808378d374f1606a0cddabd381ab32fc20f GIT binary patch literal 6148 zcmeHKPjAyO6o2jlbD2OrfW&T*xS?sRXs}9LLMaEX1i^u!k~GyCEsdilT@O{I+~I3* z=FBJH8}MB?!SC5_wRQt;2r<9N{@L%3llPNs=MjWNBF) z9DEavE$`a(ZMiLXao)3O^)-tz2~QdztlNAc@)IP14=KT~-XrTK881JYy& zm*Y2So~T(@P4lGGv7s@@rfl|GyYqRc^Jvd+A3k2}`SbR}Lrjj2I*Ub9-nsYS$;9pHfAmbwb2_*ft5#e%VPo9H?;t`%+eG zj8(5^$&zQs`@6S3ePv?L*^}Sei_s-(C!}aVryJk*9qXR|z6P>L*L~>ohR^bQ9bP#G z?ADMwS1xmxuw%e6@FxcNeDKj21B>QaS_{Vd*B|J!0vK4V4MGDECKPBw zg)K3JtsI02hdr?VwLue3Lcba7*f$GXp$J=e2w%IC2n@R3G2j?jXJE@rTYUdN#~%Jg z{l6aMW{v^Jz<~*XMd==kC(*m2%17KjW THV6&G{Rn6pT;~}0s|@@EdyJ6`}<*0^i)x@mKRu`Ku(M83VC zhFZ$B{qp_yu-&|?=urm0b~|6KbGI8EaIkf`uAPJO;q~h6d4E`p_vrHGTl?206ANvyflnH7zhS}fq?73li(2PT;S zR*t`8RkE63hYR*`uP2gXA{35hNk_yq<&0P_elmH+?% literal 0 HcmV?d00001 diff --git a/apps/contacts/crimata.json b/apps/contacts/crimata.json new file mode 100644 index 0000000..d63cf4b --- /dev/null +++ b/apps/contacts/crimata.json @@ -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 index 0000000..89c2482 --- /dev/null +++ b/apps/contacts/package.json @@ -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 index 0000000..0f2d4a9 --- /dev/null +++ b/apps/contacts/src/db.ts @@ -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 index 0000000..b9b23f5 --- /dev/null +++ b/apps/contacts/src/index.ts @@ -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 index 0000000..6aa0628 --- /dev/null +++ b/apps/contacts/src/migrate.ts @@ -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 index 0000000..3cc0668 --- /dev/null +++ b/apps/contacts/src/routes.ts @@ -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 index 0000000..1963e4b --- /dev/null +++ b/apps/contacts/src/types.ts @@ -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 index 0000000..daf3709 --- /dev/null +++ b/apps/contacts/tsconfig.json @@ -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 index 0000000..9cc64b9 --- /dev/null +++ b/auth/Makefile @@ -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 index 0000000..5476a1c --- /dev/null +++ b/auth/src/main.c @@ -0,0 +1,149 @@ +#include +#include +#include +#include +#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 index 0000000..e3b4b1d --- /dev/null +++ b/auth/src/pam_auth.c @@ -0,0 +1,50 @@ +#include +#include +#include +#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 index 0000000..1d03bcc --- /dev/null +++ b/auth/src/pam_auth.h @@ -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 index 0000000..6f10d71 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,66 @@ + + + + + + Crimata + + + + + + + + + +
+ + +
+ + +
+ +
+ + +
+ +
+ + + + diff --git a/ui/js/os.js b/ui/js/os.js new file mode 100644 index 0000000..d02dae4 --- /dev/null +++ b/ui/js/os.js @@ -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 index 0000000..d730ff5 --- /dev/null +++ b/ui/styles/canvas.css @@ -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 index 0000000..b40c15c --- /dev/null +++ b/ui/styles/cursor.css @@ -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 index 0000000..bcb87c3 --- /dev/null +++ b/ui/styles/dock.css @@ -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 index 0000000..d077408 --- /dev/null +++ b/ui/styles/window.css @@ -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; +} -- 2.43.0