diff --git a/README.md b/README.md index 45754df..3f3975d 100644 --- a/README.md +++ b/README.md @@ -70,17 +70,27 @@ npm run tauri build | Dashboard | List View | | :--------------------------------------------------------------: | :------------------------------------------------------------: | | ![Dashboard](docs/screenshots/dashboard.png) | ![List view](docs/screenshots/dashboard-list.png) | -| _All your sites at a glance, with live container status badges_ | _A denser take on the dashboard for lots of sites_ | +| _Every site at a glance — WordPress, PHP and Docker — with live status badges_ | _A denser table view for lots of sites_ | -| Site Detail | New Site | +| Site Detail | Tools | | :-------------------------------------------------------------: | :------------------------------------------------------------: | -| ![Site detail](docs/screenshots/site-detail.png) | ![New site](docs/screenshots/new-site.png) | -| _Credentials, wp-cli info, ServerKit sync & history_ | _Pick a name, WordPress version, and PHP version_ | +| ![Site detail](docs/screenshots/site-detail.png) | ![Tools tab](docs/screenshots/site-tools.png) | +| _Credentials, database, wp-cli info, snapshots & sync_ | _Adminer DB browser, safe search-replace, WP\_DEBUG & a config editor — in-app_ | -| Settings | Local Domains | +| Snapshots | New Site | +| :-------------------------------------------------------------: | :------------------------------------------------------------: | +| ![Snapshots](docs/screenshots/snapshots.png) | ![New site](docs/screenshots/new-site.png) | +| _One-click restore; one is taken before every push, pull & delete_ | _WordPress, PHP/Laravel or a Docker project — blank or from a blueprint_ | + +| Import | Settings | +| :-------------------------------------------------------------: | :------------------------------------------------------------: | +| ![Import remote site](docs/screenshots/import-site.png) | ![Settings](docs/screenshots/settings.png) | +| _Clone a remote ServerKit site down as a fresh local copy_ | _Docker status, updates, data paths & defaults_ | + +| Local Domains | ServerKit | | :--------------------------------------------------------------: | :-------------------------------------------------------------: | -| ![Settings](docs/screenshots/settings.png) | ![Local domains](docs/screenshots/settings-domains.png) | -| _Docker status, data paths, and ServerKit connections_ | _Serve sites as `http://.test` via a shared Caddy router_ | +| ![Local domains](docs/screenshots/settings-domains.png) | ![ServerKit](docs/screenshots/settings-serverkit.png) | +| _Serve sites as `http://.test` via a shared Caddy router_ | _Browse a server's sites and push/pull or import them_ | ## 🎯 Features diff --git a/docs/README.es.md b/docs/README.es.md index bd444d3..d6c233e 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -68,17 +68,27 @@ npm run tauri build | Panel Principal | Vista de Lista | | :--------------------------------------------------------------: | :------------------------------------------------------------: | | ![Panel Principal](screenshots/dashboard.png) | ![Vista de lista](screenshots/dashboard-list.png) | -| _Todos tus sitios de un vistazo, con insignias de estado de contenedores en vivo_ | _Una versión más densa del panel para muchos sitios_ | +| _Todos tus sitios de un vistazo — WordPress, PHP y Docker — con insignias de estado en vivo_ | _Una vista de tabla más densa para muchos sitios_ | -| Detalle del Sitio | Nuevo Sitio | +| Detalle del Sitio | Herramientas | | :-------------------------------------------------------------: | :------------------------------------------------------------: | -| ![Detalle del sitio](screenshots/site-detail.png) | ![Nuevo sitio](screenshots/new-site.png) | -| _Credenciales, información de wp-cli, sincronización con ServerKit e historial_ | _Elige un nombre, versión de WordPress y versión de PHP_ | +| ![Detalle del sitio](screenshots/site-detail.png) | ![Herramientas](screenshots/site-tools.png) | +| _Credenciales, base de datos, información de wp-cli, snapshots y sincronización_ | _Explorador de BD Adminer, buscar-reemplazar seguro, WP\_DEBUG y editor de configuración — dentro de la app_ | -| Ajustes | Dominios Locales | +| Snapshots | Nuevo Sitio | +| :-------------------------------------------------------------: | :------------------------------------------------------------: | +| ![Snapshots](screenshots/snapshots.png) | ![Nuevo sitio](screenshots/new-site.png) | +| _Restauración en un clic; se toma uno antes de cada push, pull y borrado_ | _WordPress, PHP/Laravel o un proyecto Docker — en blanco o desde un blueprint_ | + +| Importar | Ajustes | +| :-------------------------------------------------------------: | :------------------------------------------------------------: | +| ![Importar sitio remoto](screenshots/import-site.png) | ![Ajustes](screenshots/settings.png) | +| _Clona un sitio remoto de ServerKit como una copia local nueva_ | _Estado de Docker, actualizaciones, rutas de datos y valores por defecto_ | + +| Dominios Locales | ServerKit | | :--------------------------------------------------------------: | :-------------------------------------------------------------: | -| ![Ajustes](screenshots/settings.png) | ![Dominios locales](screenshots/settings-domains.png) | -| _Estado de Docker, rutas de datos y conexiones con ServerKit_ | _Sirve sitios como `http://.test` mediante un router Caddy compartido_ | +| ![Dominios locales](screenshots/settings-domains.png) | ![ServerKit](screenshots/settings-serverkit.png) | +| _Sirve sitios como `http://.test` mediante un router Caddy compartido_ | _Explora los sitios de un servidor y haz push/pull o impórtalos_ | ## 🎯 Funcionalidades diff --git a/docs/README.pt.md b/docs/README.pt.md index e789847..6c77811 100644 --- a/docs/README.pt.md +++ b/docs/README.pt.md @@ -68,17 +68,27 @@ npm run tauri build | Painel | Visualização em Lista | | :--------------------------------------------------------------: | :------------------------------------------------------------: | | ![Painel](screenshots/dashboard.png) | ![Visualização em lista](screenshots/dashboard-list.png) | -| _Todos os seus sites num relance, com selos de status dos contêineres ao vivo_ | _Uma versão mais densa do painel para muitos sites_ | +| _Todos os seus sites num relance — WordPress, PHP e Docker — com selos de status ao vivo_ | _Uma visão em tabela mais densa para muitos sites_ | -| Detalhe do Site | Novo Site | +| Detalhe do Site | Ferramentas | | :-------------------------------------------------------------: | :------------------------------------------------------------: | -| ![Detalhe do site](screenshots/site-detail.png) | ![Novo site](screenshots/new-site.png) | -| _Credenciais, informações do wp-cli, sincronização com ServerKit e histórico_ | _Escolha um nome, a versão do WordPress e a versão do PHP_ | +| ![Detalhe do site](screenshots/site-detail.png) | ![Ferramentas](screenshots/site-tools.png) | +| _Credenciais, banco de dados, informações do wp-cli, snapshots e sincronização_ | _Navegador de BD Adminer, busca-substituição segura, WP\_DEBUG e editor de configuração — no app_ | -| Configurações | Domínios Locais | +| Snapshots | Novo Site | +| :-------------------------------------------------------------: | :------------------------------------------------------------: | +| ![Snapshots](screenshots/snapshots.png) | ![Novo site](screenshots/new-site.png) | +| _Restauração num clique; um é feito antes de cada push, pull e exclusão_ | _WordPress, PHP/Laravel ou um projeto Docker — em branco ou a partir de um blueprint_ | + +| Importar | Configurações | +| :-------------------------------------------------------------: | :------------------------------------------------------------: | +| ![Importar site remoto](screenshots/import-site.png) | ![Configurações](screenshots/settings.png) | +| _Clone um site remoto do ServerKit como uma nova cópia local_ | _Status do Docker, atualizações, caminhos de dados e padrões_ | + +| Domínios Locais | ServerKit | | :--------------------------------------------------------------: | :-------------------------------------------------------------: | -| ![Configurações](screenshots/settings.png) | ![Domínios locais](screenshots/settings-domains.png) | -| _Status do Docker, caminhos de dados e conexões com ServerKit_ | _Sirva sites como `http://.test` através de um roteador Caddy compartilhado_ | +| ![Domínios locais](screenshots/settings-domains.png) | ![ServerKit](screenshots/settings-serverkit.png) | +| _Sirva sites como `http://.test` através de um roteador Caddy compartilhado_ | _Navegue pelos sites de um servidor e faça push/pull ou importe-os_ | ## 🎯 Funcionalidades diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 4e5f3a5..61689d4 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -68,17 +68,27 @@ npm run tauri build | 仪表盘 | 列表视图 | | :--------------------------------------------------------------: | :------------------------------------------------------------: | | ![仪表盘](screenshots/dashboard.png) | ![列表视图](screenshots/dashboard-list.png) | -| _一览所有站点,容器状态徽标实时更新_ | _站点较多时更紧凑的仪表盘视图_ | +| _一览所有站点 —— WordPress、PHP 与 Docker —— 状态徽标实时更新_ | _站点较多时更紧凑的表格视图_ | -| 站点详情 | 新建站点 | +| 站点详情 | 工具 | | :-------------------------------------------------------------: | :------------------------------------------------------------: | -| ![站点详情](screenshots/site-detail.png) | ![新建站点](screenshots/new-site.png) | -| _凭据、wp-cli 信息、ServerKit 同步与历史记录_ | _选择名称、WordPress 版本和 PHP 版本_ | +| ![站点详情](screenshots/site-detail.png) | ![工具](screenshots/site-tools.png) | +| _凭据、数据库、wp-cli 信息、快照与同步_ | _Adminer 数据库浏览器、安全的搜索替换、WP\_DEBUG 与配置编辑器 —— 全部内置_ | -| 设置 | 本地域名 | +| 快照 | 新建站点 | +| :-------------------------------------------------------------: | :------------------------------------------------------------: | +| ![快照](screenshots/snapshots.png) | ![新建站点](screenshots/new-site.png) | +| _一键还原;每次推送、拉取和删除前都会自动生成一个_ | _WordPress、PHP/Laravel 或 Docker 项目 —— 空白或基于蓝图_ | + +| 导入 | 设置 | +| :-------------------------------------------------------------: | :------------------------------------------------------------: | +| ![导入远程站点](screenshots/import-site.png) | ![设置](screenshots/settings.png) | +| _将远程 ServerKit 站点克隆为一个全新的本地副本_ | _Docker 状态、更新、数据路径与默认设置_ | + +| 本地域名 | ServerKit | | :--------------------------------------------------------------: | :-------------------------------------------------------------: | -| ![设置](screenshots/settings.png) | ![本地域名](screenshots/settings-domains.png) | -| _Docker 状态、数据路径和 ServerKit 连接_ | _通过共享 Caddy 路由器以 `http://.test` 提供站点访问_ | +| ![本地域名](screenshots/settings-domains.png) | ![ServerKit](screenshots/settings-serverkit.png) | +| _通过共享 Caddy 路由器以 `http://.test` 提供站点访问_ | _浏览服务器上的站点并进行推送/拉取或导入_ | ## 🎯 功能特性 diff --git a/docs/plans/27_visual-identity-pass.md b/docs/plans/27_visual-identity-pass.md new file mode 100644 index 0000000..2702b75 --- /dev/null +++ b/docs/plans/27_visual-identity-pass.md @@ -0,0 +1,79 @@ +# 27 — Visual identity pass: site tiles, action icons, micro-polish + +Status: ✅ shipped + +Frontend-only. No Rust, no migrations. Two icon dependencies, per the +maintainer's direction (the Faro pattern): `lucide-react` for UI chrome and +`@iconify/react/offline` + a committed, generated subset of +`@iconify-json/simple-icons` for real brand marks — zero icon network calls. + +## Motivation + +The UI is structurally complete but visually anonymous: every site is a block +of text, every action is a gray text pill, and the sidebar is two text links. +Competing tools (LocalWP, Herd, DevKinsta) feel "alive" mostly through one +device — each site is a recognizable *thing* with a face and a glanceable +running state. LocalKit has the information architecture; this pass gives it +the identity layer. + +## Design + +The existing token system (navy-tinted zinc scale, violet `#6C5CE7`, Inter + +JetBrains Mono, radius scale) is good and stays untouched. This is a pass +*within* the system, and the boldness is spent in exactly one place: + +**Signature: deterministic monogram tiles.** `SiteTile` renders a rounded +square with the site's initials, tinted by a hue hashed from the site *slug* +(FNV-1a → 0–360). The color is therefore stable across grid, list, detail, +restarts and machines — a site's tile is its face. The tile doubles as a +status light: + +- **up** (running/degraded): saturated gradient + soft same-hue glow +- **down** (stopped/error): same hue, desaturated and dim — identity + persists while the site sleeps +- **creating**: dim + pulse + +Everything else stays quiet and disciplined: + +- **Icons on actions.** `components/icons.tsx` becomes thin lucide-react + wrappers keeping the old names and `h-4 w-4` default (the hand-rolled set + was feather-derived, so nothing shifts visually): play, stop, + arrow-up-right (open), wrench (details), duplicate (clone), trash, + bookmark (blueprint), camera (snapshots), database, key, file-text (logs), + sync, search, bug, layers (sites nav). Buttons keep their labels — icons + lead, text stays for discoverability. +- **Real brand marks.** `scripts/gen-brand-icons.mjs` (Faro plan-14 pattern) + bakes a curated simple-icons subset (wordpress, docker, php, laravel, + mariadb) into `src/lib/brandIconData.ts`; `lib/brandIcons.tsx` renders + them offline. `KindBadge` shows the actual logo — and gains a distinct + PHP variant (it previously mislabeled `php` sites as "WP"). +- **Hero action.** "Open" on an up site becomes a violet-tinted button — + the one thing you most likely came to do. +- **Section headers** on SiteDetail + the Tools/Snapshots/Sync panels get a + small leading icon via a shared `SectionTitle`. +- **Sidebar**: layers icon on Sites plus a live running-count pill; + Terminal keeps its icon. +- **Empty state**: three overlapping tiles (one violet, glowing) as a pure-CSS + illustration + invitation copy. +- **Micro-motion**: card hover lift + border tint, transitions on buttons. + All transform motion gated behind `motion-reduce`. + +Out of scope, deliberately: Settings modal headers (secondary surface), light +theme, any palette/typography change, dialogs. + +## Phases + +1. `SiteTile` + `SectionTitle` + icon set additions. +2. Dashboard (grid cards, list rows, toolbar, empty state) + Sidebar. +3. SiteDetail header + section headers; Snapshots/Push/Tools panel headers. +4. Regenerate `docs/screenshots/` via `npm run shots`; verify `npm run build` + + `npm run test`; mark shipped. + +## Verification + +- `npm run build`, `npm run test` green; `cargo check` untouched by design. +- `npm run shots` — visual review of dashboard, dashboard-list, site-detail, + site-tools, snapshots captures; screenshots are the artifact of record. +- Screenshot script contract unchanged: it clicks by `textContent.trim()` + equality — icons are `aria-hidden` SVGs and contribute no text, so + `clickText`/`shotElement` selectors keep matching. diff --git a/docs/plans/28_control-room.md b/docs/plans/28_control-room.md new file mode 100644 index 0000000..26ee385 --- /dev/null +++ b/docs/plans/28_control-room.md @@ -0,0 +1,67 @@ +# 28 — Control room: home dashboard, light theme, live rail, stamped tiles + +Status: ✅ shipped + +Frontend-only. No Rust, no migrations, no new dependencies. Builds directly +on plan 27's tiles and icon layer, and on the maintainer's direction: +LocalKit should be *the screen you walk to the PC to look at* — a control +room, not a settings page. + +## Motivation + +After plan 27 the pieces are good but the architecture still reads flat: + +- The nav is two text rows (Sites, Terminal) — weak, and it wastes the one + place a glanceable "what's running" board could live. +- There is no home. The sites list doubles as a landing page, so the first + screen is management chrome, not status. +- Kind marks are tiny pills next to the name; the maintainer wants them + *stamped on the tile* like Faro stamps a protocol on a server bubble. +- Labeled buttons ("Open", "Stop") spend space that icons + tooltips cover. +- One theme. The maintainer wants light — and the ROADMAP has always noted + light needs a CSS-var token layer first. This plan builds that layer. + +## Design + +- **Token layer + light theme.** The Tailwind zinc/white ramps become CSS + variables (`rgb(var(--c-zinc-900) / )`) with dark defaults on + `:root` and a light ramp on `[data-theme="light"]`. Violet is shared. + A `theme` setting (default dark) with a Settings → General toggle; + `main.tsx` stamps `data-theme` pre-render, App keeps it in sync. + SiteTile's dimmed state reads lightness from vars so tiles work on both. +- **Home dashboard** (new default page): a status headline ("3 of 8 sites + running"), a tile wall of every site (glow = running, click = detail), + an attention list (degraded / error / half-created), and an environment + card (Docker, local domains router, ServerKit connections, blueprints). + All from existing stores — no new IPC. +- **Live rail.** The sidebar becomes a collapsible control rail: Home / + Sites / Terminal, then every site as a tile row with a status dot — + the active site expands sub-links (Overview, Tools, Snapshots, Logs). + Collapse state persists in the settings KV (`railCollapsed`). +- **Stamped tiles.** SiteTile gains a corner chip with the real brand mark + (WordPress / Docker / PHP via plan 27's offline set) — the Faro pattern. + Kind pills leave the cards and list rows (the stamp carries it); + KindBadge remains on the site detail header only. +- **Icon-only actions** on cards and list rows (tooltips + aria-labels + carry the words). "Resume setup" keeps its label — recovery must read. +- **Blueprint grid background** on the content pane — ServerKit's square + grid (1px lines each 40px, accent-tinted, ~5% alpha), theme-aware. +- **Detail tabs.** Overview | Tools | Snapshots | Logs — Snapshots and + Logs move out of the overview scroll. The tab lives in the nav store + (`page.tab`) so the rail can deep-link to it. + +## Phases + +1. Token layer + light theme + Settings toggle. +2. SiteTile stamps; dashboard cards/list go icon-only; pills out. +3. Rail v2 (collapse + live site list + sub-nav). +4. Home page + default-route change. +5. Detail tabs + blueprint grid background. +6. Capture script: light theme for all captures + a `home.png` shot + + tab-aware snapshot capture; regenerate; verify; mark shipped. + +## Verification + +- `npm run build`, `npm run test` green. +- `npm run shots` (light theme) — visual review of every capture. +- Dark theme spot-checked by toggling the setting in the mock UI. diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index 3a8af48..0bd970c 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -34,6 +34,8 @@ The file numbers ARE the build order — each plan leans on the ones before it. | 24 | `24_site-tools` | ✅ shipped | Tools tab: Adminer sidecar, serialization-safe search-replace, WP_DEBUG + log viewer, config editor. | | 25 | `25_release-polish-completion` | ✅ | M5 remainder: update checker, OS keyring for API keys, OS notifications, real test suite. | | 26 | `26_php-laravel-stack` | ✅ shipped | Generated PHP/Laravel stack + engine-native DB sync + per-kind ServerKit sync/import parity (needs 22, 17–19). Server-side php *hosting* awaits a php backend; `serverkit-localkit` advertises `kinds: ['wordpress']` until then. | +| 27 | `27_visual-identity-pass` | ✅ shipped | Frontend-only identity layer: deterministic monogram site tiles, lucide action icons + offline brand marks, sidebar running count, empty state, hover micro-motion. | +| 28 | `28_control-room` | ✅ shipped | Control-room pass: CSS-var token layer + light theme, home dashboard (new default page), collapsible live site rail, brand-stamped tiles, icon-only actions, blueprint-grid background, detail tabs. | Status glyphs: ✅ shipped · 🔄 partial · ⬜ not started · 🅿️ deferred diff --git a/docs/screenshots/CAPTURE.md b/docs/screenshots/CAPTURE.md index 8e4aef6..68b1e88 100644 --- a/docs/screenshots/CAPTURE.md +++ b/docs/screenshots/CAPTURE.md @@ -26,13 +26,16 @@ That script (`scripts/capture-screenshots.mjs`): | File | Screen | |---|---| -| `dashboard.png` | Site list, grid view — running / stopped / creating badges, `.test` domain URLs | +| `dashboard.png` | Site list, grid view — kind badges (WP / PHP / Docker), running / degraded / stopped / creating / setup-incomplete states, imported-from link, `.test` domain URLs | | `dashboard-list.png` | Site list, dense list view (toolbar toggle) | -| `site-detail.png` | Detail of a running site (credentials, DB, wp-cli info, sync history, logs) | -| `new-site.png` | NewSiteDialog open over the dashboard | -| `settings.png` | Settings modal — Docker status, version allowlists, a ServerKit connection | +| `site-detail.png` | Detail of a running WordPress site, Overview tab (credentials, DB, wp-cli info, snapshots, sync history, logs) | +| `snapshots.png` | The Snapshots panel on its own (element crop) — manual + before-push/pull history with one-click Restore | +| `site-tools.png` | Site detail → Tools tab (Adminer DB browser, search-replace, WP\_DEBUG + log, wp-config/.env editor) | +| `new-site.png` | NewSiteDialog over the dashboard — WordPress / PHP-Laravel / Docker tabs and the blueprint picker | +| `import-site.png` | Import-remote-site dialog (from Settings → ServerKit), with the version-fallback warning | +| `settings.png` | Settings modal — Docker status, updates, data paths, version allowlists | | `settings-domains.png` | Settings modal — Local domains section (router toggle, HTTPS trust) | -| `settings-serverkit.png` | Settings modal — ServerKit section (saved connection, remote sites) | +| `settings-serverkit.png` | Settings modal — ServerKit section (saved connection, expanded remote-sites table with Import buttons) | ## Tweaking the shots diff --git a/docs/screenshots/dashboard-list.png b/docs/screenshots/dashboard-list.png index b7e3101..878374b 100644 Binary files a/docs/screenshots/dashboard-list.png and b/docs/screenshots/dashboard-list.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index 22c198e..8077cc6 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png new file mode 100644 index 0000000..1457d0d Binary files /dev/null and b/docs/screenshots/home.png differ diff --git a/docs/screenshots/import-site.png b/docs/screenshots/import-site.png new file mode 100644 index 0000000..e41964a Binary files /dev/null and b/docs/screenshots/import-site.png differ diff --git a/docs/screenshots/new-site.png b/docs/screenshots/new-site.png index 00f69f7..29a86c5 100644 Binary files a/docs/screenshots/new-site.png and b/docs/screenshots/new-site.png differ diff --git a/docs/screenshots/settings-domains.png b/docs/screenshots/settings-domains.png index 4893b35..a298cf7 100644 Binary files a/docs/screenshots/settings-domains.png and b/docs/screenshots/settings-domains.png differ diff --git a/docs/screenshots/settings-serverkit.png b/docs/screenshots/settings-serverkit.png index fbab1aa..f5f14cd 100644 Binary files a/docs/screenshots/settings-serverkit.png and b/docs/screenshots/settings-serverkit.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 75c3116..d205b2f 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/site-detail.png b/docs/screenshots/site-detail.png index 2e5365a..45c0aab 100644 Binary files a/docs/screenshots/site-detail.png and b/docs/screenshots/site-detail.png differ diff --git a/docs/screenshots/site-tools.png b/docs/screenshots/site-tools.png new file mode 100644 index 0000000..3db718a Binary files /dev/null and b/docs/screenshots/site-tools.png differ diff --git a/docs/screenshots/snapshots.png b/docs/screenshots/snapshots.png new file mode 100644 index 0000000..bcc5717 Binary files /dev/null and b/docs/screenshots/snapshots.png differ diff --git a/package-lock.json b/package-lock.json index 73974ef..b86dd1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "localkit", "version": "0.1.0", "dependencies": { + "@iconify/react": "^6.0.2", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2.0.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.25.0", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^5.0.0" @@ -21,6 +23,8 @@ "devDependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@iconify-json/simple-icons": "^1.2.91", + "@iconify/utils": "^3.1.4", "@tauri-apps/cli": "^2.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", @@ -48,6 +52,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -877,6 +905,49 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.91", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.91.tgz", + "integrity": "sha512-plQJHZxzpkIXaeYjJF0Sc9nkzYF32EDX9olx7j1hcKYFaiTrQXbcngZmvzuV0pG6uZPU3ZuA/GIsgxSHOm6jrQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.2.tgz", + "integrity": "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3137,6 +3208,17 @@ "node": ">=0.10.0" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -3358,6 +3440,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3571,6 +3662,13 @@ "node": ">= 14" } }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", diff --git a/package.json b/package.json index 12e4333..7cb9c3d 100644 --- a/package.json +++ b/package.json @@ -7,18 +7,21 @@ "dev": "vite", "dev:mock": "vite --mode mock --port 1426 --strictPort", "shots": "node scripts/capture-screenshots.mjs", + "gen:brand-icons": "node scripts/gen-brand-icons.mjs", "build": "tsc && vite build", "test": "vitest run", "preview": "vite preview", "tauri": "tauri" }, "dependencies": { + "@iconify/react": "^6.0.2", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2.0.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.25.0", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^5.0.0" @@ -26,6 +29,8 @@ "devDependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@iconify-json/simple-icons": "^1.2.91", + "@iconify/utils": "^3.1.4", "@tauri-apps/cli": "^2.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs index 2ea5c2c..5083402 100644 --- a/scripts/capture-screenshots.mjs +++ b/scripts/capture-screenshots.mjs @@ -77,16 +77,52 @@ async function main() { }); const page = await browser.newPage(); page.on("pageerror", (e) => console.warn(" page error:", e.message)); + // Capture in the light theme (plan 28) — the settings store seeds from + // its localStorage mirror, so planting the key before load is enough. + await page.evaluateOnNewDocument(() => { + localStorage.setItem("localkit.settings.theme", "light"); + }); await page.goto(URL, { waitUntil: "networkidle0" }); const settle = (ms = 450) => sleep(ms); + const scrollTop = () => page.evaluate(() => window.scrollTo(0, 0)); + // Dismiss any toasts before a shot — the mock raises an "update available" + // launch toast that would otherwise sit in every capture. + const clearToasts = async () => { + await page.evaluate(() => { + document + .querySelectorAll('.fixed.bottom-4.right-4 button[aria-label="Dismiss"]') + .forEach((b) => b.click()); + }); + await sleep(150); + }; const shot = async (name) => { - const file = path.join(OUT, `${name}.png`); - await page.screenshot({ path: file }); + await clearToasts(); + await page.screenshot({ path: path.join(OUT, `${name}.png`) }); console.log(" ✓", `${name}.png`); }; - // Wait until some text appears anywhere in the document (case-insensitive; - // CSS `uppercase` classes change what innerText returns). + // Crop a single element (e.g. one panel/card) instead of the viewport — + // puppeteer scrolls it into view and clips to its bounding box. + const shotElement = async (name, selectorText, tag = "section", heading = "h2") => { + const handle = await page.evaluateHandle( + (t, sel, h) => { + const el = [...document.querySelectorAll(h)].find( + (x) => x.textContent.trim() === t + ); + return el ? el.closest(sel) : null; + }, + selectorText, + tag, + heading + ); + const el = handle.asElement(); + if (!el) throw new Error(`could not find ${tag} for ${heading} "${selectorText}"`); + await clearToasts(); + await el.screenshot({ path: path.join(OUT, `${name}.png`) }); + console.log(" ✓", `${name}.png (element)`); + }; + // Wait until some text appears anywhere in the *visible* document + // (innerText skips display:none, so hidden tabs don't count). const waitForText = (text, timeout = 15_000) => page.waitForFunction( (t) => document.body.innerText.toLowerCase().includes(t.toLowerCase()), @@ -94,6 +130,8 @@ async function main() { text ); // Click the first element matching `selector` whose trimmed text is `text`. + // Uses a direct .click() dispatch, so it works even when the element is + // visually covered by another overlay (no pointer hit-testing). const clickText = async (selector, text) => { const ok = await page.evaluate( (sel, t) => { @@ -109,32 +147,89 @@ async function main() { ); if (!ok) throw new Error(`could not click ${selector} "${text}"`); }; + // Direct-dispatch click on a button by aria-label (survives overlays). + const clickAria = async (label) => { + const ok = await page.evaluate((l) => { + const el = document.querySelector(`button[aria-label="${l}"]`); + if (!el) return false; + el.click(); + return true; + }, label); + if (!ok) throw new Error(`no button[aria-label="${label}"]`); + }; + // Click the "Import" button in the remote-sites table row named `name`. + const clickImportInRow = async (name) => { + const result = await page.evaluate((n) => { + const row = [...document.querySelectorAll("tr")].find( + (tr) => tr.querySelector("td")?.textContent.trim() === n + ); + if (!row) return "no-row"; + const btn = [...row.querySelectorAll("button")].find( + (b) => b.textContent.trim() === "Import" + ); + if (!btn) return "no-button"; + if (btn.disabled) return "disabled"; + btn.click(); + return "ok"; + }, name); + if (result !== "ok") throw new Error(`Import for "${name}": ${result}`); + }; - // 1) Dashboard — grid view with status badges. + // 0) Home (plan 28) — the landing page: status headline, tile wall, + // attention list, environment card. await waitForText("Pixel Bakery"); - await waitForText("Client Demo"); + await waitForText("Analytics API"); // the docker site — kinds have loaded + await waitForText("Environment"); await settle(600); + await shot("home"); + + // 1) Dashboard — grid view: stamped tiles, status badges (running / + // degraded / stopped / creating), a half-created site, plus PHP and + // Docker sites. + await clickAria("Sites"); + await waitForText("New Site"); + await settle(500); await shot("dashboard"); // 1b) Dashboard — dense list view via the toolbar toggle. - await page.click('button[aria-label="List view"]'); + await clickAria("List view"); await settle(400); await shot("dashboard-list"); - await page.click('button[aria-label="Grid view"]'); + await clickAria("Grid view"); await settle(300); - // 2) Site detail — open a running site (credentials, wp-cli info, sync). + // 2) Site detail — open a running WordPress site (overview tab: credentials, + // database, wp-cli info, sync). await clickText("button", "Pixel Bakery"); await waitForText("Core version:"); - await waitForText("Sync history"); + await waitForText("ServerKit sync"); await settle(600); + await scrollTop(); await shot("site-detail"); - // 3) New site dialog — back to the dashboard, open it, type a name. + // 2b) Snapshots tab (own tab since plan 28) — the manual + before-push/pull + // history that makes every destructive action reversible (plan 17). + await clickText("button", "snapshots"); + await waitForText("Take snapshot"); + await settle(400); + await shotElement("snapshots", "Snapshots"); + await scrollTop(); + + // 2c) Tools tab (plan 24) — Adminer DB browser, search-replace, WP_DEBUG, + // config editor. Capability-gated, so it only shows on kinds that have it. + await clickText("button", "tools"); + await waitForText("Adminer"); + await settle(500); + await scrollTop(); + await shot("site-tools"); + + // 3) New site dialog — back to the dashboard, open it, type a name. The + // WordPress tab shows the version selects and the blueprint picker. await clickText("button", "← Back to sites"); - await waitForText("Pixel Bakery"); + // The rail always shows site names, so wait on dashboard-only chrome. + await waitForText("New Site"); await clickText("button", "New Site"); - await waitForText("New WordPress site"); + await waitForText("New site"); await page.type('input[placeholder="My Blog"]', "Portfolio Redesign"); await settle(400); await shot("new-site"); @@ -142,7 +237,7 @@ async function main() { await settle(300); // 4) Settings modal — opened via the sidebar gear icon; sectioned rail. - await page.click('button[aria-label="Settings"]'); + await clickAria("Settings"); await waitForText("Docker is running"); await settle(600); await shot("settings"); @@ -152,9 +247,34 @@ async function main() { await shot("settings-domains"); await clickText("nav button", "ServerKit"); await waitForText("Production"); - await settle(400); + await settle(300); + // Expand the connection's remote WordPress sites — the table with the + // per-site Import buttons (plan 18) — then scroll the modal's content pane + // so that table is in frame (it starts below the fold). + await clickText("button", "View WP sites"); + await waitForText("legacy-shop"); + await page.evaluate(() => { + const h = [...document.querySelectorAll("h3")].find( + (x) => x.textContent.trim().toLowerCase() === "remote wordpress sites" + ); + let el = h?.parentElement; + while (el && el.scrollHeight <= el.clientHeight) el = el.parentElement; + if (el) el.scrollTop = el.scrollHeight; + }); + await settle(500); await shot("settings-serverkit"); + // 5) Import a remote site — the confirm dialog, showing the version readout + // and the closest-image fallback warning (legacy-shop: 6.2→6.7, 7.4→8.3). + // The dialog is app-level, so closing Settings leaves it over the + // dashboard for a clean shot. + await clickImportInRow("legacy-shop"); + await waitForText("rewriting URLs to the local address"); + await clickAria("Close settings"); + await settle(500); + await shot("import-site"); + await clickText("button", "Cancel"); + console.log("› done. Wrote PNGs to docs/screenshots/"); } finally { if (browser) await browser.close(); diff --git a/scripts/gen-brand-icons.mjs b/scripts/gen-brand-icons.mjs new file mode 100644 index 0000000..6abc582 --- /dev/null +++ b/scripts/gen-brand-icons.mjs @@ -0,0 +1,89 @@ +// Generates src/lib/brandIconData.ts — a curated, OFFLINE subset of Iconify +// brand logos (plan 27, pattern borrowed from Faro's plan 14). Only the +// handful of marks LocalKit actually renders are extracted from the full +// @iconify-json/simple-icons set, so the app bundles ~1 KB of icon data and +// makes ZERO network calls (nothing ever hits api.iconify.design). +// +// Run `npm run gen:brand-icons` after adding an icon to CURATED below. The +// @iconify-json/* packages + @iconify/utils are devDependencies used only +// here; the generated file is committed so normal builds don't need them. +// +// Licence: simple-icons is CC0-1.0. + +import { getIconData } from "@iconify/utils"; +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import simpleIcons from "@iconify-json/simple-icons/icons.json" with { type: "json" }; + +const SET = simpleIcons.default ?? simpleIcons; + +// Every brand mark LocalKit renders, keyed by simple-icons name. +// kindIcon() in src/lib/brandIcons.tsx maps a site kind onto one of these. +const CURATED = [ + "wordpress", // kind: wordpress + "docker", // kind: docker + "php", // kind: php + "laravel", // php-kind Laravel skeletons (New Site dialog, future) + "mariadb", // database panels (future) +]; + +// Drop transformation fields that equal the Iconify defaults, to keep the +// generated file tiny and readable. +function trim(data) { + const out = { body: data.body, width: data.width, height: data.height }; + if (data.left) out.left = data.left; + if (data.top) out.top = data.top; + if (data.rotate) out.rotate = data.rotate; + if (data.hFlip) out.hFlip = true; + if (data.vFlip) out.vFlip = true; + return out; +} + +const entries = []; +const missing = []; +for (const name of CURATED) { + const data = getIconData(SET, name); + if (!data) { + missing.push(name); + continue; + } + entries.push([name, trim(data)]); +} + +if (missing.length) { + console.error("gen-brand-icons: icons not found in @iconify-json/simple-icons:"); + for (const m of missing) console.error(" - " + m); + process.exit(1); +} + +const header = `// AUTO-GENERATED by scripts/gen-brand-icons.mjs — DO NOT EDIT BY HAND. +// A curated, offline subset of simple-icons brand logos (plan 27). Regenerate +// with \`npm run gen:brand-icons\` after editing CURATED in that script. +// Licence: simple-icons = CC0-1.0. +import type { IconifyIcon } from "@iconify/react/offline"; +`; + +const lines = entries.map( + ([name, data]) => ` ${JSON.stringify(name)}: ${JSON.stringify(data)},` +); +const body = `\nexport const BRAND_ICONS: Record = {\n${lines.join( + "\n" +)}\n};\n`; + +const outPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "src", + "lib", + "brandIconData.ts" +); +writeFileSync(outPath, header + body); + +const bytes = Buffer.byteLength(header + body); +console.log( + `gen-brand-icons: wrote ${entries.length} icons → src/lib/brandIconData.ts (${( + bytes / 1024 + ).toFixed(1)} KB)` +); diff --git a/src-tauri/src/router.rs b/src-tauri/src/router.rs index ffcfd79..ee3fbf4 100644 --- a/src-tauri/src/router.rs +++ b/src-tauri/src/router.rs @@ -1118,12 +1118,28 @@ mod tests { #[tokio::test] async fn probe_returns_empty_when_ports_are_free() { - let a = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let b = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let (pa, pb) = (a.local_addr().unwrap().port(), b.local_addr().unwrap().port()); - drop(a); - drop(b); - assert_eq!(probe_ports(pa, pb).await, Vec::new()); + // Probing genuinely-free ports must report no conflicts. `probe_port` + // shells out to identify a port's owner, and that subprocess is slow + // enough that a concurrently-running test can grab a just-released + // ephemeral port before the bind check runs — so a single shot is + // racy. Retry with fresh ports: a transient reassignment clears on the + // next attempt, while a real regression fails every one. + const ATTEMPTS: usize = 5; + for attempt in 1..=ATTEMPTS { + let a = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let b = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let (pa, pb) = (a.local_addr().unwrap().port(), b.local_addr().unwrap().port()); + drop(a); + drop(b); + let conflicts = probe_ports(pa, pb).await; + if conflicts.is_empty() { + return; + } + assert!( + attempt < ATTEMPTS, + "free ports {pa}/{pb} still reported in use after {ATTEMPTS} attempts: {conflicts:?}" + ); + } } #[tokio::test] diff --git a/src/App.tsx b/src/App.tsx index ede5ad8..18f10b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import CommandPalette from "./components/CommandPalette"; import KeyboardShortcutsDialog from "./components/KeyboardShortcutsDialog"; import NewSiteDialog from "./components/NewSiteDialog"; import ImportSiteDialog from "./components/ImportSiteDialog"; +import Home from "./pages/Home"; import Dashboard from "./pages/Dashboard"; import SiteDetail from "./pages/SiteDetail"; import TerminalPage from "./pages/Terminal"; @@ -52,9 +53,14 @@ export default function App() { return (
-
+
+ {page.name === "home" && } {page.name === "sites" && } - {page.name === "site" && } + {page.name === "site" && } {page.name === "terminal" && }
diff --git a/src/components/CloneSiteDialog.tsx b/src/components/CloneSiteDialog.tsx index dcac7e8..9939876 100644 --- a/src/components/CloneSiteDialog.tsx +++ b/src/components/CloneSiteDialog.tsx @@ -52,7 +52,7 @@ export default function CloneSiteDialog({ aria-label="Clone site" className="w-full max-w-md rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-2xl" > -

Clone “{source.name}”

+

Clone “{source.name}”

LocalKit copies this site's database and files into a brand-new site with fresh ports and credentials — a throwaway copy to test a plugin or theme change. diff --git a/src/components/ConfigEditorPanel.tsx b/src/components/ConfigEditorPanel.tsx index c136c5f..40fd095 100644 --- a/src/components/ConfigEditorPanel.tsx +++ b/src/components/ConfigEditorPanel.tsx @@ -3,6 +3,8 @@ import { ipc } from "../lib/ipc"; import { errMsg, toastError } from "../lib/errors"; import { toast } from "../stores/toast"; import { useSites } from "../stores/sites"; +import SectionTitle from "./SectionTitle"; +import { SlidersIcon } from "./icons"; /** * Tools → Config (plan 24). @@ -73,7 +75,7 @@ export default function ConfigEditorPanel({ siteId }: { siteId: string }) { return (

-

Config

+ Config
{FILES.map((f) => ( ); diff --git a/src/components/DatabasePanel.tsx b/src/components/DatabasePanel.tsx index c14335f..313697b 100644 --- a/src/components/DatabasePanel.tsx +++ b/src/components/DatabasePanel.tsx @@ -3,6 +3,8 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { ipc } from "../lib/ipc"; import { toastError } from "../lib/errors"; import { toast } from "../stores/toast"; +import SectionTitle from "./SectionTitle"; +import { DatabaseIcon } from "./icons"; /** * Tools → Database (plan 24). @@ -41,7 +43,7 @@ export default function DatabasePanel({ siteId, running }: { siteId: string; run return (
-

Database

+ Database

Browse and edit the database in Adminer, a lightweight web GUI. It starts on first use and opens with the server and username pre-filled; the password is copied to your clipboard to diff --git a/src/components/DebugPanel.tsx b/src/components/DebugPanel.tsx index fc1a196..5661638 100644 --- a/src/components/DebugPanel.tsx +++ b/src/components/DebugPanel.tsx @@ -3,6 +3,8 @@ import { ipc } from "../lib/ipc"; import { toastError } from "../lib/errors"; import { toast } from "../stores/toast"; import type { DebugStatus } from "../lib/types"; +import SectionTitle from "./SectionTitle"; +import { BugIcon } from "./icons"; /** * Tools → Debug (plan 24). @@ -70,7 +72,7 @@ export default function DebugPanel({ siteId }: { siteId: string }) { return (

-

Debug

+ Debug + - {dockerDown && ( + + {/* Live site rail */} + {sites.length > 0 && ( +
+ {!collapsed && ( +

+ Your sites +

+ )} +
+ {sites.map((site) => ( + go({ name: "site", id: site.id, tab })} + /> + ))} +
+
+ )} + {sites.length === 0 &&
} + + {dockerDown && !collapsed && ( )} +
- v0.1.0 - + {!collapsed && v0.1.0} +
+ + +
); } + +/** Sub-links of the active site — the detail page's tabs, capability-gated. */ +function subTabs(site: SiteWithStatus): { tab: SiteTab; label: string; icon: typeof GlobeIcon }[] { + const caps = site.capabilities; + const tabs: { tab: SiteTab; label: string; icon: typeof GlobeIcon }[] = [ + { tab: "overview", label: "Overview", icon: GlobeIcon }, + ]; + if (caps.db_gui || caps.search_replace || caps.wp_tools) + tabs.push({ tab: "tools", label: "Tools", icon: WrenchIcon }); + if (caps.snapshots) tabs.push({ tab: "snapshots", label: "Snapshots", icon: CameraIcon }); + tabs.push({ tab: "logs", label: "Logs", icon: FileTextIcon }); + return tabs; +} + +function RailSite({ + site, + active, + activeTab, + collapsed, + onOpen, +}: { + site: SiteWithStatus; + active: boolean; + activeTab: SiteTab | null; + collapsed: boolean; + onOpen: (tab?: SiteTab) => void; +}) { + const status = site.incomplete ? "error" : site.live_status; + if (collapsed) { + return ( + + ); + } + return ( +
+ + {active && !site.incomplete && ( +
+ {subTabs(site).map(({ tab, label, icon: Icon }) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/SiteTile.tsx b/src/components/SiteTile.tsx new file mode 100644 index 0000000..67da11a --- /dev/null +++ b/src/components/SiteTile.tsx @@ -0,0 +1,84 @@ +import { BrandIcon, kindIcon } from "../lib/brandIcons"; + +/** + * Deterministic monogram tile — a site's face (plans 27/28). + * + * The hue is hashed from the *slug* (not the display name), so it survives + * renames-in-place, and is identical in the rail, grid, list and detail + * views. The tile doubles as a status light: up sites render saturated with + * a same-hue glow, everything else keeps the hue but dimmed — identity + * persists while the site sleeps. Dimmed lightness comes from the theme + * token layer, so tiles read correctly in light mode too. + * + * Pass `kind` to stamp the real brand mark (WordPress / Docker / PHP) on + * the corner — the Faro pattern: the monogram stays the identity, the + * stamp adds recognizability. + */ + +const SIZES = { + sm: "h-7 w-7 rounded-lg text-[10px]", + md: "h-10 w-10 rounded-xl text-sm", + lg: "h-12 w-12 rounded-xl text-base", +} as const; + +/** FNV-1a, folded to a hue. Tiny, stable, decent spread on short slugs. */ +export function hueFromSlug(slug: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < slug.length; i++) { + h ^= slug.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0) % 360; +} + +export function initials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return "?"; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return (words[0][0] + words[1][0]).toUpperCase(); +} + +export default function SiteTile({ + name, + slug, + status, + kind, + size = "md", +}: { + name: string; + slug: string; + status: string; + /** Site kind — when set, the brand mark is stamped on the corner. */ + kind?: string; + size?: keyof typeof SIZES; +}) { + const hue = hueFromSlug(slug); + const up = status === "running" || status === "degraded"; + const style = up + ? { + background: `linear-gradient(135deg, hsl(${hue} 70% 58%), hsl(${hue} 72% 42%))`, + boxShadow: `0 0 18px hsl(${hue} 72% 50% / 0.28), inset 0 1px 0 hsl(0 0% 100% / 0.25)`, + color: "#fff", + } + : { + background: `hsl(${hue} 26% var(--tile-dim-bg-l))`, + boxShadow: "inset 0 1px 0 hsl(0 0% 100% / 0.05)", + color: `hsl(${hue} 42% var(--tile-dim-fg-l))`, + }; + return ( + + {initials(name)} + {kind && size !== "sm" && ( + + + + )} + + ); +} diff --git a/src/components/SnapshotsPanel.tsx b/src/components/SnapshotsPanel.tsx index ab13001..b332f5b 100644 --- a/src/components/SnapshotsPanel.tsx +++ b/src/components/SnapshotsPanel.tsx @@ -4,6 +4,8 @@ import { toastError } from "../lib/errors"; import { toast } from "../stores/toast"; import { isTerminalStage, useSites } from "../stores/sites"; import type { Snapshot, SnapshotKind } from "../lib/types"; +import SectionTitle from "./SectionTitle"; +import { CameraIcon } from "./icons"; /** * Snapshots panel on the site detail page (plan 17). @@ -112,7 +114,7 @@ export default function SnapshotsPanel({ siteId }: { siteId: string }) { return (
-

Snapshots

+ Snapshots
; }; } -export function GearIcon(props: IconProps) { - return ( - - - - - ); -} - -export function GridIcon(props: IconProps) { - return ( - - - - - - - ); -} - -export function ListIcon(props: IconProps) { - return ( - - - - - - - - - ); -} - -export function PlusIcon(props: IconProps) { - return ( - - - - - ); -} - -export function CloseIcon(props: IconProps) { - return ( - - - - - ); -} - -export function SlidersIcon(props: IconProps) { - return ( - - - - - - - - - - - - ); -} - -export function TerminalIcon(props: IconProps) { - return ( - - - - - - ); -} - -export function GlobeIcon(props: IconProps) { return ( - - - - - - ); -} - -export function ServerIcon(props: IconProps) { - return ( - - - - - - - ); -} - +export const GearIcon = wrap(Settings); +export const GridIcon = wrap(LayoutGrid); +export const ListIcon = wrap(List); +export const PlusIcon = wrap(Plus); +export const CloseIcon = wrap(X); +export const SlidersIcon = wrap(SlidersHorizontal); +export const TerminalIcon = wrap(SquareTerminal); +export const GlobeIcon = wrap(Globe); +export const ServerIcon = wrap(Server); /** Chain link — marks a site imported from a ServerKit server (plan 18). */ -export function LinkIcon(props: IconProps) { - return ( - - - - - ); -} +export const LinkIcon = wrap(Link2); +export const KeyboardIcon = wrap(Keyboard); -export function KeyboardIcon(props: IconProps) { - return ( - - - - - - - - - - - ); -} +// Actions (plan 27) +export const PlayIcon = wrap(Play); +export const StopSquareIcon = wrap(Square); +export const ArrowUpRightIcon = wrap(ArrowUpRight); +export const WrenchIcon = wrap(Wrench); +export const DuplicateIcon = wrap(Copy); +export const TrashIcon = wrap(Trash2); +export const BookmarkIcon = wrap(Bookmark); +export const CheckIcon = wrap(Check); + +// Navigation + section headers (plans 27/28) +export const HomeIcon = wrap(House); +export const ChevronsLeftIcon = wrap(ChevronsLeft); +export const ChevronsRightIcon = wrap(ChevronsRight); +export const LayersIcon = wrap(Layers); +export const CameraIcon = wrap(Camera); +export const DatabaseIcon = wrap(Database); +export const KeyIcon = wrap(KeyRound); +export const FileTextIcon = wrap(FileText); +export const SyncIcon = wrap(ArrowUpDown); +export const SearchIcon = wrap(Search); +export const BugIcon = wrap(Bug); diff --git a/src/index.css b/src/index.css index b2a16e6..85fcfe9 100644 --- a/src/index.css +++ b/src/index.css @@ -10,6 +10,57 @@ @tailwind components; @tailwind utilities; +/* + * Theme token layer (plan 28). The Tailwind zinc ramp resolves to these RGB + * triplets (see tailwind.config.js), so every existing `zinc-*` class is + * theme-aware for free. `data-theme` is stamped on by main.tsx before + * first render and kept in sync by App. + * + * The ramp keeps its MEANING across themes (950 = page, 900 = surface, + * 800/700 = borders, 600/500 = dim/muted text, 50 = strongest text) — light + * mode inverts the values, not the semantics. Solid accent buttons keep + * literal `text-white` in both themes; titles use `text-zinc-50`. + */ +:root { + --c-zinc-50: 247 247 251; + --c-zinc-100: 241 242 247; + --c-zinc-200: 228 231 238; + --c-zinc-300: 211 215 226; + --c-zinc-400: 184 191 208; + --c-zinc-500: 144 151 171; + --c-zinc-600: 110 116 136; + --c-zinc-700: 58 64 86; + --c-zinc-800: 42 47 64; + --c-zinc-900: 21 24 34; + --c-zinc-950: 13 15 22; + /* Dimmed (stopped) site-tile lightness — SiteTile builds hsl() from these. */ + --tile-dim-bg-l: 17%; + --tile-dim-fg-l: 70%; + /* Blueprint grid line (ServerKit's squares — accent-tinted, barely there). */ + --grid-line: rgba(122, 107, 234, 0.05); + --scrollbar-thumb: #2a2f40; + --scrollbar-thumb-hover: #3a4056; +} + +:root[data-theme="light"] { + --c-zinc-50: 18 20 30; + --c-zinc-100: 26 29 42; + --c-zinc-200: 42 47 64; + --c-zinc-300: 58 64 86; + --c-zinc-400: 76 82 104; + --c-zinc-500: 102 109 131; + --c-zinc-600: 138 145 166; + --c-zinc-700: 201 206 221; + --c-zinc-800: 226 229 238; + --c-zinc-900: 255 255 255; + --c-zinc-950: 244 245 249; + --tile-dim-bg-l: 88%; + --tile-dim-fg-l: 34%; + --grid-line: rgba(108, 92, 231, 0.07); + --scrollbar-thumb: #c9cedd; + --scrollbar-thumb-hover: #aeb5c9; +} + @layer base { html, body, @@ -25,16 +76,25 @@ } } +@layer utilities { + /* ServerKit's blueprint squares — 1px lines every 40px on the content pane. */ + .bg-blueprint { + background-image: linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 40px 40px; + } +} + ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { - background: #2a2f40; + background: var(--scrollbar-thumb); border-radius: 8px; } ::-webkit-scrollbar-thumb:hover { - background: #3a4056; + background: var(--scrollbar-thumb-hover); } ::-webkit-scrollbar-track { background: transparent; diff --git a/src/lib/brandIconData.ts b/src/lib/brandIconData.ts new file mode 100644 index 0000000..c5ced43 --- /dev/null +++ b/src/lib/brandIconData.ts @@ -0,0 +1,13 @@ +// AUTO-GENERATED by scripts/gen-brand-icons.mjs — DO NOT EDIT BY HAND. +// A curated, offline subset of simple-icons brand logos (plan 27). Regenerate +// with `npm run gen:brand-icons` after editing CURATED in that script. +// Licence: simple-icons = CC0-1.0. +import type { IconifyIcon } from "@iconify/react/offline"; + +export const BRAND_ICONS: Record = { + "wordpress": {"body":"","width":24,"height":24}, + "docker": {"body":"","width":24,"height":24}, + "php": {"body":"","width":24,"height":24}, + "laravel": {"body":"","width":24,"height":24}, + "mariadb": {"body":"","width":24,"height":24}, +}; diff --git a/src/lib/brandIcons.tsx b/src/lib/brandIcons.tsx new file mode 100644 index 0000000..f3afc56 --- /dev/null +++ b/src/lib/brandIcons.tsx @@ -0,0 +1,59 @@ +// Real brand marks (WordPress, Docker, PHP…) via Iconify, bundled OFFLINE +// (plan 27 — pattern borrowed from Faro's plan 14). +// +// This is additive: lucide (components/icons.tsx) still owns UI chrome. +// Iconify fills the one gap it can't — recognizable brand logos on site +// kinds. +// +// Offline by construction: the `Icon` component comes from +// `@iconify/react/offline` (no API/fetch code — nothing can reach +// api.iconify.design) and is handed raw icon DATA from the committed, +// curated `brandIconData.ts`. A string name is never looked up over the +// network. + +import { Icon } from "@iconify/react/offline"; +import { KIND_DOCKER, KIND_PHP } from "./types"; +import { BRAND_ICONS } from "./brandIconData"; + +// Site kind → simple-icons name. WordPress is the default kind, so it is +// also the fallback for any kind this frontend doesn't know yet. +const KIND_ICON: Record = { + [KIND_DOCKER]: "docker", + [KIND_PHP]: "php", +}; + +/** The simple-icons name for a site kind's brand mark. */ +export function kindIcon(kind: string): string { + return KIND_ICON[kind] ?? "wordpress"; +} + +/** A thin, offline wrapper over Iconify's ``. Renders curated icon DATA + * (never a network name); returns `null` for an unknown key so callers can + * fall back to a lucide glyph. simple-icons marks are monochrome and follow + * `currentColor`, so they tint like any other icon. */ +export function BrandIcon({ + icon, + size = 16, + className, + title, +}: { + icon: string; + size?: number; + className?: string; + /** When set, the icon is meaningful (adds a tooltip + drops aria-hidden). */ + title?: string; +}) { + const data = BRAND_ICONS[icon]; + if (!data) return null; + return ( + + ); +} diff --git a/src/lib/commands.tsx b/src/lib/commands.tsx index c4341f7..3850e37 100644 --- a/src/lib/commands.tsx +++ b/src/lib/commands.tsx @@ -31,6 +31,13 @@ export interface Command { function staticCommands(): Command[] { const nav = useNav.getState(); return [ + { + id: "go-home", + title: "Go to Home", + group: "Navigation", + defaultCombo: "mod+0", + run: () => nav.navigate({ name: "home" }), + }, { id: "go-sites", title: "Go to Sites", diff --git a/src/main.tsx b/src/main.tsx index 9b67590..6bfbe25 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import { applyTheme, getTheme } from "./stores/settings"; import "./index.css"; +// Stamp the theme before first render — the settings store seeds +// synchronously (injection / localStorage mirror), so there's no flash. +applyTheme(getTheme()); + ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index dbe6ddc..556537c 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -7,11 +7,23 @@ import { useRouter } from "../stores/router"; import { useServerKit } from "../stores/serverkit"; import { useSites } from "../stores/sites"; import { useBlueprints } from "../stores/blueprints"; -import { KIND_DOCKER, type SiteWithStatus } from "../lib/types"; +import { KIND_DOCKER, KIND_PHP, type SiteWithStatus } from "../lib/types"; import StatusBadge from "../components/StatusBadge"; -import KindBadge from "../components/KindBadge"; +import SiteTile from "../components/SiteTile"; +import { BrandIcon, kindIcon } from "../lib/brandIcons"; import CloneSiteDialog from "../components/CloneSiteDialog"; -import { GridIcon, LinkIcon, ListIcon, PlusIcon } from "../components/icons"; +import { + ArrowUpRightIcon, + DuplicateIcon, + GridIcon, + LinkIcon, + ListIcon, + PlayIcon, + PlusIcon, + StopSquareIcon, + TrashIcon, + WrenchIcon, +} from "../components/icons"; export default function Dashboard() { const sites = useSites((s) => s.sites); @@ -69,11 +81,19 @@ export default function Dashboard() { {sites.length === 0 && !loading ? (
-

No sites yet.

+ {/* Three site tiles waiting to exist — the violet one is yours. */} +
+
+
+
+ +
+
+

Create your first site

{blueprints.length > 0 - ? `Create your first site in one click — blank, or from one of your ${blueprints.length} blueprints.` - : "Create your first local WordPress site in one click."} + ? `Blank WordPress, PHP or Docker — or one of your ${blueprints.length} blueprints.` + : "WordPress, PHP or a Docker app — running in about a minute."}

- + + ); +} + +/** + * The shared action row for a healthy site — identical buttons in grid cards + * and list rows, so the two views never drift. + */ +function SiteActions({ + site, + a, + onClone, +}: { + site: SiteWithStatus; + a: ReturnType; + onClone: (site: SiteWithStatus) => void; +}) { + const ToggleIcon = a.up ? StopSquareIcon : PlayIcon; + return ( + <> + {a.up && ( + + )} + + + {site.capabilities.wp_tools && ( + + )} + ); @@ -192,8 +288,6 @@ function ImportedBadge({ site }: { site: SiteWithStatus }) { ); } -const dangerBtn = - "rounded-md border border-red-900 px-2.5 py-1 text-xs font-medium text-red-400 hover:border-red-700 disabled:opacity-50"; function GridView({ sites, @@ -220,58 +314,32 @@ function GridCard({ }) { const a = useSiteActions(site); return ( -
+
-
- - +
+ +
+ +

{a.url}

+
{a.incomplete ? : }
-

{a.url}

-
+
{stackLabel(site)}
-
+
{a.incomplete ? ( ) : ( - <> - {a.up && ( - - )} - - - {site.capabilities.wp_tools && ( - - )} - - + )}
@@ -318,14 +386,18 @@ function ListRow({ return ( -
+
+ - + {/* Real brand glyph instead of a text pill (plan 28). */} + + +
@@ -339,35 +411,7 @@ function ListRow({ {a.incomplete ? ( ) : ( - <> - {a.up && ( - - )} - - - {site.capabilities.wp_tools && ( - - )} - - + )}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..f86452e --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,255 @@ +import { useEffect } from "react"; +import { useNav } from "../stores/nav"; +import { useDocker } from "../stores/docker"; +import { useRouter } from "../stores/router"; +import { useServerKit } from "../stores/serverkit"; +import { useBlueprints } from "../stores/blueprints"; +import { useSites } from "../stores/sites"; +import type { SiteWithStatus } from "../lib/types"; +import SiteTile from "../components/SiteTile"; +import { + ArrowUpRightIcon, + BookmarkIcon, + GlobeIcon, + LayersIcon, + PlusIcon, + ServerIcon, + TerminalIcon, +} from "../components/icons"; + +/** + * Home (plan 28) — the screen you walk up to the machine to look at. Status + * headline, the wall of site tiles (glow = running), what needs attention, + * and the environment at a glance. Everything reads from existing stores; + * management stays on the Sites page. + */ +export default function Home() { + const sites = useSites((s) => s.sites); + const docker = useDocker((s) => s.status); + const router = useRouter((s) => s.status); + const connections = useServerKit((s) => s.connections); + const refreshConnections = useServerKit((s) => s.refresh); + const blueprints = useBlueprints((s) => s.blueprints); + const refreshBlueprints = useBlueprints((s) => s.refresh); + const navigate = useNav((s) => s.navigate); + const setNewSiteOpen = useNav((s) => s.setNewSiteOpen); + const openSettings = useNav((s) => s.openSettings); + + useEffect(() => { + void refreshConnections(); + void refreshBlueprints(); + }, [refreshConnections, refreshBlueprints]); + + const up = sites.filter( + (s) => s.live_status === "running" || s.live_status === "degraded", + ).length; + const attention = sites.filter( + (s) => s.incomplete || s.live_status === "degraded" || s.live_status === "error", + ); + + const headline = + sites.length === 0 + ? "Let's create your first site." + : up === 0 + ? "All quiet — every site is stopped." + : up === sites.length + ? sites.length === 1 + ? "Your site is running." + : `All ${sites.length} sites are running.` + : `${up} of ${sites.length} sites running.`; + + return ( +
+ {/* Hero */} +
+
+

+ Local environment +

+

{headline}

+

+ {docker == null + ? "Checking Docker…" + : docker.available + ? "Docker is healthy" + : "Docker is unavailable"} + {" · "} + {router?.enabled + ? router.conflicts.length > 0 + ? "local domains blocked" + : "local domains on" + : "local domains off"} + {connections.length > 0 && + ` · ${connections.length} ServerKit ${connections.length === 1 ? "connection" : "connections"}`} +

+
+ +
+ + {/* Tile wall */} + {sites.length > 0 && ( +
+ {sites.map((site) => ( + navigate({ name: "site", id: site.id })} /> + ))} +
+ )} + +
+ {/* Needs attention — the whole reason to glance at this screen. */} + {attention.length > 0 && ( +
+

+ + Needs attention +

+
+ {attention.map((site) => ( + + ))} +
+
+ )} + + {/* Environment */} +
+

+ Environment +

+
+ + 0 + ? "ports blocked by another app" + : "sites served at .test" + : "off — sites use localhost ports" + } + /> + 0 ? true : null} + label="ServerKit" + value={ + connections.length > 0 + ? connections.map((c) => c.label).join(", ") + : "no connections yet" + } + /> + 0 ? true : null} + label="Blueprints" + value={ + blueprints.length > 0 + ? `${blueprints.length} ready to reuse` + : "save a site as a blueprint to reuse its stack" + } + /> +
+
+ + {/* Quick actions */} +
+

+ Quick actions +

+
+ navigate({ name: "sites" })} /> + navigate({ name: "terminal" })} /> + openSettings("serverkit")} /> + openSettings("domains")} /> + setNewSiteOpen(true)} /> + setNewSiteOpen(true)} /> +
+
+
+
+ ); +} + +function WallTile({ site, onOpen }: { site: SiteWithStatus; onOpen: () => void }) { + return ( + + ); +} + +function EnvRow({ ok, label, value }: { ok: boolean | null; label: string; value: string }) { + return ( +
+ +
{label}
+
+ {value} +
+
+ ); +} + +function QuickAction({ + icon: Icon, + label, + onClick, +}: { + icon: typeof LayersIcon; + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 621eb34..2a17c49 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,7 +5,13 @@ import { checkForUpdate, getLastUpdateResult } from "../lib/update"; import type { AppInfo, DockerStatus, UpdateInfo } from "../lib/types"; import { useDocker } from "../stores/docker"; import { useNav } from "../stores/nav"; -import { useOsNotifications, useTerminalFontSize, useTerminalScrollback } from "../stores/settings"; +import { + useOsNotifications, + useTerminalFontSize, + useTerminalScrollback, + useTheme, + type Theme, +} from "../stores/settings"; import { useDialog } from "../hooks/useDialog"; import ServerKitSettings from "../components/ServerKitSettings"; import DomainsSettings from "../components/DomainsSettings"; @@ -45,7 +51,7 @@ export default function Settings() { > {/* Left section rail — Faro-style */}
+ ); +} diff --git a/src/pages/SiteDetail.tsx b/src/pages/SiteDetail.tsx index 36dd84f..60c6e51 100644 --- a/src/pages/SiteDetail.tsx +++ b/src/pages/SiteDetail.tsx @@ -2,13 +2,28 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { ipc } from "../lib/ipc"; import { siteUrl, sitePort } from "../lib/domains"; -import { useNav } from "../stores/nav"; +import { useNav, type SiteTab } from "../stores/nav"; import { useRouter } from "../stores/router"; import { useSites } from "../stores/sites"; import type { SiteDetail as SiteDetailData, WpUser } from "../lib/types"; import StatusBadge from "../components/StatusBadge"; import KindBadge from "../components/KindBadge"; +import SiteTile from "../components/SiteTile"; +import SectionTitle from "../components/SectionTitle"; import CopyButton from "../components/CopyButton"; +import { + ArrowUpRightIcon, + BookmarkIcon, + DatabaseIcon, + DuplicateIcon, + FileTextIcon, + GlobeIcon, + KeyIcon, + PlayIcon, + StopSquareIcon, + TerminalIcon, + TrashIcon, +} from "../components/icons"; import PushPanel from "../components/PushPanel"; import SnapshotsPanel from "../components/SnapshotsPanel"; import DeleteSiteDialog from "../components/DeleteSiteDialog"; @@ -17,7 +32,10 @@ import SaveBlueprintDialog from "../components/SaveBlueprintDialog"; import SiteTools from "../components/SiteTools"; import { describeConflicts } from "../components/DomainsSettings"; -export default function SiteDetail({ id }: { id: string }) { +const headerGhostBtn = + "inline-flex items-center gap-1.5 rounded-md border border-zinc-700 px-4 py-2 text-sm text-zinc-200 transition-colors hover:border-zinc-500 hover:bg-zinc-800/60 disabled:opacity-50"; + +export default function SiteDetail({ id, tab: navTab }: { id: string; tab?: SiteTab }) { const navigate = useNav((s) => s.navigate); const start = useSites((s) => s.start); const stop = useSites((s) => s.stop); @@ -39,7 +57,9 @@ export default function SiteDetail({ id }: { id: string }) { const [confirmDelete, setConfirmDelete] = useState(false); const [cloneOpen, setCloneOpen] = useState(false); const [blueprintOpen, setBlueprintOpen] = useState(false); - const [tab, setTab] = useState<"overview" | "tools">("overview"); + // The tab lives in the nav store (plan 28) so the rail can deep-link to it. + const tab: SiteTab = navTab ?? "overview"; + const setTab = (t: SiteTab) => navigate({ name: "site", id, tab: t }); const logRef = useRef(null); const loadDetail = useCallback(() => { @@ -133,7 +153,14 @@ export default function SiteDetail({ id }: { id: string }) {
-

{detail.name}

+ +

{detail.name}

@@ -142,23 +169,26 @@ export default function SiteDetail({ id }: { id: string }) { ) : ( )} {caps.wp_tools && ( @@ -166,8 +196,9 @@ export default function SiteDetail({ id }: { id: string }) { onClick={() => setCloneOpen(true)} disabled={busyId === id} title="Copy this site's database and files into a new site" - className="rounded-md border border-zinc-700 px-4 py-2 text-sm text-zinc-200 hover:border-zinc-500 disabled:opacity-50" + className={headerGhostBtn} > + Clone )} @@ -176,16 +207,18 @@ export default function SiteDetail({ id }: { id: string }) { onClick={() => setBlueprintOpen(true)} disabled={busyId === id} title="Save this site's stack as a reusable blueprint" - className="rounded-md border border-zinc-700 px-4 py-2 text-sm text-zinc-200 hover:border-zinc-500 disabled:opacity-50" + className={headerGhostBtn} > + Save as blueprint )}
@@ -219,33 +252,45 @@ export default function SiteDetail({ id }: { id: string }) { - {hasTools && ( -
- {(["overview", "tools"] as const).map((t) => ( - - ))} -
- )} +
+ {( + [ + "overview", + ...(hasTools ? (["tools"] as const) : []), + ...(caps.snapshots ? (["snapshots"] as const) : []), + "logs", + ] as SiteTab[] + ).map((t) => ( + + ))} +
{hasTools && tab === "tools" && ( - setTab("overview")} /> + setTab("snapshots")} /> )} -
+ {/* Snapshots + one-click restore (plan 17) — its own tab since plan 28 */} + {caps.snapshots && tab === "snapshots" && ( +
+ +
+ )} + +
{/* URL */}
-

Site

+ Site

{url}

{caps.wp_tools @@ -256,8 +301,9 @@ export default function SiteDetail({ id }: { id: string }) { {caps.one_click_login && ( @@ -291,7 +337,7 @@ export default function SiteDetail({ id }: { id: string }) { {/* Credentials (WordPress only) */} {caps.one_click_login && (

-

WP Admin credentials

+ WP Admin credentials
Username
@@ -312,7 +358,7 @@ export default function SiteDetail({ id }: { id: string }) { {/* Database (needs the db_gui capability — hidden for docker apps) */} {caps.db_gui && (
-

Database (MariaDB)

+ Database (MariaDB)
{[ ["Host", detail.db_host], @@ -336,7 +382,7 @@ export default function SiteDetail({ id }: { id: string }) { {caps.wp_tools && (
-

WordPress info (wp-cli)

+ WordPress info (wp-cli)
- {/* Snapshots + one-click restore (plan 17) — every kind that supports them */} - {caps.snapshots && ( -
- -
- )} - {/* ServerKit push/pull (M4) — WordPress only until plan 26 */} {caps.wp_tools && (
)} +
- {/* Logs */} -
-
-

Container logs

-
- - + {/* Logs — its own tab since plan 28; stays mounted so the auto-scroll + and polling survive tab switches. */} +
+
+
+ Container logs +
+ + +
-
-
-          {logs || "No logs yet."}
-        
-
+
+            {logs || "No logs yet."}
+          
+
); diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx index e4c31b8..9a45fbd 100644 --- a/src/pages/Terminal.tsx +++ b/src/pages/Terminal.tsx @@ -48,7 +48,7 @@ export default function TerminalPage({ siteId }: { siteId?: string }) { onClick={() => setActiveId(site.id)} className={`flex shrink-0 items-center gap-2 rounded-t-lg border border-b-0 px-3.5 py-2 text-xs font-medium transition-colors ${ active - ? "border-zinc-800 bg-zinc-950 text-white" + ? "border-zinc-800 bg-zinc-950 text-zinc-50" : "border-transparent text-zinc-500 hover:bg-zinc-900 hover:text-zinc-300" }`} > diff --git a/src/stores/nav.ts b/src/stores/nav.ts index 27d462d..5ddcf33 100644 --- a/src/stores/nav.ts +++ b/src/stores/nav.ts @@ -1,6 +1,13 @@ import { create } from "zustand"; -export type Page = { name: "sites" } | { name: "site"; id: string } | { name: "terminal"; siteId?: string }; +/** Sections of the site detail page (plan 28) — deep-linkable from the rail. */ +export type SiteTab = "overview" | "tools" | "snapshots" | "logs"; + +export type Page = + | { name: "home" } + | { name: "sites" } + | { name: "site"; id: string; tab?: SiteTab } + | { name: "terminal"; siteId?: string }; /** Sections of the settings modal (mirrors `SectionId` in pages/Settings.tsx). */ export type SettingsSection = "general" | "terminal" | "keyboard" | "domains" | "serverkit"; @@ -26,7 +33,8 @@ interface NavState { } export const useNav = create((set) => ({ - page: { name: "sites" }, + // Home is the landing page (plan 28) — status first, management second. + page: { name: "home" }, navigate: (page) => set({ page }), settingsOpen: false, setSettingsOpen: (open) => set({ settingsOpen: open }), diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 1c1a386..ca009ab 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -112,6 +112,41 @@ export function useSiteView(): [SiteView, (view: SiteView) => void] { return [view, (v) => set("siteView", v)]; } +// --------------------------------------------------------------------------- +// Theme (plan 28) — token layer in index.css; dark is the default +// --------------------------------------------------------------------------- + +export type Theme = "dark" | "light"; + +/** Non-hook read (main.tsx stamps the theme before first render). */ +export function getTheme(): Theme { + return useSettings.getState().values.theme === "light" ? "light" : "dark"; +} + +/** Stamp `data-theme` on so the CSS-var ramp switches. */ +export function applyTheme(theme: Theme): void { + document.documentElement.dataset.theme = theme; +} + +export function useTheme(): [Theme, (t: Theme) => void] { + const theme = useSettings((s): Theme => (s.values.theme === "light" ? "light" : "dark")); + const set = useSettings((s) => s.set); + return [ + theme, + (t) => { + set("theme", t); + applyTheme(t); + }, + ]; +} + +/** Sidebar rail collapse (plan 28) — expanded by default. */ +export function useRailCollapsed(): [boolean, (v: boolean) => void] { + const collapsed = useSettings((s) => s.values.railCollapsed === "true"); + const set = useSettings((s) => s.set); + return [collapsed, (v) => set("railCollapsed", String(v))]; +} + /** Terminal font size in px (11–16, default 13). */ export const TERMINAL_FONT_SIZE_DEFAULT = 13; /** Terminal scrollback lines (default 5k). */ diff --git a/tailwind.config.js b/tailwind.config.js index 8418b3b..ff9572f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,20 +4,23 @@ export default { theme: { extend: { colors: { - // LocalKit design kit — the zinc scale is remapped to the navy-tinted - // palette so existing classes pick up the brand surface colors. + // LocalKit design kit — the zinc scale resolves to the CSS-var token + // layer in index.css (plan 28), so every zinc-* class is theme-aware: + // dark keeps the navy-tinted palette, light inverts the ramp. The + // semantic roles are stable: 950 page bg, 900 surface, 800/700 + // borders, 600/500 dim/muted text, 50 strongest text. zinc: { - 50: "#F7F7FB", - 100: "#F1F2F7", - 200: "#E4E7EE", - 300: "#D3D7E2", - 400: "#B8BFD0", - 500: "#9097AB", // muted text - 600: "#6E7488", // dim text - 700: "#3A4056", // strong border - 800: "#2A2F40", // muted border - 900: "#151822", // surface - 950: "#0D0F16", // deep navy background + 50: "rgb(var(--c-zinc-50) / )", + 100: "rgb(var(--c-zinc-100) / )", + 200: "rgb(var(--c-zinc-200) / )", + 300: "rgb(var(--c-zinc-300) / )", + 400: "rgb(var(--c-zinc-400) / )", + 500: "rgb(var(--c-zinc-500) / )", + 600: "rgb(var(--c-zinc-600) / )", + 700: "rgb(var(--c-zinc-700) / )", + 800: "rgb(var(--c-zinc-800) / )", + 900: "rgb(var(--c-zinc-900) / )", + 950: "rgb(var(--c-zinc-950) / )", }, // Brand violet per design kit (#6C5CE7) + soft lavender accent. violet: {