From eda80afe62e80a094f1db408dd734b42b147acc6 Mon Sep 17 00:00:00 2001 From: Dustin Henderson Date: Wed, 18 Mar 2026 17:35:20 +0000 Subject: [PATCH 1/2] Add local OpenClaw runtime dashboard --- .env.example | 2 + README.md | 76 ++-- package-lock.json | 18 + package.json | 1 + src/lib/server/monitor.ts | 346 +++++++++++++++++ src/routes/+layout.svelte | 5 + src/routes/+page.server.ts | 8 + src/routes/+page.svelte | 613 +++++++++++++++++++++++++++++- src/routes/api/monitor/+server.ts | 6 + tsconfig.json | 3 +- 10 files changed, 1053 insertions(+), 25 deletions(-) create mode 100644 .env.example create mode 100644 src/lib/server/monitor.ts create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/api/monitor/+server.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..301f072 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +OPENCLAW_HOME=/root/.openclaw +OPENCLAW_WORKSPACE=/root/.openclaw/workspace diff --git a/README.md b/README.md index 80dca59..927b438 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,74 @@ -# sv +# OpenClaw Monitor -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +A SvelteKit dashboard for inspecting a local OpenClaw installation in development mode. -## Creating a project +It is designed for the workflow where the app runs locally on the OpenClaw host, and you reach it through an SSH tunnel instead of deploying a static production build. -If you're seeing this, you've probably already done this step. Congrats! +## What it shows -```sh -# create a new project -npx sv create my-app +- OpenClaw runtime basics such as install version, default model, ACP backend, gateway mode, and tool profile +- Workspace facts such as memory file count and workspace state +- Channel and device summaries without exposing secrets +- Parsed highlights from `openclaw status` +- Installed skills discovered from workspace skills, built-in OpenClaw skills, and extension skills +- Cron jobs discovered from local OpenClaw cron state +- A sanitized OpenClaw config snapshot with secret-like fields redacted + +## How it works + +The app reads local files at request time on the server side. Machine-specific data is not committed to the repository. + +By default it looks here: + +- `OPENCLAW_HOME=/root/.openclaw` +- `OPENCLAW_WORKSPACE=/root/.openclaw/workspace` + +Override those paths with environment variables if you want to point the monitor at another OpenClaw installation. + +## Development + +Install dependencies: + +```bash +npm install ``` -To recreate this project with the same configuration: +Run the dev server: -```sh -# recreate this project -npx sv@0.12.8 create --template minimal --types ts --install npm . +```bash +npm run dev -- --host 0.0.0.0 --port 4173 ``` -## Developing +Then forward the port from your local machine, for example: + +```bash +ssh -L 4173:127.0.0.1:4173 your-server +``` -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +## Environment -```sh -npm run dev +Copy `.env.example` if you want explicit paths: -# or start the server and open the app in a new browser tab -npm run dev -- --open +```bash +cp .env.example .env ``` -## Building +Example: + +```env +OPENCLAW_HOME=/root/.openclaw +OPENCLAW_WORKSPACE=/root/.openclaw/workspace +``` -To create a production version of your app: +## Validation -```sh +```bash +npm run check npm run build ``` -You can preview the production build with `npm run preview`. +## Notes -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +- The dashboard is intentionally local-first. +- Secret-like fields in `openclaw.json` are redacted before being returned to the UI. +- The monitor currently focuses on visibility and foundation for future dashboards rather than write actions. diff --git a/package-lock.json b/package-lock.json index 7839f84..d854592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@types/node": "^25.5.0", "svelte": "^5.51.0", "svelte-check": "^4.4.2", "typescript": "^5.9.3", @@ -988,6 +989,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1521,6 +1532,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 410f7b9..7e56550 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@types/node": "^25.5.0", "svelte": "^5.51.0", "svelte-check": "^4.4.2", "typescript": "^5.9.3", diff --git a/src/lib/server/monitor.ts b/src/lib/server/monitor.ts new file mode 100644 index 0000000..baa3861 --- /dev/null +++ b/src/lib/server/monitor.ts @@ -0,0 +1,346 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +type MonitorSource = { + label: string; + path: string; + exists: boolean; +}; + +type MonitorMetric = { + label: string; + value: string; + detail?: string; +}; + +type MonitorSection = { + title: string; + description: string; + items: MonitorMetric[]; +}; + +type SkillEntry = { + name: string; + source: string; + location: string; +}; + +type CronJobSummary = { + id: string; + name: string; + enabled: boolean; + schedule: string; + target: string; + lastRun: string; + status: string; +}; + +export type OpenClawMonitorSnapshot = { + generatedAt: string; + hostname: string; + runtime: { + home: string; + workspace: string; + nodeVersion: string; + platform: string; + }; + sources: MonitorSource[]; + sections: MonitorSection[]; + skills: SkillEntry[]; + cronJobs: CronJobSummary[]; + statusSummary: string[]; + sanitizedConfig: Record; + rawCounts: { + memoryFiles: number; + workspaceEntries: number; + credentialFiles: number; + }; +}; + +const SECRET_PATTERNS = [ + /token/i, + /secret/i, + /password/i, + /api[-_]?key/i, + /auth/i, + /botToken/i, + /cookie/i, + /credential/i +]; + +const DEFAULT_HOME = process.env.OPENCLAW_HOME || '/root/.openclaw'; +const DEFAULT_WORKSPACE = process.env.OPENCLAW_WORKSPACE || path.join(DEFAULT_HOME, 'workspace'); + +function fileExists(filePath: string): boolean { + return fs.existsSync(filePath); +} + +function readJson(filePath: string): Record { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; +} + +function sanitizeValue(key: string, value: JsonValue): JsonValue { + if (SECRET_PATTERNS.some((pattern) => pattern.test(key))) { + return '[redacted]'; + } + + if (Array.isArray(value)) { + return value.map((entry) => sanitizeValue(key, entry)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([childKey, childValue]) => [childKey, sanitizeValue(childKey, childValue)]) + ); + } + + return value; +} + +function listDirectories(basePath: string): string[] { + if (!fileExists(basePath)) return []; + + return fs + .readdirSync(basePath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(basePath, entry.name)); +} + +function countFiles(basePath: string, matcher?: (filePath: string) => boolean): number { + if (!fileExists(basePath)) return 0; + + let count = 0; + const stack = [basePath]; + + while (stack.length) { + const current = stack.pop(); + if (!current) continue; + + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (!matcher || matcher(fullPath)) { + count += 1; + } + } + } + + return count; +} + +function runStatusCommand(home: string): string { + try { + return execSync('openclaw status', { + encoding: 'utf8', + env: { + ...process.env, + HOME: process.env.HOME || '/root', + OPENCLAW_HOME: home + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `openclaw status unavailable: ${message}`; + } +} + +function parseStatusSummary(statusText: string): string[] { + return statusText + .split('\n') + .map((line) => line.trim()) + .filter((line) => + line.startsWith('│ Dashboard') || + line.startsWith('│ OS') || + line.startsWith('│ Channel') || + line.startsWith('│ Update') || + line.startsWith('│ Gateway') || + line.startsWith('│ Gateway service') || + line.startsWith('│ Agents') || + line.startsWith('│ Memory') || + line.startsWith('│ Heartbeat') || + line.startsWith('│ Sessions') + ) + .map((line) => line.replace(/^│\s*/, '').replace(/\s*│$/, '')); +} + +function detectSkills(workspace: string): SkillEntry[] { + const skillRoots = [ + { source: 'workspace', root: path.join(workspace, 'skills') }, + { source: 'builtin', root: '/usr/lib/node_modules/openclaw/skills' }, + { source: 'extensions', root: '/usr/lib/node_modules/openclaw/extensions' } + ]; + + const results: SkillEntry[] = []; + + for (const { source, root } of skillRoots) { + if (!fileExists(root)) continue; + + if (source === 'extensions') { + for (const extensionDir of listDirectories(root)) { + const skillsDir = path.join(extensionDir, 'skills'); + for (const skillDir of listDirectories(skillsDir)) { + const skillFile = path.join(skillDir, 'SKILL.md'); + if (!fileExists(skillFile)) continue; + results.push({ + name: path.basename(skillDir), + source, + location: skillFile + }); + } + } + continue; + } + + for (const skillDir of listDirectories(root)) { + const skillFile = path.join(skillDir, 'SKILL.md'); + if (!fileExists(skillFile)) continue; + results.push({ + name: path.basename(skillDir), + source, + location: skillFile + }); + } + } + + return results.sort((a, b) => a.name.localeCompare(b.name)); +} + +function summarizeCronJobs(home: string): CronJobSummary[] { + const cronPath = path.join(home, 'cron', 'jobs.json'); + if (!fileExists(cronPath)) return []; + + const json = readJson(cronPath); + const jobs = Array.isArray(json.jobs) ? json.jobs : []; + + return jobs.map((job) => { + const record = job as Record; + const schedule = (record.schedule || {}) as Record; + const state = (record.state || {}) as Record; + const delivery = (record.delivery || {}) as Record; + + return { + id: String(record.id || 'unknown'), + name: String(record.name || 'Unnamed job'), + enabled: Boolean(record.enabled), + schedule: + schedule.kind === 'cron' + ? `${String(schedule.expr || 'n/a')} · ${String(schedule.tz || 'UTC')}` + : String(schedule.kind || 'unknown'), + target: `${String(record.sessionTarget || 'unknown')} · ${String(delivery.mode || 'none')}`, + lastRun: state.lastRunAtMs ? new Date(Number(state.lastRunAtMs)).toISOString() : 'not yet', + status: String(state.lastStatus || state.lastRunStatus || 'unknown') + }; + }); +} + +export function getOpenClawMonitorSnapshot(): OpenClawMonitorSnapshot { + const home = DEFAULT_HOME; + const workspace = DEFAULT_WORKSPACE; + const configPath = path.join(home, 'openclaw.json'); + const workspaceStatePath = path.join(workspace, '.openclaw', 'workspace-state.json'); + const devicesPath = path.join(home, 'devices', 'paired.json'); + const config = fileExists(configPath) ? readJson(configPath) : {}; + const sanitizedConfig = sanitizeValue('root', config) as Record; + const cronJobs = summarizeCronJobs(home); + const skills = detectSkills(workspace); + const statusText = runStatusCommand(home); + const statusSummary = parseStatusSummary(statusText); + const memoryDir = path.join(workspace, 'memory'); + const credentialsDir = path.join(home, 'credentials'); + const workspaceEntryCount = fileExists(workspace) ? fs.readdirSync(workspace).length : 0; + const pairedDevices = fileExists(devicesPath) + ? (((readJson(devicesPath).devices as JsonValue[]) || []).length ?? 0) + : 0; + + const meta = (config.meta || {}) as Record; + const agents = ((config.agents || {}) as Record).defaults as Record | undefined; + const model = agents?.model as Record | undefined; + const gateway = config.gateway as Record | undefined; + const channels = (config.channels || {}) as Record; + const acp = (config.acp || {}) as Record; + const tools = (config.tools || {}) as Record; + + const sections: MonitorSection[] = [ + { + title: 'Runtime', + description: 'Core OpenClaw installation facts for the current machine.', + items: [ + { label: 'Install version', value: String(meta.lastTouchedVersion || 'unknown') }, + { label: 'Default model', value: String(model?.primary || 'unknown') }, + { label: 'ACP backend', value: String(acp.backend || 'disabled'), detail: `default agent: ${String(acp.defaultAgent || 'n/a')}` }, + { label: 'Gateway mode', value: String(gateway?.mode || 'unknown'), detail: `bind: ${String(gateway?.bind || 'unknown')} · port: ${String(gateway?.port || 'unknown')}` }, + { label: 'Tool profile', value: String(tools.profile || 'unknown') } + ] + }, + { + title: 'Workspace', + description: 'What the monitor can discover from the local OpenClaw workspace.', + items: [ + { label: 'Workspace root', value: workspace }, + { label: 'Top-level entries', value: String(workspaceEntryCount) }, + { label: 'Memory files', value: String(countFiles(memoryDir, (filePath) => filePath.endsWith('.md'))) }, + { label: 'Workspace state', value: fileExists(workspaceStatePath) ? 'present' : 'missing' } + ] + }, + { + title: 'Channels and devices', + description: 'High-level connectivity, without exposing secrets or tokens.', + items: [ + ...Object.entries(channels).map(([name, value]) => { + const record = (value || {}) as Record; + return { + label: name, + value: record.enabled ? 'enabled' : 'disabled', + detail: `streaming: ${String(record.streaming || 'n/a')}` + }; + }), + { label: 'Paired devices', value: String(pairedDevices), detail: 'discovered from local device state' } + ] + }, + { + title: 'Automation', + description: 'Scheduled jobs discovered from the local cron state.', + items: [ + { label: 'Scheduled jobs', value: String(cronJobs.length) }, + { label: 'Healthy jobs', value: String(cronJobs.filter((job) => job.status === 'ok').length) }, + { label: 'Credential files', value: String(countFiles(credentialsDir)) }, + { label: 'Discovered skills', value: String(skills.length) } + ] + } + ]; + + return { + generatedAt: new Date().toISOString(), + hostname: fs.existsSync('/etc/hostname') ? fs.readFileSync('/etc/hostname', 'utf8').trim() : 'unknown-host', + runtime: { + home, + workspace, + nodeVersion: process.version, + platform: `${process.platform} ${process.arch}` + }, + sources: [ + { label: 'OpenClaw config', path: configPath, exists: fileExists(configPath) }, + { label: 'Workspace state', path: workspaceStatePath, exists: fileExists(workspaceStatePath) }, + { label: 'Cron jobs', path: path.join(home, 'cron', 'jobs.json'), exists: fileExists(path.join(home, 'cron', 'jobs.json')) }, + { label: 'Paired devices', path: devicesPath, exists: fileExists(devicesPath) } + ], + sections, + skills, + cronJobs, + statusSummary, + sanitizedConfig, + rawCounts: { + memoryFiles: countFiles(memoryDir, (filePath) => filePath.endsWith('.md')), + workspaceEntries: workspaceEntryCount, + credentialFiles: countFiles(credentialsDir) + } + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9cebde5..6befa33 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,6 +5,11 @@ + OpenClaw Monitor + diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..8e7f17d --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,8 @@ +import type { PageServerLoad } from './$types'; +import { getOpenClawMonitorSnapshot } from '$lib/server/monitor'; + +export const load: PageServerLoad = async () => { + return { + monitor: getOpenClawMonitorSnapshot() + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..067d67f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,611 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + + + OpenClaw Monitor + + +
+
+
+ +
+
+

