diff --git a/.claude/skills/brand/SKILL.md b/.claude/skills/brand/SKILL.md new file mode 100644 index 0000000..feecb22 --- /dev/null +++ b/.claude/skills/brand/SKILL.md @@ -0,0 +1,101 @@ +--- +name: brand +description: The Carlos Arias website brand system (carlosarias.co) — a sumi-e identity: ink on washi paper with a single vermillion seal. Use whenever designing, building, restyling, or reviewing any page, component, or image for this site, so the work stays on-brand. Covers the palette, the seal rule (how sparingly red may appear), the type scale, radii, spacing, imagery direction, and the do/don't list. Source of truth: brand/BRAND.md and the tokens in app/src/styles.css. +--- + +# Skill: Carlos Arias brand system (sumi-e) + +You are designing for **carlosarias.co** — the portfolio of an Agentic AI & Automation +Engineer whose audience is engineers, CTOs, and technical hiring managers. Technical +credibility comes first; projects lead, marketing is supporting context. + +The identity is **sumi-e**: traditional Japanese ink painting — white paper, black sumi ink, +and one red *hanko* seal pressed at the end. That is the whole system. Adjacent to wabi-sabi +(negative space, asymmetry, restraint, natural imperfection) but permits exactly one +saturated color. + +**Always match the existing design system first.** Read `app/src/styles.css` (the `--ca-*` +tokens and the `.ca-*` component classes) and the nearby components before inventing anything. +Reuse existing `.ca-*` classes rather than adding new styles. Full guide: `brand/BRAND.md`. + +## The one rule that matters most — the seal rule + +**Red is a seal, not a color. Target 1–2% coverage per screen.** This single discipline is +what makes the site read as Japanese minimalism instead of a generic startup page with a red +button. Same hex, entirely different design language. + +Red (`--ca-seal`, `#D0342C` light / `#E8564A` dark) is permitted **only** on: +- the seal mark / logo lozenge (the 印), +- **one** inline link or emphasis per view, +- **one** active state (current nav item, selected tag), +- hairline accents at small scale. + +Red is **never** allowed on: +- primary buttons — these are **ink** (`--ca-ink`), never red, +- headlines or body copy, +- filled bands, blocks, or backgrounds, +- more than one element competing for attention in the same view. + +If a screen has two things fighting to be the red thing, one of them is wrong. When in doubt, +make it ink and leave the seal for the single most important accent. + +## Palette (use the tokens, never raw hex) + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--ca-paper` | `#F6F5F2` | `#121317` | Page ground — **never pure `#FFF`** | +| `--ca-paper-2` | `#EFEEEA` | `#191A1F` | Raised surface | +| `--ca-rule` | `#DFDDD7` | `#2A2C33` | Hairlines, borders | +| `--ca-ink-4` | `#9A9CA3` | `#5E606A` | Disabled, faint meta | +| `--ca-ink-3` | `#6E7078` | `#8B8D95` | Secondary text | +| `--ca-ink-2` | `#3D3F47` | `#C2C1BD` | Body text | +| `--ca-ink` | `#14151A` | `#EDECE8` | Headings, primary, buttons | +| `--ca-seal` | `#D0342C` | `#E8564A` | The seal — see the rule above | + +Two choices to preserve: the ground is **washi-toned, never pure white**; the near-black is +**blue-black, not neutral grey** (sumi ink has a cool cast, and that bias runs through the +whole grey ramp). Dark mode uses a more luminous seal on purpose — it is not a naive inversion. + +## Type + +1.333 modular scale, wide intervals. Serif for display/headings, sans for body, mono for +eyebrows/meta. Eyebrows/meta are uppercase with `0.12em` tracking. Running text caps at +`62ch`; headings get `text-wrap: balance`. Use the existing `--ca-s1`…`--ca-s8` scale tokens +and the `.ca-*` type classes — don't introduce new sizes. + +## Form & layout + +- **Near-square radius (2px).** No rounded cards. +- **Hairline rules** for separation. No drop shadows, no elevation. +- **Asymmetric, off-grid** placement. Not centered-everything. +- Density: the homepage runs sparse and airy; project index and detail pages are intentionally + denser (that's where evaluators dig in). Restraint at the front, substance behind it. + +## Imagery + +Generated imagery is fine for texture, environment, and project visuals — **never for +portraits** (a detectably synthetic photo of the person is an unrecoverable credibility hit on +an AI-expertise site). Portraits are real photography, graded to the palette. On-palette +generation fragment: + +> washi-paper ground, sumi ink blacks, single muted vermillion accent (#D0342C), natural +> directional light, soft film grain, generous negative space, asymmetric off-center +> composition, low saturation, matte finish, no gloss + +Explicitly avoid (these read as "AI imagery" and destroy the identity): blue/cyan/purple +glowing circuitry or neural-network motifs, holographic UI, saturated gradients, lens flares, +bokeh bursts, glossy chrome tech-product rendering, high-key studio lighting, symmetrical +centered compositions, or more than one accent color. + +## Do / don't + +| Do | Don't | +|---|---| +| Ink primary buttons | Red primary buttons | +| One red element per view | Red as a general accent | +| Washi ground `#F6F5F2` | Pure white `#FFFFFF` | +| Near-square radius (2px) | Rounded cards | +| Asymmetric, off-grid placement | Centered everything | +| Hairline rules for separation | Drop shadows and elevation | +| Real photography for portraits | Generated portraits | +| Reuse existing `.ca-*` classes/tokens | Inventing new one-off styles | diff --git a/.claude/skills/ui-ux b/.claude/skills/ui-ux new file mode 120000 index 0000000..f9552c4 --- /dev/null +++ b/.claude/skills/ui-ux @@ -0,0 +1 @@ +../../app/.astroagent/skills/ui-ux \ No newline at end of file diff --git a/agents/console/server.mjs b/agents/console/server.mjs index 99793a9..84d0a21 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -42,6 +42,7 @@ const APP = join(REPO, "app"); const PREVIEW_DIR = join(REPO, "public-preview"); const ENV_FILE = join(REPO, "agents", ".env"); const CLAUDE = "/usr/local/bin/claude"; +const PHP = "/usr/bin/php"; const PORT = Number(process.env.ADMIN_PORT || 3011); const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch"; const DEFAULT_MODEL = "claude-sonnet-4-6"; @@ -399,6 +400,171 @@ function buildProject({ name, category, draft, prompt, images, instagram }) { }); } +// ---- Web Designer task queue ------------------------------------------------- +// A durable design queue (cja_tasks) drained one task at a time. Each task runs +// the full-builder agent on any page (skills + brief), then auto-publishes: +// build must pass, then a scoped git commit. A failing task reverts itself and +// the queue moves on. The runner has no DB driver, so it claims/finishes rows +// via the api/cli/tasks-*.php helpers. +const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"]; +let draining = false; + +function phpCli(args) { + return new Promise((resolve) => { + const child = spawn(PHP, args, { cwd: REPO, env: agentEnv() }); + let out = "", err = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (err += d)); + child.on("close", () => resolve({ out, err })); + child.on("error", () => resolve({ out: "", err: "php spawn failed" })); + }); +} + +async function claimNextTask() { + const { out } = await phpCli(["api/cli/tasks-next.php"]); + try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; } + catch { return null; } +} +async function finishTask(id, status, result) { + await phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]); +} + +function liveUrl(target) { + if (!target) return "/"; + if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, ""); + return target; +} + +function buildDesignPrompt(task) { + let assets = { images: [], videos: [] }; + try { assets = JSON.parse(task.assets || "{}") || {}; } catch {} + const images = Array.isArray(assets.images) ? assets.images : []; + const videos = Array.isArray(assets.videos) ? assets.videos : []; + const target = task.target_page || "/"; + const isNew = target.startsWith("new:"); + const slug = isNew ? target.slice(4).replace(/^\/+/, "") : ""; + + const p = []; + p.push( + "You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.", + "You are a real designer: make considered, on-brand design decisions, not just literal edits.", + "", + "Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.", + "The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.", + "Do NOT run builds or git — the console builds and publishes for you.", + "", + ); + if (isNew) { + p.push( + `TASK: create a NEW page at /${slug}.`, + `- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`, + "- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.", + "", + ); + } else { + p.push( + `TASK: work on the existing page ${target}.`, + "- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.", + "", + ); + } + p.push("What to do:", task.prompt || "(no instructions given)", ""); + if (task.draft && String(task.draft).trim()) { + p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, ""); + } + if (images.length) { + p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:"); + for (const im of images) { + p.push(`- ${im.url} (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`); + } + p.push(""); + } + if (videos.length) { + p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):"); + for (const v of videos) p.push(`- ${v}`); + p.push(""); + } + p.push("Keep the change scoped to what's asked and leave the working tree with only your intended edits."); + return p.join("\n"); +} + +async function runDesignTask(task) { + const prompt = buildDesignPrompt(task); + + // Pre-flight: the auto-publish commit is scoped to GIT_SCOPE, so a dirty tree + // would get swept into this task's commit. Refuse rather than clobber WIP. + const pre = await git(["status", "--porcelain", "--", ...GIT_SCOPE]); + if (pre.tail.trim() !== "") { + return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." }; + } + + return new Promise((resolve) => { + const args = [ + "-p", prompt, + "--output-format", "json", + "--allowedTools", "Read Write Edit Glob Grep WebSearch Skill", + "--model", DEFAULT_MODEL, + ]; + const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); + let out = ""; + child.stdout.on("data", (d) => (out += d)); + child.on("close", async () => { + let summary = ""; + try { + const env = JSON.parse(out); + if (typeof env.result === "string") summary = env.result.trim().slice(0, 500); + } catch {} + + // Any source changes to publish? + const status = await git(["status", "--porcelain", "--", ...GIT_SCOPE]); + const changed = status.tail.trim() !== ""; + if (!changed) { + return resolve({ ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) }); + } + + // Build must pass before anything is committed. + const built = await build({}); + if (!built.ok) { + await git(["checkout", "--", ...GIT_SCOPE]); + await git(["clean", "-fd", ...GIT_SCOPE]); // drop any new files the agent added + return resolve({ ok: false, error: "The change broke the build, so it was reverted." }); + } + await git(["add", "--", ...GIT_SCOPE]); + const title = (task.title || "task").toString().slice(0, 120); + await git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]); + const head = await git(["rev-parse", "--short", "HEAD"]); + resolve({ ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) }); + }); + child.on("error", () => resolve({ ok: false, error: "Could not start the agent." })); + }); +} + +// Drain the queue sequentially. Shares the global `busy` lock so it never runs +// concurrently with the in-page console. Re-entrant-safe via `draining`. +async function drainTasks() { + if (draining) return; + draining = true; + try { + for (;;) { + if (busy) break; // in-page console is mid-edit; a later kick resumes the queue + const task = await claimNextTask(); + if (!task) break; + busy = true; + let res; + try { res = await runDesignTask(task); } + catch (e) { res = { ok: false, error: String((e && e.message) || e) }; } + busy = false; + await finishTask( + task.task_id, + res.ok ? "published" : "failed", + res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" }, + ); + } + } finally { + draining = false; + } +} + // ---- request helpers --------------------------------------------------------- function json(res, status, obj) { const body = JSON.stringify(obj); @@ -493,6 +659,15 @@ const server = createServer(async (req, res) => { return json(res, 200, d); } + // Kick the Web Designer task queue: drain any queued design tasks. Returns + // immediately; the admin UI polls /api/admintasks/list for status. + if (path === "/tasks/run" && method === "POST") { + const env = loadEnv(); + if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." }); + drainTasks(); // fire-and-forget + return json(res, 200, { ok: true, draining, busy }); + } + if (path === "/stream") { const id = url.searchParams.get("conversationId"); const job = id && jobs.get(id); diff --git a/api/cli/tasks-finish.php b/api/cli/tasks-finish.php new file mode 100644 index 0000000..58f2f05 --- /dev/null +++ b/api/cli/tasks-finish.php @@ -0,0 +1,42 @@ + --status=published|failed --result=\n"); + exit(1); +} + +// Validate the result payload is JSON; fall back to empty object if not. +if (json_decode($result) === null && json_last_error() !== JSON_ERROR_NONE) { + $result = '{}'; +} + +$pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME), + DB_USER, + DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] +); + +$stmt = $pdo->prepare( + 'UPDATE cja_tasks SET status = ?, result = ?, finished_at = NOW() WHERE task_id = ?' +); +$stmt->execute([$status, $result, $id]); + +echo json_encode(['ok' => true, 'id' => $id, 'status' => $status]); diff --git a/api/cli/tasks-next.php b/api/cli/tasks-next.php new file mode 100644 index 0000000..7c1aacf --- /dev/null +++ b/api/cli/tasks-next.php @@ -0,0 +1,53 @@ + running) guarded by + * task_id, so two concurrent callers can never claim the same row. + */ + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../config.php'; + +$pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME), + DB_USER, + DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +// Find + claim in a loop: SELECT the next candidate, then a guarded UPDATE. +// If the UPDATE affects 0 rows (someone else took it), try the next candidate. +for ($attempt = 0; $attempt < 20; $attempt++) { + $row = $pdo->query( + "SELECT task_id FROM cja_tasks WHERE status = 'queued' + ORDER BY sort_order ASC, task_id ASC LIMIT 1" + )->fetch(); + + if (!$row) { + echo "{}"; + exit; + } + + $id = (int) $row['task_id']; + $upd = $pdo->prepare( + "UPDATE cja_tasks SET status = 'running', started_at = NOW() + WHERE task_id = ? AND status = 'queued'" + ); + $upd->execute([$id]); + + if ($upd->rowCount() === 1) { + $task = $pdo->prepare('SELECT task_id, title, target_page, prompt, draft, assets FROM cja_tasks WHERE task_id = ?'); + $task->execute([$id]); + echo json_encode($task->fetch(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; + } + // lost the race — loop and grab the next one +} + +echo "{}"; diff --git a/api/db/migrations/011_create_cja_tasks.sql b/api/db/migrations/011_create_cja_tasks.sql new file mode 100644 index 0000000..030f0e9 --- /dev/null +++ b/api/db/migrations/011_create_cja_tasks.sql @@ -0,0 +1,35 @@ +-- Web Designer task queue — design requests the agent drains one at a time. +-- +-- The admin submits a brief (target page, prompt, draft, assets) which lands +-- here as `queued`. The console runner claims the oldest queued row, runs the +-- design agent, builds, and auto-publishes (git commit), then marks it +-- `published` (or `failed`). The queue is FIFO by (sort_order, task_id). +-- +-- `assets` is JSON: { "images": [{ "url": "/media/x.webp", "alt": "" }], +-- "videos": ["https://instagram.com/reel/..."] } +-- `result` is JSON, written on completion: { commit, summary, live, error }. + +CREATE TABLE IF NOT EXISTS `cja_tasks` ( + `task_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + + `title` varchar(200) NOT NULL DEFAULT '', + -- "/about", "/services", or "new:" to create a brand-new page + `target_page` varchar(200) NOT NULL DEFAULT '', + + `prompt` text DEFAULT NULL, -- the request / special instructions + `draft` mediumtext DEFAULT NULL, -- optional draft content + `assets` longtext DEFAULT NULL CHECK (json_valid(`assets`)), + + `status` enum('queued','running','published','failed','canceled') + NOT NULL DEFAULT 'queued', + `result` longtext DEFAULT NULL CHECK (`result` IS NULL OR json_valid(`result`)), + + `sort_order` int(11) NOT NULL DEFAULT 0, + + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + `started_at` datetime DEFAULT NULL, + `finished_at` datetime DEFAULT NULL, + + PRIMARY KEY (`task_id`), + KEY `idx_cja_tasks_queue` (`status`, `sort_order`, `task_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/api/public/controllers/admintasks.php b/api/public/controllers/admintasks.php new file mode 100644 index 0000000..72a6fa0 --- /dev/null +++ b/api/public/controllers/admintasks.php @@ -0,0 +1,130 @@ + { title, target_page, prompt, draft, assets } + * GET /api/admintasks/list -> [{ task_id, title, status, ... }] + * GET /api/admintasks/get?id= -> full row + result + * POST /api/admintasks/cancel -> { id } (only if still queued) + * + * All admin-gated. This controller only does CRUD on the queue; the runner owns + * execution. The Node runner claims/finishes rows via api/cli/tasks-*.php. + */ +class AdminTasks extends PublicController +{ + private const STATUSES = ['queued', 'running', 'published', 'failed', 'canceled']; + + public function create(): void + { + $this->requireAdmin(); + $this->guardPublic('admintasks_create', 60, 300); + + $in = json_decode((string) file_get_contents('php://input'), true) ?: []; + + $title = mb_substr(trim((string) ($in['title'] ?? '')), 0, 200); + $target = mb_substr(trim((string) ($in['target_page'] ?? '')), 0, 200); + $prompt = trim((string) ($in['prompt'] ?? '')); + $draft = (string) ($in['draft'] ?? ''); + + if ($prompt === '') { + $this->json(null, 400, ['code' => 'no_prompt', 'message' => 'Describe what you want done.']); + } + if ($target === '') { + $this->json(null, 400, ['code' => 'no_target', 'message' => 'Pick a page (or a new page) for the task.']); + } + if ($title === '') { + $title = mb_substr($prompt, 0, 80); + } + + // Assets: only known-safe local /media/ images and real http(s) video URLs. + $images = []; + foreach ((array) ($in['assets']['images'] ?? []) as $img) { + $url = trim((string) ($img['url'] ?? '')); + if (preg_match('#^/media/[\w./-]+$#', $url)) { + $images[] = ['url' => $url, 'alt' => mb_substr(trim((string) ($img['alt'] ?? '')), 0, 200)]; + } + } + $videos = []; + foreach ((array) ($in['assets']['videos'] ?? []) as $v) { + $url = trim((string) $v); + if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL) && preg_match('#^https?://#i', $url)) { + $videos[] = mb_substr($url, 0, 500); + } + } + $assets = json_encode(['images' => $images, 'videos' => $videos], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $order = (int) \Db::getValue('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM cja_tasks'); + \Db::insert('cja_tasks', [ + 'title' => $title, + 'target_page' => $target, + 'prompt' => $prompt, + 'draft' => $draft, + 'assets' => $assets, + 'status' => 'queued', + 'sort_order' => $order, + ]); + + $this->json(['ok' => true, 'id' => (int) \Db::getValue('SELECT LAST_INSERT_ID()')]); + } + + public function list(): void + { + $this->requireAdmin(); + $rows = \Db::select( + 'SELECT task_id, title, target_page, status, result, created_at, finished_at + FROM cja_tasks ORDER BY sort_order DESC, task_id DESC LIMIT 100' + ); + $out = array_map(function ($r) { + $result = json_decode($r['result'] ?? '', true) ?: []; + return [ + 'id' => (int) $r['task_id'], + 'title' => $r['title'], + 'target' => $r['target_page'], + 'status' => $r['status'], + 'commit' => $result['commit'] ?? '', + 'summary' => $result['summary'] ?? '', + 'live' => $result['live'] ?? '', + 'error' => $result['error'] ?? '', + 'created' => $r['created_at'], + 'finished' => $r['finished_at'], + ]; + }, $rows); + $this->json(['tasks' => $out]); + } + + public function get(): void + { + $this->requireAdmin(); + $id = (int) ($_GET['id'] ?? 0); + $r = \Db::getRow('SELECT * FROM cja_tasks WHERE task_id = ?', [$id]); + if (!$r) { + $this->json(null, 404, ['code' => 'not_found', 'message' => 'No such task.']); + } + $r['assets'] = json_decode($r['assets'] ?? '{}', true) ?: []; + $r['result'] = json_decode($r['result'] ?? '{}', true) ?: []; + $this->json($r); + } + + /** Cancel a task that has not started yet. */ + public function cancel(): void + { + $this->requireAdmin(); + $in = json_decode((string) file_get_contents('php://input'), true) ?: []; + $id = (int) ($in['id'] ?? 0); + $status = (string) \Db::getValue('SELECT status FROM cja_tasks WHERE task_id = ?', [$id]); + if ($status === '') { + $this->json(null, 404, ['code' => 'not_found', 'message' => 'No such task.']); + } + if ($status !== 'queued') { + $this->json(null, 409, ['code' => 'not_queued', 'message' => 'Only queued tasks can be canceled.']); + } + \Db::update('cja_tasks', ['status' => 'canceled', 'finished_at' => date('Y-m-d H:i:s')], 'task_id = ?', [$id]); + $this->json(['ok' => true]); + } +}