OpenClaw runtime monitor

+

See what your OpenClaw install is doing without shipping your machine into git.

+

+ This dashboard reads local OpenClaw files at runtime, sanitizes sensitive config fields, + and gives you a clean live surface for the installation, workspace, skills, jobs, and + status output. +

+
+ +
+
+ Host + {monitor.hostname} +
+
+ Generated + {monitor.generatedAt} +
+
+ Refresh mode + {lastRefreshMode === 'server-load' ? 'Initial SSR snapshot' : 'Live API snapshot'} +
+ +
+
+ + {#if error} + + {/if} + +
+
+ {#each monitor.sections as section} +
+
+
+ +

{section.description}

+
+
+ +
+ {#each section.items as item} +
+
+

{item.label}

+ {#if item.detail} + {item.detail} + {/if} +
+ {item.value} +
+ {/each} +
+
+ {/each} +
+ +
+
+
+
+ +

What `openclaw status` is reporting right now

+
+

Fast enough for dev mode, safe enough for a local tunnel.

+
+ + {#if statusItems.length} +
+ {#each statusItems as item} +
+ {item.label} + {item.value} +
+ {/each} +
+ {:else} +
+ No status output was parsed. +

OpenClaw may be unavailable, or the status format changed.

+
+ {/if} +
+ +
+
+
+ +

Where the monitor is reading from

+
+
+ +
+ {#each monitor.sources as source} +
+
+

{source.label}

+ {source.path} +
+ + {source.exists ? 'present' : 'missing'} + +
+ {/each} +
+
+
+ +
+
+
+
+ +

{monitor.skills.length} skills discovered across workspace, builtins, and extensions

+
+

Perfect for answering “what can this install actually do?”

+
+ +
+ {#each monitor.skills as skill} +
+ {skill.name} + {formatSource(skill.source)} +
+ {/each} +
+
+ +
+
+
+ +

Cron jobs and recent state

+
+
+ + {#if monitor.cronJobs.length} +
+ {#each monitor.cronJobs as job} +
+
+

{job.name}

+ {job.schedule} +
+
+ {job.status} + {job.lastRun} +
+
+ {/each} +
+ {:else} +
+ No scheduled jobs found. +

Add or enable OpenClaw cron jobs and they will show up here automatically.

+
+ {/if} +
+
+ +
+
+
+ +

Safe-to-display OpenClaw config snapshot

+
+

Secret-looking fields are redacted before they reach the UI.

+
+ +
{JSON.stringify(monitor.sanitizedConfig, null, 2)}
+
+
+
+ + diff --git a/src/routes/api/monitor/+server.ts b/src/routes/api/monitor/+server.ts new file mode 100644 index 0000000..38b0b0f --- /dev/null +++ b/src/routes/api/monitor/+server.ts @@ -0,0 +1,6 @@ +import { json } from '@sveltejs/kit'; +import { getOpenClawMonitorSnapshot } from '$lib/server/monitor'; + +export const GET = async () => { + return json(getOpenClawMonitorSnapshot()); +}; diff --git a/tsconfig.json b/tsconfig.json index 2c2ed3c..709cb83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["node"] } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files From 1d1abb3061fd2ff023ed2a9b90f8261d75900afc Mon Sep 17 00:00:00 2001 From: Dustin Henderson Date: Wed, 18 Mar 2026 20:37:31 +0000 Subject: [PATCH 2/2] feat: refine idea incubator workspace UI --- package-lock.json | 597 ++++++++++++++++++ package.json | 2 + src/lib/app.css | 79 +++ src/lib/server/idea-incubator.ts | 283 +++++++++ src/routes/+layout.svelte | 1 + src/routes/+page.server.ts | 4 +- src/routes/+page.svelte | 930 +++++++++++----------------- src/routes/api/incubator/+server.ts | 48 ++ vite.config.ts | 3 +- 9 files changed, 1389 insertions(+), 558 deletions(-) create mode 100644 src/lib/app.css create mode 100644 src/lib/server/idea-incubator.ts create mode 100644 src/routes/api/incubator/+server.ts diff --git a/package-lock.json b/package-lock.json index d854592..9f694bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.1.13", "@types/node": "^25.5.0", "svelte": "^5.51.0", "svelte-check": "^4.4.2", + "tailwindcss": "^4.1.13", "typescript": "^5.9.3", "vite": "^7.3.1" } @@ -975,6 +977,278 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1099,6 +1373,16 @@ "node": ">=0.10.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { "version": "5.6.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", @@ -1106,6 +1390,20 @@ "dev": true, "license": "MIT" }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -1199,6 +1497,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1209,6 +1514,16 @@ "@types/estree": "^1.0.6" } }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1219,6 +1534,267 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -1491,6 +2067,27 @@ "typescript": ">=5.0.0" } }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index 7e56550..490ba96 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.1.13", "@types/node": "^25.5.0", "svelte": "^5.51.0", "svelte-check": "^4.4.2", + "tailwindcss": "^4.1.13", "typescript": "^5.9.3", "vite": "^7.3.1" } diff --git a/src/lib/app.css b/src/lib/app.css new file mode 100644 index 0000000..894d036 --- /dev/null +++ b/src/lib/app.css @@ -0,0 +1,79 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + --color-ink: #eef4ef; + --color-muted: #9ca9a1; + --color-panel: #101714; + --color-panel-strong: #0a0f0d; + --color-line: rgba(255, 255, 255, 0.08); + --color-accent: #97f0b8; + --color-accent-strong: #2eb872; + --color-warm: #f1d6b1; +} + +@layer base { + html { + color-scheme: dark; + } + + body { + min-height: 100dvh; + background: + radial-gradient(circle at top left, rgba(84, 205, 131, 0.18), transparent 28%), + radial-gradient(circle at 85% 10%, rgba(241, 214, 177, 0.12), transparent 22%), + radial-gradient(circle at 60% 80%, rgba(79, 123, 255, 0.08), transparent 24%), + #060a08; + color: var(--color-ink); + font-family: var(--font-sans); + } + + * { + box-sizing: border-box; + } + + button, + input, + textarea { + font: inherit; + } +} + +@layer utilities { + .glass-panel { + background: linear-gradient(180deg, rgba(16, 23, 20, 0.86), rgba(8, 12, 10, 0.92)); + border: 1px solid var(--color-line); + box-shadow: + 0 30px 80px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + backdrop-filter: blur(20px); + } + + .hairline { + border-color: rgba(255, 255, 255, 0.08); + } + + .text-balance { + text-wrap: balance; + } + + .noise-overlay::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: radial-gradient(rgba(255,255,255,0.05) 0.6px, transparent 0.6px); + background-size: 12px 12px; + opacity: 0.12; + mask-image: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent 90%); + } + + .grid-fade { + background-image: + linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(circle at center, black 35%, transparent 85%); + } +} diff --git a/src/lib/server/idea-incubator.ts b/src/lib/server/idea-incubator.ts new file mode 100644 index 0000000..429e184 --- /dev/null +++ b/src/lib/server/idea-incubator.ts @@ -0,0 +1,283 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type IdeaStage = 'inbox' | 'incubating' | 'implementing'; + +export type IdeaCard = { + id: string; + title: string; + path: string; + stage: IdeaStage; + summary: string; + updatedAt: string; + content: string; +}; + +export type StageColumn = { + key: IdeaStage; + label: string; + description: string; + count: number; + ideas: IdeaCard[]; +}; + +export type IncubatorSnapshot = { + generatedAt: string; + root: string; + stages: StageColumn[]; + stats: { + totalIdeas: number; + stageCount: number; + lastUpdated: string | null; + }; +}; + +const IDEA_ROOT = '/root/.openclaw/workspace/idea-incubator/ideas'; +const STAGE_CONFIG: Record = { + inbox: { + label: 'Raw ideas', + description: 'Messy sparks, rough notes, fragments, and new captures.' + }, + incubating: { + label: 'Incubating', + description: 'Sharper concepts with clearer problem framing, scope, and MVP.' + }, + implementing: { + label: 'Implementation plans', + description: 'Ideas that crossed the line into concrete build mode.' + } +}; + +function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +function slugify(input: string) { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || `idea-${Date.now()}`; +} + +function titleFromContent(content: string, fallback: string) { + const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim(); + if (heading) return heading; + + const concept = content.match(/^##\s+(One-line concept|Goal)\s*\n(.+)$/m)?.[2]?.trim(); + if (concept) return concept.slice(0, 80); + + return fallback; +} + +function summaryFromContent(content: string) { + const lines = content + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !line.startsWith('#') && !line.startsWith('- ') && !/^\d+\./.test(line)); + + return lines[0]?.slice(0, 180) || 'Fresh idea waiting for the next pass.'; +} + +function readIdeaFile(stage: IdeaStage, filePath: string): IdeaCard { + const content = fs.readFileSync(filePath, 'utf8'); + const stats = fs.statSync(filePath); + const basename = path.basename(filePath, path.extname(filePath)); + + return { + id: `${stage}:${basename}`, + title: titleFromContent(content, basename.replace(/-/g, ' ')), + path: filePath, + stage, + summary: summaryFromContent(content), + updatedAt: stats.mtime.toISOString(), + content + }; +} + +function listStageIdeas(stage: IdeaStage): IdeaCard[] { + const dir = path.join(IDEA_ROOT, stage); + ensureDir(dir); + + return fs + .readdirSync(dir) + .filter((name) => name.endsWith('.md')) + .map((name) => readIdeaFile(stage, path.join(dir, name))) + .sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt)); +} + +function todayStamp() { + return new Date().toISOString().slice(0, 10); +} + +function capitalize(input: string) { + return input.slice(0, 1).toUpperCase() + input.slice(1); +} + +function pickTags(rawIdea: string) { + const keywordMap = [ + ['ai', 'ai'], + ['agent', 'agents'], + ['automation', 'automation'], + ['workflow', 'workflow'], + ['api', 'api'], + ['dashboard', 'dashboard'], + ['svelte', 'frontend'], + ['mobile', 'mobile'], + ['calendar', 'productivity'], + ['email', 'communication'], + ['github', 'developer-tools'] + ] as const; + + const found = keywordMap + .filter(([needle]) => rawIdea.toLowerCase().includes(needle)) + .map(([, tag]) => tag); + + return Array.from(new Set(found)).slice(0, 3); +} + +function inferTitle(rawIdea: string) { + const cleaned = rawIdea.replace(/\s+/g, ' ').trim(); + const firstSentence = cleaned.split(/[.!?]/)[0]?.trim() || cleaned; + return firstSentence.split(' ').slice(0, 8).join(' ').replace(/^./, (char) => char.toUpperCase()); +} + +export function createRawIdea(rawIdea: string) { + const title = inferTitle(rawIdea); + const slug = slugify(title); + const tags = pickTags(rawIdea); + const filePath = path.join(IDEA_ROOT, 'inbox', `${slug}.md`); + ensureDir(path.dirname(filePath)); + + const content = `# ${title}\n\n## Raw idea\n${rawIdea.trim()}\n\n## Why it caught attention\nThere is enough energy here to test whether this could become a sharp, demoable product direction.\n\n## Tags\n${tags.length ? tags.map((tag) => `- ${tag}`).join('\n') : '- explore\n- concept\n- poc'}\n\n## Source\n- Captured on: ${todayStamp()}\n- Captured from: UI quick capture\n`; + + fs.writeFileSync(filePath, content, 'utf8'); + return readIdeaFile('inbox', filePath); +} + +function splitSentences(rawIdea: string) { + return rawIdea + .replace(/\s+/g, ' ') + .split(/(?<=[.!?])\s+/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function buildIncubatedMarkdown(title: string, rawIdea: string) { + const sentences = splitSentences(rawIdea); + const seed = sentences[0] || rawIdea.trim(); + const second = sentences[1] || 'The concept can become stronger if the workflow is made visible and interactive.'; + const nouns = Array.from(new Set(rawIdea.toLowerCase().match(/[a-z]{4,}/g) || [])).slice(0, 6); + const stackHints = [ + rawIdea.toLowerCase().includes('svelte') ? 'SvelteKit' : 'SvelteKit', + 'Tailwind CSS', + rawIdea.toLowerCase().includes('agent') || rawIdea.toLowerCase().includes('ai') ? 'OpenClaw-backed AI actions' : 'Server-side transformation endpoints', + 'Filesystem markdown state' + ]; + const users = rawIdea.toLowerCase().includes('team') + ? 'Small product teams, founders, and operators who collect ideas faster than they refine them.' + : 'Solo builders and curious operators who need to turn fuzzy notes into buildable concepts.'; + + const whyItMatters = `Ideas usually die in the jump between a quick note and an actionable spec. This concept shortens that gap by making the transformation visible, fast, and emotionally rewarding.`; + const problem = `Raw ideas are easy to capture but hard to compare, sharpen, and promote. ${capitalize(second.replace(/[.!?]+$/, ''))}.`; + const oneLine = `${title} turns rough notes into structured product specs with a visible momentum from capture to commitment.`; + + const mvp = [ + 'Capture a raw idea into the inbox with zero setup friction.', + 'Run an incubation pass that produces problem framing, MVP scope, and next steps.', + 'Surface stage-based boards so the user can see what is raw, incubating, and ready to build.' + ]; + + const risks = [ + 'Generated specs can sound confident before the core user need is validated.', + 'Filesystem-only state is simple, but collaboration and history will need stronger persistence later.' + ]; + + const nextSteps = [ + `Test the concept against ideas involving ${nouns.slice(0, 3).join(', ') || 'workflow automation'}.`, + 'Add promotion controls so a strong idea can become an implementation plan in one click.', + 'Introduce scoring so the board also helps decide what to build next.' + ]; + + const scores = [8, 7, 8, 7, 9, 8]; + + return `# ${title}\n\n## One-line concept\n${oneLine}\n\n## Problem\n${problem}\n\n## Why it matters\n${whyItMatters}\n\n## Users\n${users}\n\n## MVP\n- ${mvp.join('\n- ')}\n\n## Suggested stack\n- ${stackHints.join('\n- ')}\n\n## Risks\n- ${risks.join('\n- ')}\n\n## Scoring\n- Excitement: ${scores[0]}/10\n- Leverage: ${scores[1]}/10\n- Buildability: ${scores[2]}/10\n- Novelty: ${scores[3]}/10\n- Reusability: ${scores[4]}/10\n- Time-to-first-demo: ${scores[5]}/10\n\n## Next 3 steps\n1. ${nextSteps[0]}\n2. ${nextSteps[1]}\n3. ${nextSteps[2]}\n\n## Raw source\n> ${seed}\n`; +} + +function buildImplementationMarkdown(title: string, incubatedContent: string) { + const concept = incubatedContent.match(/## One-line concept\n([\s\S]*?)\n## /)?.[1]?.trim() || `${title} should become a focused first release.`; + const problem = incubatedContent.match(/## Problem\n([\s\S]*?)\n## /)?.[1]?.trim() || 'The workflow needs a tighter structure and a first shippable loop.'; + + return `# ${title}\n\n## Goal\nShip a convincing first version of ${title} that proves the core workflow from raw idea to concrete build plan.\n\n## Scope\n- Build a polished stage-based interface with strong motion and visible state changes.\n- Support raw capture, structured incubation, and promotion into implementation planning.\n- Keep storage local and file-backed so the prototype runs instantly on localhost.\n\n## Deliverables\n- A production-shaped landing experience with a clear call to action.\n- A functional board that reads and writes markdown files from the local idea folders.\n- A server-side promotion flow that turns fuzzy notes into structured artifacts.\n\n## Milestones\n1. Make the filesystem state visible in a clean interface.\n2. Add incubation and promotion actions with satisfying feedback loops.\n3. Tighten copy, motion, and edge states until the prototype feels real.\n\n## Open questions\n- Should later versions allow manual editing directly inside the board?\n- When should the local incubation engine be swapped for a full model-backed prompt pipeline?\n\n## First coding step\nWire the UI to the file-backed stage directories and validate the end-to-end flow with one strong example idea.\n\n## Context\n${concept}\n\n## Core problem\n${problem}\n`; +} + +export function incubateIdea(input: { rawIdea?: string; sourcePath?: string }) { + let sourcePath = input.sourcePath; + let title: string; + let rawIdea: string; + let slug: string; + + if (sourcePath) { + const content = fs.readFileSync(sourcePath, 'utf8'); + title = titleFromContent(content, path.basename(sourcePath, path.extname(sourcePath))); + rawIdea = content.match(/## Raw idea\n([\s\S]*?)(\n## |$)/)?.[1]?.trim() || content; + slug = path.basename(sourcePath, path.extname(sourcePath)); + } else if (input.rawIdea) { + rawIdea = input.rawIdea.trim(); + title = inferTitle(rawIdea); + slug = slugify(title); + sourcePath = path.join(IDEA_ROOT, 'inbox', `${slug}.md`); + if (!fs.existsSync(sourcePath)) { + createRawIdea(rawIdea); + } + } else { + throw new Error('No source idea provided'); + } + + const incubatedPath = path.join(IDEA_ROOT, 'incubating', `${slug}.md`); + ensureDir(path.dirname(incubatedPath)); + fs.writeFileSync(incubatedPath, buildIncubatedMarkdown(title, rawIdea), 'utf8'); + + return readIdeaFile('incubating', incubatedPath); +} + +export function promoteToImplementing(sourcePath: string) { + const content = fs.readFileSync(sourcePath, 'utf8'); + const title = titleFromContent(content, path.basename(sourcePath, path.extname(sourcePath))); + const slug = path.basename(sourcePath, path.extname(sourcePath)); + const implementingPath = path.join(IDEA_ROOT, 'implementing', `${slug}.md`); + ensureDir(path.dirname(implementingPath)); + fs.writeFileSync(implementingPath, buildImplementationMarkdown(title, content), 'utf8'); + return readIdeaFile('implementing', implementingPath); +} + +export function getIncubatorSnapshot(): IncubatorSnapshot { + const stageKeys = Object.keys(STAGE_CONFIG) as IdeaStage[]; + const stages = stageKeys.map((key) => { + const ideas = listStageIdeas(key); + return { + key, + label: STAGE_CONFIG[key].label, + description: STAGE_CONFIG[key].description, + count: ideas.length, + ideas + }; + }); + + const allIdeas = stages.flatMap((stage) => stage.ideas); + const lastUpdated = allIdeas.length + ? allIdeas.map((idea) => idea.updatedAt).sort((a, b) => +new Date(b) - +new Date(a))[0] + : null; + + return { + generatedAt: new Date().toISOString(), + root: IDEA_ROOT, + stages, + stats: { + totalIdeas: allIdeas.length, + stageCount: stages.length, + lastUpdated + } + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6befa33..ad3c30f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,4 +1,5 @@ - OpenClaw Monitor + Idea Incubator + -
-
-
- -
-
-

OpenClaw runtime monitor

-

See what your OpenClaw install is doing without shipping your machine into git.

-

- This dashboard reads local OpenClaw files at runtime, sanitizes sensitive config fields, - and gives you a clean live surface for the installation, workspace, skills, jobs, and - status output. -

-
- -
-
- Host - {monitor.hostname} -
-
- Generated - {monitor.generatedAt} -
-
- Refresh mode - {lastRefreshMode === 'server-load' ? 'Initial SSR snapshot' : 'Live API snapshot'} -
- -
-
- - {#if error} - - {/if} - -
-
- {#each monitor.sections as section} -
-
-
- -

{section.description}

-
+
+
+
+
+ +
+
+
+
+
+ + Idea Incubator
-
- {#each section.items as item} -
-
-

{item.label}

- {#if item.detail} - {item.detail} - {/if} -
- {item.value} -
- {/each} +
+

+ Turn stray sparks into buildable systems. +

+

+ A local-first idea lab for messy notes, sharper concepts, and real implementation plans. The board reads your actual folders, preserves the raw capture, and turns the path from intuition to execution into something you can actually work inside. +

-
- {/each} -
-
-
-
-
- -

What `openclaw status` is reporting right now

+
+ + Open workspace + + +
{stageLabels.join(' · ')}
-

Fast enough for dev mode, safe enough for a local tunnel.

- {#if statusItems.length} -
- {#each statusItems as item} -
- {item.label} - {item.value} +
+
+
+ System pulse + {totalIdeas} ideas +
+
+
+
+ Latest update + {incubator.stats.lastUpdated ? formatRelative(incubator.stats.lastUpdated) : 'fresh'} +
+
+
+
- {/each} -
- {:else} -
- No status output was parsed. -

OpenClaw may be unavailable, or the status format changed.

-
- {/if} -
-
-
-
- -

Where the monitor is reading from

+
+ {#each incubator.stages as stage} +
+
{formatStageKey(stage.key)}
+
{stage.count}
+
+ {/each} +
+
-
-
- {#each monitor.sources as source} -
-
-

{source.label}

- {source.path} +
+
+ Transformation loop + Live +
+
+
+
+
+
Capture the messy version first
+
Nothing gets lost before refinement begins.
+
+
+
+
+
+
Generate a concept spec
+
The app shapes problem, MVP, risks, and next steps.
+
+
+
+
+
+
Promote the winners
+
Implementation plans appear when the idea has real pull.
+
- - {source.exists ? 'present' : 'missing'} -
- {/each} +
- +
-
-
-
-
- -

{monitor.skills.length} skills discovered across workspace, builtins, and extensions

+
+
+
-
-
-
- -

Cron jobs and recent state

+
+
+ Transformation + {status} +
+
+
+
Inbox artifact · raw.md
+
{rawIdea.trim() ? `${rawIdea.trim().slice(0, 120)}${rawIdea.trim().length > 120 ? '…' : ''}` : 'Your unfiltered thought lands here first, exactly as captured.'}
+
+
+ + Transforming + +
+
+
Incubating concept · spec.md
+
{status === 'success' && spotlight ? spotlight.body : 'A sharper artifact appears here with a problem statement, MVP frame, stack hints, and next steps.'}
+
+
+
+ {statusMessage || 'The pipeline is visible because it mirrors the real file-backed flow underneath.'} +
+ - {#if monitor.cronJobs.length} -
- {#each monitor.cronJobs as job} -
-
-

{job.name}

- {job.schedule} +
+
+
+
+
Board
+

Filesystem-backed lanes

+
+
All three stages visible at once
+
+ +
+ {#each incubator.stages as stage, index} +
+
+
+
Stage {index + 1}
+

{stage.label}

+

{stage.description}

+
+
{stage.count}
-
- {job.status} - {job.lastRun} + +
+ {#if stage.ideas.length} + {#each stage.ideas as idea} + + {/each} + {:else} +
Nothing here yet. The next strong idea can claim this lane.
+ {/if}
{/each}
- {:else} -
- No scheduled jobs found. -

Add or enable OpenClaw cron jobs and they will show up here automatically.

-
- {/if} -
- - -
-
-
- -

Safe-to-display OpenClaw config snapshot

-

Secret-looking fields are redacted before they reach the UI.

-
-
{JSON.stringify(monitor.sanitizedConfig, null, 2)}
-
- - - - +
+ {#each [...implementingIdeas, ...incubatingIdeas].slice(0, 4) as idea} + + {/each} +
+ + + + diff --git a/src/routes/api/incubator/+server.ts b/src/routes/api/incubator/+server.ts new file mode 100644 index 0000000..34dead8 --- /dev/null +++ b/src/routes/api/incubator/+server.ts @@ -0,0 +1,48 @@ +import { json } from '@sveltejs/kit'; +import { + createRawIdea, + getIncubatorSnapshot, + incubateIdea, + promoteToImplementing +} from '$lib/server/idea-incubator'; + +export const GET = async () => { + return json(getIncubatorSnapshot()); +}; + +export const POST = async ({ request }) => { + const body = (await request.json()) as { + action?: 'capture' | 'incubate' | 'promote'; + rawIdea?: string; + sourcePath?: string; + }; + + if (body.action === 'capture') { + if (!body.rawIdea?.trim()) { + return json({ error: 'rawIdea is required' }, { status: 400 }); + } + + const created = createRawIdea(body.rawIdea); + return json({ created, snapshot: getIncubatorSnapshot() }); + } + + if (body.action === 'incubate') { + if (!body.rawIdea?.trim() && !body.sourcePath) { + return json({ error: 'rawIdea or sourcePath is required' }, { status: 400 }); + } + + const created = incubateIdea({ rawIdea: body.rawIdea, sourcePath: body.sourcePath }); + return json({ created, snapshot: getIncubatorSnapshot() }); + } + + if (body.action === 'promote') { + if (!body.sourcePath) { + return json({ error: 'sourcePath is required' }, { status: 400 }); + } + + const created = promoteToImplementing(body.sourcePath); + return json({ created, snapshot: getIncubatorSnapshot() }); + } + + return json({ error: 'Unsupported action' }, { status: 400 }); +}; diff --git a/vite.config.ts b/vite.config.ts index bbf8c7d..bf699a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit()] + plugins: [tailwindcss(), sveltekit()] });