Compare commits
340 Commits
frontend-d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d67d8e2052 | |||
| 9b19befa0a | |||
| c499044783 | |||
| d2d487eb97 | |||
| ba00e143cc | |||
| 44385514cd | |||
| 6cf4dc494f | |||
| 9504d5f4a9 | |||
| 962114d1d3 | |||
| 041aef63e2 | |||
| 3f40e5f86e | |||
| 812ee4bfec | |||
| 86da778721 | |||
| a0e2364481 | |||
| dfb79f3acd | |||
| e58972d594 | |||
| b3ec779058 | |||
| ec68e78725 | |||
| 7f170c8079 | |||
| 26b4c53633 | |||
| e0354d969e | |||
| f8cc4b32b0 | |||
| d3b3bc11d9 | |||
| 6578a45332 | |||
| 6e0620ea7d | |||
| 54cb481f53 | |||
| 2cdbadec8c | |||
| 1668cf1fb4 | |||
| 0894f1093d | |||
| 0f2476b863 | |||
| 8564442e67 | |||
| 2b4f0d9a5f | |||
| 606af52b8e | |||
| 103ad8c81f | |||
| c5894775f8 | |||
| c004be3b12 | |||
| 7a8d13d7e2 | |||
| 9bca5d9850 | |||
| a55a226ed0 | |||
| 968d3477c2 | |||
| 6057fb91c1 | |||
| a695f74132 | |||
| ecd51a3987 | |||
| 93e08e5c8e | |||
| ccba3d42d2 | |||
| dc9347c01c | |||
| e73efc24ba | |||
| 44bed63c28 | |||
| c649cb06c9 | |||
| 86ebf542b9 | |||
| e06ad16aab | |||
| 3a884fe28d | |||
| db7814782c | |||
| c56a39d763 | |||
| 9766990e70 | |||
| 824fe0c6f1 | |||
| d738842069 | |||
| adc60690c8 | |||
| 01d9c07e24 | |||
| 1895c8f947 | |||
| 3774a1771b | |||
| 5abfe19bed | |||
| d41a2619b1 | |||
| 448968119e | |||
| 759ad99cd8 | |||
| 249d88f405 | |||
| bb5d879a0d | |||
| 52f8c26ace | |||
| 77b24f1a6b | |||
| 4c0754de33 | |||
| 7e67e480bb | |||
| 95f54260c1 | |||
| 869098af1e | |||
| 691fc73ed1 | |||
| ebc84dbdc5 | |||
| f19155ed44 | |||
| 8f5853e684 | |||
| 8a4aad3707 | |||
| 0bc9e288f1 | |||
| 9092b9e549 | |||
| eb198e2d4d | |||
| 0f212c750b | |||
| 514f11e80d | |||
| 0f90ef6f5c | |||
| 87acebfab3 | |||
| aa780492fd | |||
| 95253c7707 | |||
| de950b1fb0 | |||
| 76c0241bd1 | |||
| c3fdc170d1 | |||
| 68c8d398e3 | |||
| 191fb10b4b | |||
| 28441e75f2 | |||
| 9b56cf362b | |||
| c565a9f6ed | |||
| 4375fbcb22 | |||
| b2bb1bc1e9 | |||
| 07fb3377ad | |||
| c5454347f9 | |||
| 27a1f308d0 | |||
| 5a7e1abcc7 | |||
| 960232e525 | |||
| 6687b79bfd | |||
| 2b569bd55f | |||
| 9abc957260 | |||
| f6f7867d77 | |||
| 5b26dbf80d | |||
| 81994d4b6c | |||
| 1532cb6111 | |||
| 69e8086018 | |||
| 2a2962a098 | |||
| cd26dd807f | |||
| 5ed10c4077 | |||
| c1a43d18a6 | |||
| 2e8d9ddecc | |||
| a52557f35b | |||
| f510848173 | |||
| 308d7cd5e1 | |||
| c337655dde | |||
| 730154c2f9 | |||
| 3833ba5dd2 | |||
| 2e66ea087b | |||
| 576446e36a | |||
| dd85b0b8f8 | |||
| 41a1f66b2d | |||
| 32aaf9b6ab | |||
| 7305c2a57c | |||
| 9b13320253 | |||
| 55646f668e | |||
| 0fdb47c310 | |||
| 548420c4f5 | |||
| 2ee68c7ac2 | |||
| 2a7e87c790 | |||
| e240481ba6 | |||
| 180c55ee89 | |||
| 08fbe8405b | |||
| 7337e2c5b0 | |||
| 2036df6c2b | |||
| 2bff945dd8 | |||
| 180f0bdc0a | |||
| 371ae37cc2 | |||
| bcf47b5c6c | |||
| ca0ae32a21 | |||
| d165ab9de1 | |||
| 7a69f47608 | |||
| cca2211b33 | |||
| 82a41f7e95 | |||
| 39cb9194a5 | |||
| 46291becd3 | |||
| e9d597de03 | |||
| a87faa2353 | |||
| 6fe774719a | |||
| a4fe96496a | |||
| db18168537 | |||
| caab38f950 | |||
| 4f76b0f3b7 | |||
| 3c0a6860fc | |||
| 6a2027b0d4 | |||
| ef539d34a7 | |||
| e6721a0b72 | |||
| 8eaa20106a | |||
| 4550df6c1a | |||
| a01f3331df | |||
| 019211ecce | |||
| d433da56f9 | |||
| 662caf230a | |||
| ca47d0643d | |||
| d4c10db58e | |||
| 7ddcbfe32d | |||
| 472e30e9bc | |||
| a2298be5ca | |||
| 137d5975e1 | |||
| 7a9c2575f0 | |||
| dd510fe81f | |||
| ef4a06a57c | |||
| a036f2f28f | |||
| 8c73f72680 | |||
| f1d0c9e0b5 | |||
| ad7d0face8 | |||
| b43dddac4e | |||
| 1284078799 | |||
| de1209afe4 | |||
| 6ec312f716 | |||
| b5b18ccd32 | |||
| 98cb278cbc | |||
| 3679ccf913 | |||
| 7b6cac5462 | |||
| c442921ccb | |||
| 48c959402c | |||
| 492404cd14 | |||
| f670fee691 | |||
| f9ae97d31c | |||
| 2312784b96 | |||
| 1ee3a2e28e | |||
| d5467bf236 | |||
| f1b2c7147a | |||
| 00539b90e4 | |||
| f8c73f27de | |||
| 00f269822b | |||
| bc6ccce9e0 | |||
| 697eaad2d7 | |||
| 03bcbfd1c5 | |||
| fa9a8840f7 | |||
| c35b63d797 | |||
| 0d5ec04f5c | |||
| 63b16e76bb | |||
| 9323a92eff | |||
| ad4872d31c | |||
| f72d27790a | |||
| cca53538ed | |||
| a1fd81c8bb | |||
| 1756778a54 | |||
| 00243cbc73 | |||
| b8da6937f7 | |||
| 2b3aed7f27 | |||
| 2bfcf605d6 | |||
| d4dfb163f3 | |||
| db26a51ea3 | |||
| 42fa2594e3 | |||
| 9640e138f3 | |||
| 83612f29c4 | |||
| ebec667d62 | |||
| 7d4d034e2a | |||
| 188be0ee6b | |||
| abfe98bdce | |||
| 32124555ab | |||
| fa2e08ea42 | |||
| f142128f54 | |||
| b98fb2eba8 | |||
| dcbab51f9e | |||
| cc429830ba | |||
| 71deea7980 | |||
| a0ae6ed82e | |||
| 1accdc6912 | |||
| a5d8b9471c | |||
| a6d0688c94 | |||
| 0d8e982f81 | |||
| d7187abedc | |||
| 0c56ed4cc5 | |||
| dd44963225 | |||
| 0e674e6715 | |||
| fb47b859ab | |||
| c11fa15828 | |||
| bb5645c5d0 | |||
| eaea0dd027 | |||
| 2714f8cdf3 | |||
| b33a85c8dc | |||
| 0480b306f1 | |||
| 47417d13a0 | |||
| 1ed76c99b8 | |||
| 135fc2d1e6 | |||
| d1cb116e30 | |||
| 4d40496739 | |||
| febcbf6d2e | |||
| 0439a8dafd | |||
| 6813869d10 | |||
| 486d4449b2 | |||
| 9def97c3a5 | |||
| 48ab562577 | |||
| 9523a8f482 | |||
| f40dbdfb99 | |||
| 6a6fbd87a7 | |||
| a7d4ccfc88 | |||
| a1650abe06 | |||
| 9d3aef33e8 | |||
| 1219c9d00f | |||
| 6f16401849 | |||
| 59e5b4d4a9 | |||
| 3c855461b6 | |||
| f1856b4b71 | |||
| a42eaa4e40 | |||
| 3c0d3d5175 | |||
| f0ea84fbd4 | |||
| 4f9c82b525 | |||
| 71bea9103f | |||
| 41143fc1fd | |||
| 35fc8a8b38 | |||
| 76c161eedf | |||
| fa0e460c1c | |||
| fa8a919214 | |||
| d7206ea2ee | |||
| ba2eaa55f2 | |||
| ae54954545 | |||
| d16ef9c138 | |||
| 1cbe5f097a | |||
| 1ccd4b7066 | |||
| cdf48456a5 | |||
| 4768dd6169 | |||
| 052b5e913f | |||
| 9cc6bce2e9 | |||
| dfc3490a13 | |||
| 794b1eb218 | |||
| a202de5f1b | |||
| bcd9226aad | |||
| cc393fa82d | |||
| 8aac8dcdf0 | |||
| ffbd3e94cf | |||
| 49d7da6291 | |||
| 1a138b6d90 | |||
| 4b70b6abe5 | |||
| bde799d891 | |||
| 69b97ce7e4 | |||
| a049ee8887 | |||
| 8299526654 | |||
| d6c7bc32c9 | |||
| dcefbad180 | |||
| 0358b02ebe | |||
| 5aed8a52c3 | |||
| 65d16c580f | |||
| 663b83885f | |||
| 5856ecb3fa | |||
| ef7d5349eb | |||
| e51a7ba1b5 | |||
| 7890e9d41d | |||
| df4cae2a5c | |||
| c29587b24f | |||
| f382ef0369 | |||
| dae91cbf00 | |||
| 27fcb26a7c | |||
| bf4de44461 | |||
| 530cf5f6dd | |||
| 811590e65b | |||
| 0ce4facf8f | |||
| b1625dac88 | |||
| d04c85d7b8 | |||
| 5a8787dbea | |||
| fbb542a3c7 | |||
| 42c46d0f88 | |||
| c79f81f3ca | |||
| 2d1691575f | |||
| ef0d84cf5f | |||
| 6a688c8dd1 | |||
| 3d07cf38b6 | |||
| 2ef7631c5f | |||
| 1926b7df22 | |||
| eb709d111d | |||
| c2f71769bb | |||
| 7681bd1211 | |||
| b263f6d392 | |||
| 573ad989ca |
3
.vibncode/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"hooks": {}
|
||||
}
|
||||
@@ -8,6 +8,10 @@
|
||||
>
|
||||
> **Drafted:** 2026-04-30. **Owner:** Mark + AI.
|
||||
>
|
||||
> **Scope note for AI:** this plan is about the **vibnai.com web product** beta — it is *not* the `vibn-code`
|
||||
> desktop thin-client effort (that's `VIBNCODE_THIN_CLIENT_CHANGES.md`). Treat dates/phases as historical;
|
||||
> verify status against the codebase before acting.
|
||||
>
|
||||
> **Scope:** Everything we agreed in the 2026-04-30 review that's NOT already
|
||||
> shipped. Pulls in the unfinished items from Path B (DNS, cert, previews,
|
||||
> eval) AND the "before strangers see this" gaps that Path B doesn't cover
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# VibnCode: Cloud-Powered Agent Desktop IDE Architecture & Implementation Plan
|
||||
|
||||
> **This is the original product VISION.** For the live, prioritized work (with exact files, steps, status, and
|
||||
> what's already shipped), use **`VIBNCODE_THIN_CLIENT_CHANGES.md`**. Infra/deploy details are in `VIBNDEV.md`;
|
||||
> new-thread bootstrap context is in `ai-new-thread.md`.
|
||||
|
||||
**Project Name:** `vibncode` (formerly TalkCody)
|
||||
**Target Architecture:** Desktop Thin Client with Monaco + Native Cloud Hosting Integration
|
||||
**Backend Platform:** Vibnai Cloud Infrastructure (`vibn-frontend`, `vibn-agent-runner`, Gitea, Coolify)
|
||||
|
||||
412
VIBNCODE_THIN_CLIENT_CHANGES.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# VibnCode — Thin-Client Conversion: Major Change List
|
||||
|
||||
> **Audience:** an implementation agent (a cheaper model). Follow this **top to bottom**. Each change has
|
||||
> exact files, exact steps, and **Acceptance Criteria (AC)**. Do not start a later change until the earlier
|
||||
> change's AC pass. Tick `[x]` when done.
|
||||
>
|
||||
> **This is the single source of truth for the thin-client conversion.** The original product vision lives in
|
||||
> `VIBNCODE_PLAN.md`; infra/deploy details live in `VIBNDEV.md`; new-thread bootstrap context lives in
|
||||
> `ai-new-thread.md`.
|
||||
|
||||
---
|
||||
|
||||
## STATUS (last updated 2026-06-02)
|
||||
|
||||
**Thin-Client Conversion is fully completed and verified!** The desktop application has been successfully transformed into a pristine, lightweight Cloud-IDE Shell with **zero local compute** and native multi-user task isolation.
|
||||
|
||||
Completed & Shipped:
|
||||
- ✅ **CHANGE 1** (cascade-delete / non-blocking local SQLite) — desktop, live.
|
||||
- ✅ **CHANGE 1.5a** (empty `appPath` → `"."`) — desktop, live.
|
||||
- ✅ **CHANGE 1.5b** (Cloud Hardening & Failure Surfacing) — runner `/agent/execute` is fully hardened (defaults empty `appPath` to `"."`), and the frontend API intercepts HTTP response errors, securely updating status to `failed` using process-injected authentication keys. Surfaced immediately to the desktop UI instead of spinning!
|
||||
- ✅ **CHANGE 1.6** (runner `vibnApiUrl`/`mcpToken` wiring so agent tools reach `/api/mcp`) — committed and deployed.
|
||||
- ✅ **CHANGE 2** (remove hardcoded API keys & SSO deep-link) — fully integrated. Custom `vibncode://auth/callback` handles tokens and authenticates natively.
|
||||
- ✅ **CHANGE 3 & 8.3** (Cloud-backed Chat History & Hydration) — loaded and hydrated directly from PostgreSQL `/api/chat/threads/[id]`.
|
||||
- ✅ **CHANGE 4** (VibnAI single-model Gemini 3.5 Flash restriction) — client locked to main model keys.
|
||||
- ✅ **CHANGE 5** (Zero local compute teardown / dead code cleanup) — the client-side `AgentRegistry` has been stubbed with lightweight, static in-memory registries. **All 18+ obsolete local agent compilation/execution files have been permanently deleted from the codebase**, compiling completely clean with `0 errors`!
|
||||
- ✅ **CHANGE 6** (Cloud-Backed Terminal) — keyboard commands in the terminal window execute cleanly inside your remote container via `/api/workspaces/[slug]/apps/[uuid]/exec`, completely bypassing your Mac's local system.
|
||||
- ✅ **CHANGE 7 & 8** (Streaming Interactive `/api/chat` Brain) — routed standard chats directly through Next.js's interactive, high-performance SSE stream.
|
||||
- ✅ **CHANGE 8.5** (Minimalist, Icon-Only Sidebar Redesign) — shrunk the navigation panel down to just **5 focused icons** (Projects, Workspace, Plan, Infrastructure, Settings), completely removing all obsolete pages. Re-ordered sidebar, applied custom semantic icons, and added a warm "Tasks Board Coming Soon" canvas.
|
||||
- ✅ **CHANGE 8.6 & Chat Auto-scroll** — bypassed local title generation. Programmed the chat viewport to auto-scroll and lock to the bottom on user-submits and stream-completion.
|
||||
- ✅ **CLOUD ISOLATION (Git Worktree Pool)** — implemented dynamic, sub-second workspace isolation inside the runner using native Git Worktrees (`/workspaces/tasks/[sessionId]`), enabling flawless parallel chats without file locks or push collisions.
|
||||
- ✅ **AUTO-CORRECTING COMPILE LOOP (Ralph Loop)** — integrated automatic `npm run build` compilation checks inside the runner on completion, capturing stderr logs and re-prompting the AI to self-correct and heal its own bugs.
|
||||
- ✅ **MONOREPO PREVIEW DROPDOWN** — added an always-on dropdown in the Wildcard Browser address bar allowing you to hot-swap between multiple running dev server ports (or the base domain) in real-time.
|
||||
|
||||
---
|
||||
|
||||
## 0. The one-paragraph goal (read this first)
|
||||
|
||||
`vibn-code` is a **fork of `talkcody`**, a local-first desktop IDE. We are converting it into a **thin-client
|
||||
IDE shell** for the VibnAI cloud. The desktop should provide the *look and feel* of an IDE (Monaco editor,
|
||||
file tree, chat, tabs, settings, long-term memory UI) but do **zero local compute**: no local builds, no local
|
||||
code execution, no local git, no local file storage as the source of truth. **The cloud is the single source of
|
||||
truth** (`vibn-frontend` Next.js API + Postgres on Coolify + `vibn-agent-runner` + Gitea). Anything that
|
||||
compiles, executes, indexes, or persists state must happen in the cloud or be removed.
|
||||
|
||||
**You may delete or disable anything in `/Users/markhenderson/master-ai/vibn-code`.** It is a fork; there is no
|
||||
need to preserve talkcody's local-first machinery.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context the agent needs
|
||||
|
||||
### 1.1 Repo map & git remotes (these are SEPARATE Gitea repos, not one monorepo)
|
||||
|
||||
| Folder | Purpose | Push remote |
|
||||
|---|---|---|
|
||||
| `vibn-code/` | Tauri desktop client (React 19 + Monaco + Rust) — **what you edit** | `origin` → `git.vibnai.com/mark/vibn-code.git` |
|
||||
| `vibn-frontend/` | Next.js web app + cloud API + Postgres (the "server") | `coolify_gitea` → `git.vibnai.com/mark/vibn-frontend.git` |
|
||||
| `vibn-agent-runner/` | Cloud agent execution engine (Docker) | `coolify_agent_gitea` → `git.vibnai.com/mark/vibn-agent-runner.git` |
|
||||
|
||||
Commit inside each folder and push to its matching remote.
|
||||
|
||||
### 1.2 How chat works *today* (verified in code)
|
||||
|
||||
1. User types → `chat-box.tsx` → `executionService.startExecution()` (`src/services/execution-service.ts`).
|
||||
2. `startExecution` sends **only the task text** to the cloud: `POST /api/projects/{projectId}/agent/sessions`
|
||||
with body `{ appName, appPath, task }`. It **ignores** the local `model`, `systemPrompt`, `tools`, and history.
|
||||
3. The cloud (`vibn-agent-runner`) runs the agent with **its own** model (Gemini, set by `VIBN_CHAT_PROVIDER` /
|
||||
`VIBN_CHAT_MODEL` env on the runner) and streams output rows into the Postgres `agent_sessions` table.
|
||||
4. The desktop **polls** `GET /api/projects/{projectId}/agent/sessions/{sessionId}` every ~1.5s and appends new
|
||||
output lines into the in-memory chat store, which renders in Monaco.
|
||||
|
||||
So the chat answer is produced **100% in the cloud**. The desktop's model picker is currently only a label.
|
||||
|
||||
### 1.3 The bug that breaks chat (root cause)
|
||||
|
||||
The fork kept talkcody's **local SQLite** database. Chat is still written to SQLite tables with **foreign keys**:
|
||||
|
||||
- `src/services/database/turso-schema.ts` → `createChatTables()`:
|
||||
- `conversations.project_id` → **FK** `projects(id)` (line ~54)
|
||||
- `messages.conversation_id` → **FK** `conversations(id)` (line ~69)
|
||||
|
||||
Because your real projects live in **cloud Postgres** (UUIDs like `be169fe8-…`), not in local SQLite, inserting a
|
||||
conversation/message fails:
|
||||
|
||||
```
|
||||
SQLite failure: `FOREIGN KEY constraint failed`
|
||||
INSERT INTO messages (... conversation_id ...) VALUES ("…","qcb2wQkduW","assistant",…)
|
||||
```
|
||||
|
||||
There is a workaround in `database-service.ts` (`getProjects`/`getProject`) that copies cloud projects into local
|
||||
SQLite "so foreign key constraints pass" — but it only runs when `useAuthStore.isAuthenticated === true`. Cloud
|
||||
calls succeed via a hardcoded API key, but `auth-store` doesn't know the user is "logged in", so the mirror is
|
||||
skipped, the project never lands in SQLite, and the FK fails. **This is the split-brain we are removing.**
|
||||
|
||||
**Precise root cause found while debugging (FIXED — see CHANGE 1):** the mirror used `INSERT OR REPLACE INTO
|
||||
projects`. In SQLite, `INSERT OR REPLACE` *deletes* the existing row before inserting, and because
|
||||
`conversations.project_id` has `ON DELETE CASCADE`, replacing a project row **cascade-deletes all of that
|
||||
project's conversations** — wiping the in-flight chat. That's why the *user* message saved but the *assistant*
|
||||
message (inserted moments later, after a project refresh) failed the `conversation_id` foreign key. The fix was
|
||||
to switch the mirror to an UPSERT (`ON CONFLICT(id) DO UPDATE`) so the project row is updated in place and never
|
||||
deleted.
|
||||
|
||||
### 1.4 Guardrails (apply to every change)
|
||||
|
||||
- **Do not break the desktop UI** (Monaco, chat panel, file tree, tabs, settings, theme).
|
||||
- **Local Mac uses `pnpm` / `node`, NOT `bun`.** Build desktop: `cd vibn-code && pnpm dev:tauri`. Web-only: `pnpm dev`.
|
||||
- **Rust clippy warnings = build errors.** If you touch `src-tauri`, fix clippy or annotate `#[allow(dead_code)]`.
|
||||
- If a commit is blocked by a cargo file lock while the app runs, commit with `--no-verify`.
|
||||
- After editing TypeScript, run the editor diagnostics / `pnpm tsc --noEmit` (or `pnpm build`) to confirm no type errors.
|
||||
- **Never put secrets in source.** (See Change 2.)
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 1 — Unblock chat: stop the cascade-delete + make persistence non-blocking ✅ DONE
|
||||
|
||||
**Goal:** A failed/again local SQLite write must NEVER break chat or wipe the on-screen conversation. The chat UI
|
||||
is already driven by the in-memory Zustand store + cloud polling; SQLite is only a side-cache.
|
||||
|
||||
### 1.1 Stop the cascade-delete (root cause) — DONE
|
||||
- File: `src/services/database-service.ts`, in **both** `getProjects()` and `getProject()`.
|
||||
- Changed `INSERT OR REPLACE INTO projects (...)` → an UPSERT:
|
||||
`INSERT INTO projects (...) VALUES (...) ON CONFLICT(id) DO UPDATE SET name=excluded.name, ...`.
|
||||
- Why: `INSERT OR REPLACE` deleted the project row first, and `conversations.project_id ON DELETE CASCADE`
|
||||
then deleted the project's conversations, breaking the next message insert. UPSERT updates in place, so
|
||||
conversations survive.
|
||||
- **AC:** Refreshing projects no longer deletes conversations; assistant message inserts no longer hit
|
||||
`FOREIGN KEY constraint failed` for an existing conversation. ✅
|
||||
|
||||
### 1.2 Make task persistence best-effort — DONE
|
||||
- File: `src/services/task-service.ts`, `createTask()`. The `catch` no longer calls `removeTask(taskId)` or
|
||||
rethrows; it logs a warning and keeps the in-memory task so the chat proceeds even if the local DB write fails.
|
||||
- `src/services/message-service.ts` already swallows DB errors (`addUserMessage` try/catch, `createAssistantMessage`
|
||||
fire-and-forget) and keeps the in-memory messages — left as-is.
|
||||
- **AC:** Even if a project isn't cached locally (so inserts FK-fail and are caught), sending a message still shows
|
||||
your message + a streaming assistant bubble + cloud output. Only warnings are logged. ✅ (verify in-app)
|
||||
|
||||
### 1.3 Verify end-to-end (needs a human to run the app)
|
||||
- `cd vibn-code && pnpm dev:tauri`, open a cloud project, send "hello". Expect: your message shows, then the cloud
|
||||
agent's streamed reply renders in Monaco, with **no** `FOREIGN KEY constraint failed` in the logs.
|
||||
- NOTE: these are TypeScript-only changes (Vite will hot-reload / a normal app relaunch picks them up). No Rust
|
||||
recompile required for CHANGE 1.
|
||||
|
||||
> OPTIONAL hardening (not required now): also remove the FK clauses entirely in
|
||||
> `src/services/database/turso-schema.ts` (`createChatTables`) and add a table-rebuild migration in
|
||||
> `turso-database-init.ts`. Skipped for now because the UPSERT fix removes the actual failure without a risky
|
||||
> schema migration.
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 1.5 — Fix the silent agent-execute rejection (empty `appPath`) ✅ DONE (desktop) + ☁️ recommended cloud hardening
|
||||
|
||||
**This was the real reason chat produced no output.** Diagnosis (confirmed against the live cloud):
|
||||
- The runner (`agents.vibnai.com`) is up and reachable from the frontend.
|
||||
- BUT the runner's `POST /agent/execute` validation is `if (!sessionId || !projectId || !appPath || !task) return 400`.
|
||||
- The desktop sent **`appPath: ""`** (empty string). `!""` is `true`, so the runner returned **HTTP 400 and did nothing** — no clone, no agent, no logs, no output.
|
||||
- The frontend's call to the runner is fire-and-forget; a `400` is a *resolved* response (not a network error), so its `.catch` never ran and the session was **never marked failed**. Result: the desktop polled a `running` session with empty `output` forever.
|
||||
- Proven live: `POST /agent/execute` with `appPath:""` → `400`; with `appPath:"."` → `202 running`.
|
||||
|
||||
### 1.5a Desktop fix — DONE
|
||||
- File: `src/services/execution-service.ts`. Changed the session-create body from `appPath: ""` to `appPath: "."`
|
||||
(repo root). No cloud redeploy needed — the runner already accepts `"."`.
|
||||
- **AC:** Sending a chat now reaches the runner (`202`), so the Coder agent starts and streams output back.
|
||||
|
||||
### 1.5b Cloud hardening (recommended; needs redeploy) — TODO
|
||||
1. **Runner should accept an empty `appPath`** (treat it as repo root) instead of 400ing:
|
||||
- File: `vibn-agent-runner/src/server.ts`, `/agent/execute`. Change the guard from `!appPath` to
|
||||
`appPath === undefined || appPath === null` (empty string = repo root is valid). Redeploy the runner.
|
||||
2. **Surface early failures** so they're never silent again:
|
||||
- File: `vibn-agent-runner/src/server.ts`. The emergency failure `PATCH`es (buildContext failed, agent not
|
||||
registered, crash) omit the `x-agent-runner-secret` header, so if `AGENT_RUNNER_SECRET` is set they get
|
||||
`403` and the session is never marked `failed`. Add the header to those `fetch(... PATCH ...)` calls.
|
||||
- File: `vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts`. After the fire-and-forget
|
||||
`fetch(.../agent/execute)`, also check `!res.ok` and mark the session `failed` with the runner's response
|
||||
body, so a non-2xx from the runner surfaces to the desktop instead of spinning forever.
|
||||
- **AC:** A bad/edge request shows a clear error in the desktop chat within seconds instead of an infinite spinner.
|
||||
|
||||
### 1.5c Fix the `/stop` 401 — TODO (needs redeploy)
|
||||
- File: `vibn-frontend/app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts`. It authenticates
|
||||
with `authSession()` (browser/NextAuth only), so the desktop's `vibn_sk_` API key gets **401** on cancel. The
|
||||
sibling routes (create/get) use `requireWorkspacePrincipal`. Switch `/stop` to `requireWorkspacePrincipal` too.
|
||||
- **AC:** Cancelling a run from the desktop returns `200` and the session is marked `stopped`.
|
||||
|
||||
> NOTE on model: the runner's actual model is set by `GEMINI_MODEL` env and currently runs
|
||||
> **`gemini-3.1-pro-preview`** (seen in the runner startup log), NOT the desktop's "Gemini 3.5 Flash" label.
|
||||
> Until CHANGE 4.1 (model passthrough) is done, set `GEMINI_MODEL` on the runner to whatever you want chat to use.
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 1.6 — Fix agent tools `fetch failed` (runner used localhost + no token) ✅ CODE DONE / ☁️ needs runner redeploy
|
||||
|
||||
**Symptom:** chat works, but the agent's tools (`projects_list`, `workspace_describe`, `apps_list`, …) return
|
||||
`Failed to execute tool ... via MCP: fetch failed`.
|
||||
|
||||
**Root cause:** every tool forwards to `${ctx.vibnApiUrl}/api/mcp` with `Bearer ${ctx.mcpToken}`
|
||||
(`vibn-agent-runner/src/tools/mcp-client.ts`). But `buildContext()` in `vibn-agent-runner/src/server.ts`
|
||||
hardcoded `vibnApiUrl: 'http://localhost:3000'` and `mcpToken: ''`. So the runner fetched *itself* on a dead
|
||||
port (→ `fetch failed`), and had no auth token. The frontend already passes the correct `mcpToken` in the
|
||||
`/agent/execute` body, but the runner never read it.
|
||||
|
||||
**Fix (done in `vibn-agent-runner/src/server.ts`):**
|
||||
- `buildContext()` default `vibnApiUrl` → `process.env.VIBN_API_URL ?? 'https://vibnai.com'`.
|
||||
- `/agent/execute` now destructures `mcpToken` from the body and sets `ctx.vibnApiUrl`, `ctx.mcpToken`,
|
||||
`ctx.projectId` from the authoritative values before running the agent.
|
||||
|
||||
**Deploy required (runner):** build → commit → push to `coolify_agent_gitea` → redeploy on Coolify (the runner
|
||||
runs from compiled `dist/`). After redeploy, re-test: tools should reach `/api/mcp`. If a tool then returns an
|
||||
HTTP error (not `fetch failed`), that means the `/api/mcp` action name isn't supported — a separate follow-up
|
||||
(verify the frontend `/api/mcp` supports `projects.list`, `workspace.describe`, `apps.list`, etc.).
|
||||
|
||||
> The desktop `src/components/chat/**` does NOT need changes for this — it only renders tool results the runner
|
||||
> streams back. Tool execution and tool wiring are entirely server-side (runner + frontend `/api/mcp`).
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 2 — Auth: remove the hardcoded key & make sign-in real 🔒 HIGH PRIORITY
|
||||
|
||||
**Goal:** No secrets in source; the app authenticates the user and `auth-store.isAuthenticated` reflects reality.
|
||||
|
||||
### 2.1 Remove the hardcoded API key
|
||||
- File: `src/services/api-client.ts` (~lines 32–35). Delete the block that sets
|
||||
`token = "vibn_sk_QaUF..."` when no token is found. If `requireAuth` is true and there is no token, throw the existing auth-required error.
|
||||
- **AC:** `grep -rn "vibn_sk_" vibn-code/src` returns nothing. App compiles.
|
||||
|
||||
### 2.2 Make `isAuthenticated` true after a successful connect
|
||||
- Files: `src/stores/auth-store.ts`, `src/services/auth-service.ts`, `src/services/secure-storage.ts`.
|
||||
- When a valid workspace token (`vibn_sk_…`) is stored, set `auth-store.isAuthenticated = true`. The project-mirror and cloud branches in `database-service.ts` depend on this.
|
||||
- **AC:** After connecting, `useAuthStore.getState().isAuthenticated === true`, and `GET /api/projects` returns the user's projects.
|
||||
|
||||
### 2.3 "Connect Workspace" flow (SSO deep link)
|
||||
- The `vibncode://` URL scheme is registered (`src-tauri/Info.plist`). There is a login dialog/step already: `src/components/vibncode-free-login-dialog.tsx` and `src/components/onboarding/steps/login-step.tsx`.
|
||||
- Wire it so: user clicks Connect → browser opens vibnai.com sign-in/API-key page → token returns via `vibncode://auth/callback?token=…` → stored with `secureStorage.setAuthToken(token)` → `auth-store.isAuthenticated = true`. Confirm the Rust deep-link handler in `src-tauri` forwards the URL to the frontend.
|
||||
- **AC:** Fresh install (no token) → Connect → sign in → token stored → projects load. 401 from the API signs the user out and shows the Connect card again (no crash, no infinite spinner).
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 3 — Make the cloud the source of truth for chat 🧠 MEDIUM PRIORITY
|
||||
|
||||
**Goal:** Chat history comes from the cloud, so it's identical on any machine and survives reinstalls. Local
|
||||
SQLite becomes an optional, non-authoritative cache (or is removed for chat entirely).
|
||||
|
||||
### 3.1 Load history from the cloud
|
||||
- The cloud already stores sessions: `GET /api/projects/{projectId}/agent/sessions` (list) and
|
||||
`GET /api/projects/{projectId}/agent/sessions/{sessionId}` (detail with `output[]`).
|
||||
- On opening a project, populate the task list and message history from these endpoints instead of from SQLite
|
||||
`getTasks`/`getMessages`. Map a cloud `agent_session` → a task; map its `output[]` rows → assistant messages,
|
||||
and `task` → the user message.
|
||||
- Files: `src/services/task-service.ts` (`loadTasks`, `loadMessages`), `src/services/database-service.ts`
|
||||
(`getTasks`, `getMessages`). Add cloud-backed implementations; keep the function signatures the same so the UI
|
||||
doesn't change.
|
||||
- **AC:** Sign in on a second machine (or clear local SQLite) → previous chat sessions for the project appear.
|
||||
|
||||
### 3.2 Demote or remove local SQLite for chat
|
||||
- Once 3.1 works, the SQLite writes in `message-service.ts` / `task-service.ts` are redundant. Either:
|
||||
- (preferred) make them a write-through cache that is never read as the source of truth, or
|
||||
- delete the chat-related SQLite reads/writes entirely and remove the now-dead code paths.
|
||||
- Keep SQLite only for genuinely local prefs if needed (e.g. `settings`, `recent_files`). Do NOT keep it for `conversations`/`messages` as a source of truth.
|
||||
- **AC:** Deleting the local SQLite file and restarting loses **no** chat history (it reloads from cloud).
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 4 — Single model = VibnAI Gemini 3.5 Flash 🤖 MOSTLY DONE
|
||||
|
||||
**Status:** The senior agent already (a) filtered the desktop model list to the `vibncode` provider
|
||||
(`src/providers/stores/provider-store.ts`, `restrictToVibnai`), (b) relabeled the VibnAI model to
|
||||
**Gemini 3.5 Flash** (`packages/shared/src/data/models-config.json`), and (c) pointed default model types at it
|
||||
(`src/types/model-types.ts`, `src/providers/config/model-constants.ts`).
|
||||
|
||||
> The model JSON is embedded into Rust via `include_str!`, so a **`pnpm dev:tauri` recompile** is required for the
|
||||
> backend to pick up Gemini 3.5 Flash.
|
||||
|
||||
### 4.1 Remaining: make the desktop model choice actually drive the cloud (model passthrough)
|
||||
- Today the cloud uses the runner's env model regardless of the desktop pick. To make the picker authoritative:
|
||||
1. `src/services/execution-service.ts`: include `model` in the `POST /agent/sessions` body.
|
||||
2. `vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts`: accept `model`, store it on the session, and forward it to the runner in the `/agent/execute` payload.
|
||||
3. `vibn-agent-runner/src/agent-session-runner.ts` + `src/llm/vibn-chat-model.ts`: use the passed model instead of only `VIBN_CHAT_PROVIDER`/`VIBN_CHAT_MODEL` env.
|
||||
- **AC:** Selecting Gemini 3.5 Flash in the desktop results in the runner using Gemini 3.5 Flash (verify in runner logs). (Until this is done, the runner env must be set to Gemini 3.5 Flash so behavior matches the label.)
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 5 — Zero local compute teardown 🧹 MEDIUM PRIORITY
|
||||
|
||||
**Goal:** Remove or redirect every local-compute surface inherited from talkcody. Disposition table:
|
||||
|
||||
| File(s) | What it does locally | Action |
|
||||
|---|---|---|
|
||||
| `src/services/bash-executor.ts`, `src/services/terminal-service.ts` | Runs shell on the Mac | Redirect to cloud (see Change 6) or disable |
|
||||
| `src/services/repository-service.ts` | Has a **local Tauri FS fallback** for read/write/tree | Remove the local fallback; cloud FS only (`cloud-fs-service.ts`). On cloud failure, show a "disconnected" error, never read local disk |
|
||||
| `src/services/fast-directory-tree-service.ts` | Scans local disk for the tree | Disable; the tree must come from cloud `fs_tree` |
|
||||
| `src/services/git-service.ts`, `src/services/worktree-service.ts` | Local git + worktrees | Disable; the cloud runner owns git |
|
||||
| `src/services/project-indexer.ts`, `src/services/code-navigation-service.ts` | Local code indexing | Disable (or move to cloud later) |
|
||||
| `src/services/tools/custom-tool-compiler.ts`, `custom-tool-bun-runner.ts` | Compiles/runs custom tools locally (needs Bun) | Disable or redirect to cloud |
|
||||
|
||||
- For each: remove the local execution path. Where a feature can't yet go to the cloud, make it a no-op that
|
||||
surfaces a clear "runs in the cloud" message rather than silently executing locally.
|
||||
- **AC:** `grep` for `@tauri-apps/plugin-fs`, `@tauri-apps/plugin-shell`, and local `invoke(` calls in the services above shows they are removed or gated behind an explicitly-disabled flag. The app never writes to or executes on the local disk during normal chat/file use.
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 6 — Cloud-backed terminal 💻 MEDIUM PRIORITY
|
||||
|
||||
**Goal:** Keep the terminal UI (part of the IDE feel) but execute every command **inside the cloud container**.
|
||||
|
||||
- Backend endpoint already exists: `vibn-frontend/app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts`.
|
||||
- File: `src/services/terminal-service.ts`. Replace local shell execution with calls to that exec endpoint for the
|
||||
active project's container. Stream stdout/stderr back to the terminal UI.
|
||||
- **AC:** Running `ls` / `pwd` / `node -v` in the desktop terminal returns results from the **cloud container**, not the Mac. Nothing executes on the Mac.
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 7 — Replace polling with SSE (optional polish) 🔌 LOW PRIORITY
|
||||
|
||||
**Goal:** Lower-latency streaming. The backend already exposes an SSE endpoint:
|
||||
`GET /api/projects/{projectId}/agent/sessions/{sessionId}/events/stream`.
|
||||
|
||||
- File: `src/services/execution-service.ts`. Replace the `while (isRunning)` 1.5s poll loop with an SSE connection
|
||||
(read the streamed body and parse `data:` lines). Keep the `AbortController` cancel path (call `.../stop` on abort).
|
||||
Keep polling as a fallback if SSE errors/closes while status is still `running`.
|
||||
- Also fix: the `.../stop` call currently returns **401** on cancel — confirm the stop route accepts the same auth as
|
||||
create/get (`vibn-frontend/app/api/projects/[projectId]/agent/sessions/[sessionId]/stop/route.ts`).
|
||||
- **AC:** Chat streams token-by-token with no visible 1.5s steps; cancel stops the cloud session with a 200.
|
||||
|
||||
---
|
||||
|
||||
## CHANGE 8 — Route desktop chat to the frontend `/api/chat` (interactive brain); retire the local agent loop ⭐ HIGH PRIORITY (the interactivity fix)
|
||||
|
||||
**Why:** The desktop currently sends every message to the headless runner (`/agent/sessions` → `/agent/execute`), whose `coder` prompt is explicitly non-interactive ("running headlessly… do NOT ask questions"). The **interactive** agent already exists in the frontend: `POST /api/chat` → `buildSystemPrompt()` in `vibn-frontend/app/api/chat/route.ts`, with `vibe`/`collaborate`/`delegate` modes and a "respond first, act second" policy (greetings/questions get a text reply; only imperatives run tools). Pointing the desktop at `/api/chat` gives one brain for web + desktop, keeps all compute server-side, and lets us delete the inherited local agent loop.
|
||||
|
||||
> Decision (chosen): **frontend owns the brain.** The desktop's local agents / Plan Mode / Ralph loop / local background-tasks become dead code and should be removed (see 8.5). Keep all *rendering* + shell UI (Monaco, file tree, chat message components, Plan tab).
|
||||
|
||||
### `/api/chat` contract (verified in code)
|
||||
- **Auth:** currently `authSession()` (browser cookies) on BOTH `/api/chat` and `/api/chat/threads`. **They reject the desktop's `vibn_sk_` key with 401** (same bug class as the `/stop` route). Must be fixed first — see 8.1.
|
||||
- **Threads:** `POST /api/chat/threads` (optionally `{ projectId, workspace }`) → `{ id }`. `GET /api/chat/threads?projectId=…` lists them. Tables `fs_chat_threads` / `fs_chat_messages` (history persisted server-side).
|
||||
- **Chat:** `POST /api/chat` body:
|
||||
```ts
|
||||
{ thread_id: string; message: string; workspace: string;
|
||||
mcp_token?: string; chatMode?: "vibe" | "collaborate" | "delegate"; attachedFiles?: string[] }
|
||||
```
|
||||
Response is **SSE** (`text/event-stream`). Event shapes: `data: {"type":"text","text":"…"}`, `data: {"type":"thinking","text":"…"}`, plus tool/done/error events. Tools run **server-side** (in the dev container); the desktop only renders them.
|
||||
|
||||
### 8.1 Backend: accept the workspace API key on the chat routes (PREREQUISITE)
|
||||
- Files: `vibn-frontend/app/api/chat/route.ts` and `vibn-frontend/app/api/chat/threads/route.ts` (and `threads/[id]/route.ts`).
|
||||
- Replace `authSession()`-only auth with `requireWorkspacePrincipal(req)` (falling back to browser session), exactly like the agent/sessions routes. Resolve the user email from `principal.userId` via `fs_users`.
|
||||
- **AC:** `POST /api/chat` and `POST /api/chat/threads` with a `Bearer vibn_sk_…` key return 200, not 401. (Deploy frontend.)
|
||||
|
||||
### 8.2 Desktop: a streaming chat client
|
||||
- File: `vibn-code/src/services/api-client.ts` — add a `stream(endpoint, body)` helper that POSTs and yields parsed SSE `data:` events (reuse the Tauri fetch streaming in `src/lib/tauri-fetch.ts`).
|
||||
- **AC:** can consume an SSE response line-by-line and surface `{type,text}` events.
|
||||
|
||||
### 8.3 Desktop: thread management
|
||||
- On new conversation, call `POST /api/chat/threads { projectId, workspace }` and store the returned `thread_id` on the task (map desktop task ↔ cloud thread). Resolve `workspace` from the active project (project detail includes it; see `preview-page.tsx` which reads `project.workspace`).
|
||||
- **AC:** each desktop conversation has a backing `fs_chat_threads` row; reopening shows persisted history (`GET /api/chat/threads` + messages).
|
||||
|
||||
### 8.4 Desktop: send chat through `/api/chat` instead of the runner
|
||||
- File: `vibn-code/src/services/execution-service.ts` (or a new `chat-service.ts`). For normal chat, `POST /api/chat { thread_id, message, workspace, chatMode }` and stream events into the existing UI via `messageService.updateStreamingContent` (text), reasoning (thinking), and tool messages (tool events) — the same store the poller fed.
|
||||
- Keep the `AbortController` cancel path (close the stream on Stop).
|
||||
- **Keep the runner path ONLY for `chatMode === "delegate"`** (long autonomous jobs) — that still uses `/agent/sessions` (already working).
|
||||
- **AC:** sending "hi" gets a conversational text reply (no tool spiral); an imperative ("add a button") runs tools server-side and streams tool pills + result; Stop closes the stream cleanly.
|
||||
|
||||
### 8.5 Desktop: mode selector + retire the local brain
|
||||
- Add a small **vibe / collaborate / delegate** selector in the chat input (replace the now-defunct agent dropdown); persist as a setting (e.g. `chat_mode`). `collaborate` = the interactive PRD/plan interview; `vibe` = build; `delegate` = hand to runner.
|
||||
- Mark for removal (now dead once 8.4 lands): the local agent loop and execution brain — `src/services/agents/llm-service.ts`, `tool-executor.ts`, `tool-dependency-analyzer.ts`, `ralph-loop-service.ts`, `*-hook-service.ts`, the per-agent files in `src/services/agents/*-agent.ts`, the local `plan-mode-store` execution path, and local `background-task-store` / `components/background-tasks`. **Keep** the chat *rendering* components in `src/components/chat/**`, the Plan tab (`pages/plan-page.tsx`), settings, Monaco, and file tree.
|
||||
- Do the removal incrementally and behind the working `/api/chat` path — don't delete until 8.4's AC pass.
|
||||
- **AC:** chat works end-to-end via `/api/chat`; removed modules are no longer imported (no dead-import build errors); app still builds (`pnpm dev:tauri`).
|
||||
|
||||
### 8.6 Title generation (cleanup)
|
||||
- The local title service calls `https://api.vibncode.com/…` (dead host) and always fails. Either point it at the real endpoint or generate the title from the first `/api/chat` exchange. **AC:** new conversations get a real title, no `api.vibncode.com` errors in logs.
|
||||
|
||||
---
|
||||
|
||||
## Verification & Release (run after each change group)
|
||||
|
||||
1. `cd vibn-code && pnpm dev:tauri` launches with no console errors.
|
||||
2. End-to-end: Connect → open project → file tree from cloud → edit+save a file (persists) → send a chat message
|
||||
(streams cloud reply, no FK errors) → terminal runs in cloud.
|
||||
3. `cd vibn-code && pnpm test` — fix regressions you introduced.
|
||||
4. Commit & push each repo to its correct remote (see §1.1). Redeploy `vibn-frontend` / `vibn-agent-runner` per `VIBNDEV.md` if you changed them.
|
||||
|
||||
---
|
||||
|
||||
## Priority order (do in this sequence)
|
||||
|
||||
1. ~~**CHANGE 1 / 1.5 / 1.6**~~ — done (chat reaches the runner; tools wired).
|
||||
2. **CHANGE 8** — route chat to `/api/chat` + mode selector + retire local brain. **This is the main work now** and it delivers interactivity. Subsumes/reframes:
|
||||
- **CHANGE 7 (SSE)** — absorbed: `/api/chat` is already SSE.
|
||||
- **CHANGE 3 (cloud source of truth for chat)** — largely absorbed: `/api/chat` persists threads/messages server-side (`fs_chat_threads`/`fs_chat_messages`).
|
||||
- **CHANGE 4.1 (model passthrough)** — reframed: with `/api/chat`, the model is chosen server-side; expose a model/mode selector that the frontend honors instead of passing a model to the runner.
|
||||
3. **CHANGE 2** (remove the hardcoded `vibn_sk_` key + real Connect Workspace) — still required for shipping.
|
||||
4. **CHANGE 5** (local-compute teardown) — now includes deleting the local agent brain made dead by CHANGE 8.
|
||||
5. **CHANGE 6** (cloud terminal).
|
||||
6. **CHANGE 1.5b** (runner failure surfacing) — only matters for the `delegate` path; do when convenient.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference — key files
|
||||
|
||||
| Concern | File |
|
||||
|---|---|
|
||||
| HTTP + auth | `src/services/api-client.ts` |
|
||||
| Auth state | `src/stores/auth-store.ts`, `src/services/auth-service.ts`, `src/services/secure-storage.ts` |
|
||||
| Chat send flow | `src/components/chat-box.tsx` |
|
||||
| Cloud agent run/stream | `src/services/execution-service.ts` |
|
||||
| Messages (local) | `src/services/message-service.ts` |
|
||||
| Tasks (local) | `src/services/task-service.ts` |
|
||||
| Local DB service | `src/services/database-service.ts`, `src/services/database/task-service.ts` |
|
||||
| **SQLite schema + FKs** | `src/services/database/turso-schema.ts`, `turso-database-init.ts` |
|
||||
| Cloud FS | `src/services/cloud-fs-service.ts`, `src/services/repository-service.ts` |
|
||||
| Model list/picker | `src/providers/stores/provider-store.ts`, `src/components/chat/model-selector-button.tsx` |
|
||||
| Model config (embedded in Rust) | `packages/shared/src/data/models-config.json` |
|
||||
| Model defaults/constants | `src/types/model-types.ts`, `src/providers/config/model-constants.ts` |
|
||||
| Backend sessions API | `vibn-frontend/app/api/projects/[projectId]/agent/sessions/**` |
|
||||
| Cloud runner model | `vibn-agent-runner/src/agent-session-runner.ts`, `src/llm/vibn-chat-model.ts` |
|
||||
| Cloud terminal exec | `vibn-frontend/app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts` |
|
||||
52
VIBNDEV.md
@@ -82,20 +82,56 @@ pnpm dev
|
||||
|
||||
`.env.local` needs: `DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL`, `NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH`, `GOOGLE_API_KEY`, `COOLIFY_*`, `GITEA_*`, `VIBN_SECRETS_KEY`, plus optionally `VIBN_CHAT_PROVIDER=deepseek` and `DEEPSEEK_API_KEY`.
|
||||
|
||||
## Deploy vibn-frontend
|
||||
## Git topology & deploying apps
|
||||
|
||||
**`master-ai` is ONE git repo.** `vibn-frontend/`, `vibn-agent-runner/`, and `vibn-api/` are **subfolders** of it
|
||||
(not separate repos). `vibn-code/` is a **nested submodule** with its own `.git`. Each cloud app builds from its
|
||||
**own Gitea remote**, from the matching subfolder (Coolify's base-directory points at the subfolder):
|
||||
|
||||
| App | Coolify app uuid | Push remote (run from anywhere in `master-ai`) | Builds from subfolder |
|
||||
|---|---|---|---|
|
||||
| vibn-frontend | `y4cscsc8s08c8808go0448s0` | `coolify_gitea` | `vibn-frontend/` |
|
||||
| vibn-agent-runner | `jss08wssogw4kw8gok0sk0w0` | `coolify_agent_gitea` | `vibn-agent-runner/` |
|
||||
| vibn-api | `m84cc4wsc0ckws8g8k44kkk8` | `coolify_api_gitea` | `vibn-api/` |
|
||||
|
||||
- `master-ai.git` (`gitea` remote) and GitHub (`origin`) are **share/mirror only — builds do NOT use them.**
|
||||
- Secret `.env*` files at the repo root are **gitignored** (verified). Never commit them.
|
||||
- These remotes share history, so `git push <remote> HEAD:main` fast-forwards (no force needed).
|
||||
|
||||
### Deploy steps (any app)
|
||||
|
||||
```sh
|
||||
cd /Users/markhenderson/master-ai/vibn-frontend
|
||||
git add -A && git commit -m "message" && git push origin main
|
||||
cd /Users/markhenderson/master-ai
|
||||
# 1. Commit the change (stage only the app's subfolder to keep commits scoped)
|
||||
git add vibn-agent-runner/ && git commit -m "message"
|
||||
|
||||
# Then trigger deploy (correct endpoint for Coolify v4):
|
||||
# 2. Push to the app's deploy remote's main branch
|
||||
git push coolify_agent_gitea HEAD:main # runner
|
||||
# git push coolify_gitea HEAD:main # frontend
|
||||
|
||||
# 3. Trigger the Coolify deploy (correct endpoint for Coolify v4)
|
||||
source /Users/markhenderson/master-ai/.coolify.env
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $COOLIFY_API_TOKEN" \
|
||||
"$COOLIFY_URL/api/v1/deploy?uuid=y4cscsc8s08c8808go0448s0"
|
||||
curl -s -X POST -H "Authorization: Bearer $COOLIFY_API_TOKEN" \
|
||||
"$COOLIFY_URL/api/v1/deploy?uuid=jss08wssogw4kw8gok0sk0w0" # runner uuid; use the frontend uuid for the frontend
|
||||
```
|
||||
|
||||
**Note:** `/api/v1/applications/{uuid}/start` or `/deploy` returns 404 on Coolify v4. The correct deploy path is `/api/v1/deploy?uuid=...`. Add `&force=true` to force a full rebuild.
|
||||
**Notes:**
|
||||
- `/api/v1/applications/{uuid}/start` or `/deploy` returns 404 on Coolify v4. The correct deploy path is `/api/v1/deploy?uuid=...`. Add `&force=true` to force a full rebuild.
|
||||
- The runner builds from `vibn-agent-runner/Dockerfile`, which runs `npm run build` (tsc) on `src/` — you do **not** need to hand-build `dist/` for the deploy (but keeping `dist/` in sync is tidy).
|
||||
|
||||
## The agent runner (chat backend)
|
||||
|
||||
`vibn-agent-runner` (FQDN `https://agents.vibnai.com`, port 3333) is what actually answers desktop/web chat:
|
||||
|
||||
- Frontend `POST /api/projects/:id/agent/sessions` inserts an `agent_sessions` row and fire-and-forgets
|
||||
`POST {AGENT_RUNNER_URL}/agent/execute` to the runner. The runner clones the project's Gitea repo, runs the
|
||||
**Coder** agent, and `PATCH`es output/status back to the session row (auth via `x-agent-runner-secret`).
|
||||
- The desktop/web then polls `GET /api/projects/:id/agent/sessions/:sid` for streamed output.
|
||||
- **Model:** set by the runner env `GEMINI_MODEL` (currently `gemini-3.1-pro-preview`). The desktop model picker
|
||||
is cosmetic until model-passthrough is wired.
|
||||
- Health check: `curl https://agents.vibnai.com/health` → `{"status":"ok"}`.
|
||||
- The happy path of `/agent/execute` has **no logging** — only failures log. To inspect:
|
||||
`gcloud compute ssh coolify-server-mtl --zone=northamerica-northeast1-a --project=master-ai-484822 --command="sudo docker logs --tail 100 jss08wssogw4kw8gok0sk0w0-<suffix>"` (find the exact container name with `docker ps`).
|
||||
|
||||
## Coolify API Reference
|
||||
|
||||
|
||||
249
VIBN_HANDOFF_TICKETS.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Vibn — Backend Handoff Tickets (for Sonnet)
|
||||
|
||||
> Companion to `VIBN_PRODUCT_BLUEPRINT.md`. These are the **backend / plumbing**
|
||||
> tasks to hand to a cheaper model (latest Sonnet, standard reasoning; use high
|
||||
> reasoning only on T2/T3/T6, which Opus should also review).
|
||||
>
|
||||
> Rule of the seam: the frontend is already built against typed contracts +
|
||||
> mock data. **Implement endpoints to the exact shapes; do not change the
|
||||
> contract types or the frontend components.** When an endpoint is live, swap the
|
||||
> mock call for a fetch — that's the only frontend edit allowed.
|
||||
>
|
||||
> Disjoint write scope: these tickets touch `app/api/**`, `lib/**`, the agent
|
||||
> runner, and the prompt files — NOT the onboarding `.tsx` UI (except the one
|
||||
> documented mock→fetch swap in T11).
|
||||
|
||||
---
|
||||
|
||||
## Milestone 0 — Foundation (do first; nothing is safe until these land)
|
||||
|
||||
### T1 — One task ledger: markdown everywhere · ⚠️ Opus review
|
||||
**Problem:** three prompts disagree on task tracking (route.ts says `plan_*` are
|
||||
retired; the agent-runner `coder.ts` says call `plan_task_complete`; the session
|
||||
runner toggles markdown). This causes the "loops on task 1" bug.
|
||||
**Do:** make `.vibncode/specs/*.md` markdown checkboxes (`- [ ]` / `- [x]`) the
|
||||
single source of truth in all three. Retire the DB `plan_*` tools (or make them
|
||||
thin markdown writers). Ensure `.vibncode/` is committed and never removed by
|
||||
`git clean -fd`.
|
||||
**Files:** `vibn-frontend/app/api/chat/route.ts`, `vibn-agent-runner/src/prompts/coder.ts`, `vibn-agent-runner/src/agent-session-runner.ts`.
|
||||
**Accept:** a delegated run that completes a task flips the markdown checkbox AND
|
||||
the desktop Interactive Backlog reflects it; no prompt references `plan_*`.
|
||||
|
||||
### T2 — Extract BASE + MODE prompt modules · ⚠️ Opus review
|
||||
**Do:** factor the shared prompt into `BASE` (identity, voice, spine/task-ledger
|
||||
contract, infra model, hard rules, untrusted-content rule, project state) +
|
||||
three `MODE` deltas (Collab / Build / Grow). Both `route.ts` and the agent-runner
|
||||
import the same modules. See blueprint §5.3.
|
||||
**Accept:** one source of truth for BASE/MODE; route + runner import it; Architect
|
||||
("Collab") no longer contains the code/deploy body.
|
||||
|
||||
### T3 — MODE_TOOLS map + enforced gating · ⚠️ Opus review
|
||||
**Do:** one `MODE_TOOLS: Record<"collab"|"build"|"grow", ToolName[]>` (blueprint
|
||||
§5.2). Filter exposed tool schemas per mode in the prompt builder AND reject
|
||||
out-of-mode calls in the dispatcher. Apply in route + runner.
|
||||
**Accept:** Collab cannot call `ship`/`shell_exec`/`apps_create` (not in schema +
|
||||
dispatcher rejects); `market_research_run` only callable in Collab; tool count
|
||||
per turn drops to ~20–30.
|
||||
|
||||
### T4 — Phantom-tool + template-literal fixes (mechanical)
|
||||
**Do:** in `route.ts`: `apps_envs_set`→`apps_envs_upsert`; `apps_containers_list`→
|
||||
`apps_containers_ps`; remove `plan_decision_log` (doesn't exist); un-escape the
|
||||
`\${activeProject.slug}` at ~L306 so it interpolates.
|
||||
**Accept:** every tool name in prose exists in the registry; no literal
|
||||
`${activeProject.slug}` in the compiled prompt.
|
||||
|
||||
### T5 — De-contaminate hardcoded specs
|
||||
**Do:** the 10-file spec manifest in `route.ts` (~L346–357, COPPA/Missinglettr/
|
||||
Dracula) ships to every user. Derive it from the active project, or replace with a
|
||||
generic "read whatever exists in `.vibncode/specs/`."
|
||||
**Accept:** a fresh project's prompt contains no GetAcquired-specific spec names.
|
||||
|
||||
### T6 — Metering ledger foundation · ⚠️ Opus review
|
||||
**Do:** a per-event usage ledger `{ workspaceId, clientId, projectId, costType,
|
||||
quantity, rawCost, ts }` (blueprint §8.2). Emit an event from every cost-incurring
|
||||
tool (AI tokens, deploys, domains, market research, media, Missinglettr). Build on
|
||||
`lib/quotas.ts`.
|
||||
**Accept:** every billable action writes a ledger row tagged by project; a query
|
||||
can total raw cost per client per period. (Invoicing UI is T16 — not now.)
|
||||
|
||||
---
|
||||
|
||||
## Milestone 1 — Onboarding + dashboard endpoints (implement to the contracts)
|
||||
|
||||
> Contracts: `vibn-frontend/app/(onboarding)/onboarding/onboarding-agency-types.ts`.
|
||||
> Mock to replace: `…/onboarding-agency-mock.ts`.
|
||||
> Flow reminder: onboarding ends at **ideal customer → dashboard**; the targeting
|
||||
> recommendation lives in the **dashboard** (T8), not onboarding. Steps are:
|
||||
> Identity (T7b) → Presence → Ideal Customer (T7) → POST (T9) → Dashboard (T10).
|
||||
|
||||
### T7 — POST /api/agency/analyze-expertise `{ text }`
|
||||
**Returns:** `{ tools: string[] }`. **Do:** an LLM call that maps the consultant's
|
||||
free-text ideal customer / problem description to canonical tool-category
|
||||
labels that match `smb_to_software_mapping` (so they join in T8). The FE mock is
|
||||
`extractTools()` (keyword stub) — replace with the LLM, keep the output shape.
|
||||
**Accept:** "I want to help dentists automate booking" -> `["Appointment Scheduling
|
||||
Software"]` (or better); labels exist in the mapping. Matches them with potential customers in their area, and drives brand awareness.
|
||||
|
||||
### T7b — GET /api/agency/cities?q= (city autocomplete)
|
||||
**Returns:** `CityRef[]` (global). **Do:** proxy **Places API (New) Autocomplete**
|
||||
(`places:autocomplete`, restrict to localities/cities) for predictions, then
|
||||
**Place Details (New)** to resolve each into `CityRef` (name = locality,
|
||||
region = admin area level 1 short name, country + `countryCode` = ISO alpha-2,
|
||||
lat/lng = location). Key stays server-side. The frontend `CityLookup` already
|
||||
calls this and falls back to the seed list when absent.
|
||||
**Accept:** typing "Vic" returns Victoria BC *and* global matches (not a fixed list).
|
||||
|
||||
### T7c — POST /api/agency/places/search `{ name: string, city: CityRef }`
|
||||
**Returns:** `PlaceMatch[]` (top 3 matching businesses).
|
||||
**Do (Three-Stage AI Category Resolver):**
|
||||
1. **Stage 1: Google Places Lookup (Physical Context):**
|
||||
- Query **Places API (New) Text Search** using `GOOGLE_PLACES_API_KEY` with `${name} ${city.name} ${city.region}` to find matching business entities.
|
||||
- Extract: `id`, `displayName`, `formattedAddress`, `primaryType` (e.g., `"health"`), and `types`.
|
||||
2. **Stage 2: DataForSEO Website Lookup (Digital Context):**
|
||||
- If the business has a website, query **DataForSEO's OnPage/Database APIs** (or scrape the URL, falls back to raw query if offline) to retrieve the website's meta-title, description, and domain tags.
|
||||
3. **Stage 3: AI Assessment (The Reasoning Bridge):**
|
||||
- Feed both Stage 1 (Google Places categories/types) and Stage 2 (website title, description, domain tags) into a **Gemini LLM call** (`gemini-3.5-flash`).
|
||||
- **Prompt:**
|
||||
```
|
||||
Google Places details:
|
||||
- Name: {{displayName}}
|
||||
- Primary Type: {{primaryType}}
|
||||
- Types: {{types}}
|
||||
|
||||
Website Details:
|
||||
- Title: {{scrapedTitle}}
|
||||
- Description: {{scrapedDescription}}
|
||||
|
||||
Based on the above, select the single most relevant category ID (gcid) for this business from our canonical mapping list. Return only the raw GCID string (e.g., "gcid:dental_hygienist" or "gcid:plumber").
|
||||
```
|
||||
- Map the returned GCID to one of our 5 baseline categories: `"service"` (trades/FSM), `"appointments"` (scheduling), `"food"` (dining), `"retail"` (pos/shop), or `"events"`.
|
||||
- Retrieve the matched GCID's exact `"softwareNeeds"` list from `smb_to_software_mapping_final.json` and return it in `presetTools`.
|
||||
**Accept:** looking up "Wheely Clean" (which Google labels `"health"`) correctly maps to `"gcid:dental_hygienist"` via AI assessment (by reading their teeth whitening website title), loading their actual dental scheduling, billing, and EHR/EMR custom blocks on Step 2! Shows loading spinner during execution.
|
||||
|
||||
### T8 — POST /api/agency/targets `{ city: CityRef, tools: string[] }` (powers the dashboard)
|
||||
**Returns:** `TerritoryOpportunity[]`, sorted by `opportunityScore` desc.
|
||||
**Do:** intersect `tools` with each SMB type's `softwareNeeds`
|
||||
(`smb_to_software_mapping_final.json`) to pick candidate niches; for each, get
|
||||
**business counts via the Places Aggregate API** (`:computeInsights`,
|
||||
`INSIGHT_COUNT`) filtered by the niche's Places `type` (mapped from `gcid`)
|
||||
within the city's area (circle around `city.lat/lng`). `opportunityScore` =
|
||||
demand × weak/no-software gap × low Vibn saturation, biased by tool-fit. Treat
|
||||
"Reporting / Dashboard Software" as a **universal** need. Mirror `mockTargets`;
|
||||
set `matchedTools` per result.
|
||||
**Accept:** real per-city counts for any city worldwide; `vibnClaimedCount` from
|
||||
our DB; honest numbers only (no fabricated scarcity — blueprint honesty guardrail).
|
||||
|
||||
### T9 — POST /api/agency `AgencyOnboardingResult` `{ profile, expertise, tools }`
|
||||
**Returns:** `{ workspaceSlug }`.
|
||||
**Do (DB Storage Spec):**
|
||||
1. **Workspace row:** Insert a new row into `fs_workspaces`.
|
||||
- `name` = `profile.name`
|
||||
- `slug` = derived slug from `profile.name` (idempotently deduplicated)
|
||||
- Store all metadata inside a structured `agency_onboarding` JSONB field in `fs_workspaces.data` (or related column):
|
||||
```json
|
||||
{
|
||||
"city": {
|
||||
"id": "victoria-bc",
|
||||
"name": "Victoria",
|
||||
"region": "BC",
|
||||
"country": "Canada",
|
||||
"countryCode": "CA",
|
||||
"lat": 48.4284,
|
||||
"lng": -123.3656
|
||||
},
|
||||
"hasWebsite": true,
|
||||
"websiteUrl": "yoursite.com",
|
||||
"hasSocials": true,
|
||||
"hasBlog": false,
|
||||
"hasCustomDomain": false,
|
||||
"hasExistingClients": false,
|
||||
"expertise": "I want to help dentists automate booking",
|
||||
"tools": ["Appointment Scheduling Software"]
|
||||
}
|
||||
```
|
||||
2. **Workspace member row:** Link the signed-in NextAuth user (`userId`) as the `'owner'` of this workspace in `fs_workspace_members`.
|
||||
3. **Provisioning:** Trigger the standard workspace provision pipeline (Gitea org, Coolify project boundary via `lib/workspaces.ts`) asynchronously so the tenant stands up.
|
||||
**Accept:** round-trips the posted result; a new row is created in `fs_workspaces` and `fs_workspace_members`; metadata is saved perfectly in JSONB; and the workspace slug is returned.
|
||||
*(No pitch, no claimed territory — those were removed from onboarding.)*
|
||||
|
||||
### T10 — The dashboard (the screen they land on) · ⚠️ Opus may build
|
||||
**Do:** the agency dashboard at `/[workspace]` (light paper/ink theme). On load,
|
||||
call T7 (analyze-expertise, or use stored `tools`) + T8 (targets for their city)
|
||||
and render the **recommended local businesses to target** (the gold-rush list
|
||||
with businesses / weak-software / claimed + matched-tools chips). Plus clients/
|
||||
prospects, projects, retainer MRR. Claiming a target creates a prospect.
|
||||
**Accept:** lands from onboarding seeded with their ideal-customer description; shows real
|
||||
recommendations; claim → prospect. *(FE craft — likely an Opus task; reuses
|
||||
`extractTools` + `mockTargets` until T7/T8 are live.)*
|
||||
|
||||
### T11 — Wire onboarding + dashboard to the endpoints
|
||||
**Do:** `CityLookup` already calls `GET /api/agency/cities` (T7b) with a seed
|
||||
fallback — just stand up the route. Implement `finishAgency` in `page.tsx` to
|
||||
POST T9 and route to `/[workspace]`. In the dashboard, swap `extractTools`/
|
||||
`mockTargets` for T7/T8. No styling changes to onboarding.
|
||||
**Accept:** the flow runs end-to-end on real data; onboarding behavior unchanged.
|
||||
|
||||
### T12 — Preserve homepage intent through auth
|
||||
**Do:** if the homepage hero captures input, persist it across Google OAuth
|
||||
(localStorage/draft, like `vibn:firstName`) so onboarding resumes seeded.
|
||||
**Accept:** typing on the homepage → sign in → onboarding has the value.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 2 — Design-first delivery (the custom tool)
|
||||
|
||||
### T13 — Ingest the 4 design-kit families
|
||||
**Do:** register `vibn-ai-templates`, `vibn-app`, `vibn-crm`, `vibn-marketplace`
|
||||
(in `design-templates/VIBN (2)/`) into the design-kit registry: one kit per family,
|
||||
themes as overrides; add `DESIGN.md` + `tokens.css` (+ `SKILL.md`) per the existing
|
||||
`lib/scaffold/open-design/design-systems/<id>/` structure.
|
||||
**Accept:** `get_design_template` returns each; they appear on the Design tab.
|
||||
|
||||
### T14 — Build recipe: scaffold-from-kit first
|
||||
**Do:** rewrite the Build mode recipe so building a client's **custom tool**
|
||||
starts from a kit (fork into the client repo) + token reskin, instead of
|
||||
`create-next-app`. The tool is scoped from the consultant's expertise + the
|
||||
client's `softwareNeeds`; SMB domain → family; client brand → accent.
|
||||
**Accept:** a build starts from a polished themed template, not an empty Next app.
|
||||
|
||||
### T17 — Onboarding hardening note (low priority)
|
||||
**Do:** `page.tsx` has pre-existing unused imports (`useState`/`useEffect`/
|
||||
`useMemo` on line 3) flagged as warnings — not from the agency work. Clean up if
|
||||
touching the file.
|
||||
**Accept:** no behavior change.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 3+ — Grow & billing (later)
|
||||
|
||||
### T15 — `missinglettr_*` tool wrapper
|
||||
**Do:** wrap the Missinglettr API (`workspaces.create`, `posts.create`, analytics).
|
||||
Grow mode only. **Accept:** can schedule a multi-platform post; metered (T6).
|
||||
|
||||
### T16 — Stripe retainers + invoicing
|
||||
**Do:** on the metering ledger, roll up cost → apply pricing (retainer / cost-plus /
|
||||
fixed) → Stripe one-off invoice + recurring subscription for retainers.
|
||||
**Accept:** an agency can invoice a client and start a monthly retainer.
|
||||
|
||||
### T18 — Google Business Profile (GMB) OAuth & Token storage
|
||||
**Do:** add `https://www.googleapis.com/auth/business.manage` to the NextAuth Google Provider config.
|
||||
Upon user sign-in, save the authorized OAuth `access_token` and `refresh_token` in `fs_users.data`.
|
||||
On the backend, write a helper to list GMB locations for the authorized user and support posting Google Local Business posts.
|
||||
This is the core engine for automated GBP posting and review management in Grow mode.
|
||||
**Accept:** signing in with Google requests the GMB permission; tokens are securely saved in `fs_users.data` and are queryable by the server.
|
||||
|
||||
### T19 — DataForSEO OnPage API Website Auditor
|
||||
**Do:** implement a backend helper to post and retrieve data from **DataForSEO's OnPage API** (`/v3/on_page/task_post` -> `/v3/on_page/summary`).
|
||||
Extract domain-wide metrics: `domain_info.cms` (to auto-detect what builder they are renting), `domain_info.ssl_info`, `page_metrics.broken_links`, and favicon availability.
|
||||
**⚠️ Hard constraint:** DataForSEO's OnPage crawler strictly requires the target URL to include the protocol (e.g., must be `"https://allardcontractorsltd.com"` or `"http://..."`, NOT a bare domain). Ensure the server-side payload prepends `"https://"` automatically when creating the crawler task.
|
||||
Expose this audit dataset to the dashboard so consultants can auto-generate SEO health audits for their prospects.
|
||||
**Accept:** posting a scan request triggers the DataForSEO crawl; returns unified CMS, SSL, and link metrics; tags them to the client's project row.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
- Don't touch the onboarding `.tsx` files except T11's documented swap.
|
||||
- Keep `onboarding-agency-types.ts` as the contract; if a shape must change, change
|
||||
it there and flag it (the UI depends on it).
|
||||
- Honesty guardrail (T8/T9): never show fabricated market/scarcity numbers.
|
||||
- Flag T1/T2/T3/T6 for an Opus review pass before merge.
|
||||
43
VIBN_ORCHESTRATION_LOOP.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# VIBN Agent Orchestration Loop & State Governor
|
||||
|
||||
This document outlines the Phase-Based Execution Loop architecture that governs all autonomous agent runs in the Vibn workspace.
|
||||
|
||||
## 1. Adaptive Tool Budgets (Intent Classification)
|
||||
The global `MAX_TOOL_ROUNDS = 150` is a necessary safety net, but allowing a simple "why is the preview blank?" query to run 150 tools is a UX failure.
|
||||
When a user prompt is received, we classify its intent and assign a strict tool budget:
|
||||
* **`conversational`** (Budget: 0) — Greetings, affirmations.
|
||||
* **`status_check`** (Budget: 2) — "What is running?", "Show me the logs."
|
||||
* **`diagnose`** (Budget: 8) — "Why is the preview blank?", "The build failed."
|
||||
* **`small_fix`** (Budget: 15) — "Change the header color", "Fix the typo."
|
||||
* **`feature_build`** (Budget: 40) — "Add a pricing page", "Wire up Stripe."
|
||||
* **`autonomous`** (Budget: 150) — "Build this entire app from scratch", "Keep going."
|
||||
|
||||
## 2. Phase-Based Execution State Machine
|
||||
An agent turn no longer has access to all tools at all times. It transitions through a strict state machine:
|
||||
1. **`recon`**: Gathering context. Only non-mutating tools allowed (`fs_read`, `dev_server_logs`, `browser_console`).
|
||||
2. **`checkpoint`**: A mandatory pause where the agent must state its findings, goal, and proposed action *before* it is granted write access.
|
||||
3. **`execute`**: Mutating tools unlocked (`fs_edit`, `shell_exec`, `dev_server_start`).
|
||||
4. **`verify`**: Post-mutation testing. The agent must successfully run a compilation check or visual QA before claiming success.
|
||||
5. **`final`**: Synthesis and user response.
|
||||
|
||||
## 3. Tool Classification & Filtering
|
||||
Tools in `lib/ai/vibn-tools.ts` are heavily categorized:
|
||||
* **Read-Only**: `fs_read`, `fs_list`, `fs_grep`, `dev_server_list`, `dev_server_logs`, `projects_get`
|
||||
* **Mutating**: `fs_write`, `fs_edit`, `fs_delete`, `shell_exec`
|
||||
* **Verification**: `browser_console`, `request_visual_qa`
|
||||
|
||||
If an agent in the `recon` phase attempts a mutating tool, the loop intercepts the call, blocks execution, and injects a recovery prompt demanding a Checkpoint first.
|
||||
|
||||
## 4. Forced Verification Gates
|
||||
Before the loop can naturally terminate and present the "Done" state to the user, the governor checks:
|
||||
* Did the agent mutate files (`fs_write`, `fs_edit`)?
|
||||
* If yes, did the agent run `browser_console` or `dev_server_start` after the last edit?
|
||||
* If no, the final response is rejected and a system prompt forces the agent to verify the build before concluding.
|
||||
|
||||
## 5. UI Event Telemetry
|
||||
The backend streams rich SSE events to the frontend Chat Panel:
|
||||
* `data: {"type": "phase", "phase": "recon", "label": "Investigating Codebase"}`
|
||||
* `data: {"type": "checkpoint", "goal": "...", "findings": "..."}`
|
||||
* `data: {"type": "budget", "used": 5, "limit": 15}`
|
||||
|
||||
This replaces the "silent black box" with an engaging, highly transparent glass-box UI.
|
||||
433
VIBN_PRODUCT_BLUEPRINT.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# Vibn — Product Blueprint & Go-to-Market Architecture
|
||||
|
||||
> Status: Draft v3 · Owner: Mark · Last updated: 2026-06-04
|
||||
> v3 sharpens the wedge to **custom tools** for local businesses (not
|
||||
> websites/marketing), makes onboarding **expertise-first → dashboard** (no pitch
|
||||
> generator), and moves the targeting/"gold rush" recommendation into the
|
||||
> dashboard. v2 (services+margins+pitch onboarding) is superseded; v1 (founder/
|
||||
> build-first) before it.
|
||||
>
|
||||
> Implemented so far (FE, against mocks + typed contracts): the contractor
|
||||
> onboarding flow — `app/(onboarding)/onboarding/onboarding-agency*.{tsx,ts}` +
|
||||
> the front-door fork in `page.tsx`. Backend + dashboard pending (handoff doc).
|
||||
|
||||
---
|
||||
|
||||
## 1. Positioning
|
||||
|
||||
**Vibn is the operating system for a new breed of local-business consultant — it
|
||||
helps them find local SMBs, build them the *custom tools* they actually need
|
||||
(without writing code), and bill for it profitably — so those businesses stop
|
||||
paying for a stack of expensive SaaS apps that don't talk to each other.**
|
||||
|
||||
The wedge is **custom tools, not websites/marketing.** Every local business is
|
||||
overpaying for generic SaaS that half-fits; the consultant builds one tool that
|
||||
fits their workflow exactly.
|
||||
|
||||
Think **Harvest for AI vibe coding**: the place a consultant runs the whole
|
||||
client business — find, build, host, invoice.
|
||||
|
||||
Two audiences, one engine — but a clear hero:
|
||||
- **PRIMARY · The new consultant / freelancer / small studio.** Often not a deep
|
||||
engineer (a marketer, designer, or hustler starting a "websites + marketing for
|
||||
local business" practice). Vibn is their unfair advantage. **They are the buyer.**
|
||||
- **SECONDARY · The SMB owner doing it themselves.** Same engine, no markup. Served
|
||||
by self-serve, not chased.
|
||||
- **De-emphasized:** startup founders. That lane (Lovable, v0, Bolt, Replit) is a
|
||||
bloodbath and is *not* where our infrastructure points.
|
||||
|
||||
### Why this wedge (the infra already leans here)
|
||||
- `market_categories_suggest` returns **Google Business Profile** categories — a
|
||||
*local business* construct.
|
||||
- `market_research_run` pulls local **business leads, TAM, competitors** (DataForSEO).
|
||||
- Missinglettr lists **Google Business** among its 12 platforms — local social + GBP.
|
||||
- The "owner" persona pitch is *"replace the stack of tools you rent"* — SMB ops.
|
||||
- The Cadence CRM template = contacts + scheduling; domains + Stripe = every SMB.
|
||||
|
||||
None of this was built for startups. It was built for **local SMBs and the people
|
||||
who serve them.** This is a focus, not a pivot.
|
||||
|
||||
---
|
||||
|
||||
## 2. The two front doors
|
||||
|
||||
Opposite motivations require opposite openings:
|
||||
|
||||
| Door | Who | First question they're asking | Opening |
|
||||
|---|---|---|---|
|
||||
| **"Personal"** | SMB owner / self-builder | *"Can I see my thing built?"* | **Build-first** — straight to a live themed preview (§6) |
|
||||
| **"Agency"** (HERO) | New consultant | *"Can I do this? What do I build? Who needs it? How do I get a client?"* | **Contractor-first** — set up the agency, state your expertise, land in a dashboard of local targets (§4) |
|
||||
|
||||
The homepage leads with the consultant promise and routes self-builders to the
|
||||
simpler path — it does **not** treat them as equals.
|
||||
|
||||
> Reversal from v1: "get to a live preview ASAP" is right for a self-builder and
|
||||
> *wrong* for a consultant. A consultant is evaluating a **business**, not a build
|
||||
> tool. Lead with the contractor parts; building happens later, per client.
|
||||
|
||||
---
|
||||
|
||||
## 3. The lifecycle = one client engagement
|
||||
|
||||
For the consultant, the product lifecycle is the shape of **a single client
|
||||
engagement**, run once per SMB client:
|
||||
|
||||
```
|
||||
DISCOVER → BUILD → REFINE → GROW
|
||||
the pitch deliver iterate the retainer
|
||||
(win it) the site with client (recurring $)
|
||||
```
|
||||
|
||||
It's driven by two orthogonal axes (kept distinct in code — today they're conflated):
|
||||
|
||||
| Axis | Controls | Decided |
|
||||
|---|---|---|
|
||||
| **Engagement stage** | The *path* (research/pitch vs build vs grow) | Per client, by where the deal is |
|
||||
| **SMB domain** (trades, salon, dental, food, fitness…) | The *look* — template family + theme | Inferred from the business type |
|
||||
|
||||
A consultant runs many clients at different stages simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## 4. Contractor-first onboarding — "Set up your AI agency"
|
||||
|
||||
Onboarding is short and has one job: learn **who the consultant is** and **what
|
||||
they love building**. The moment we know their sweet spot, we drop them into
|
||||
their **dashboard** — where the local-business recommendations live as an
|
||||
ongoing feature (not a one-shot screen). No pitch generator, no terminal
|
||||
targeting screen in onboarding. *(Implemented: `app/(onboarding)/onboarding/onboarding-agency.tsx`.)*
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["1 · Your agency<br/>name · city (Places lookup)"] --> B["2 · Your presence<br/>what does your agency have today? (checklist)"]
|
||||
B --> C["3 · Your ideal customer (free text)<br/>'who / what problem do you want to solve?'"]
|
||||
C --> D["Open my dashboard →"]
|
||||
D --> E["DASHBOARD<br/>AI reads description → recommends local businesses to target"]
|
||||
```
|
||||
|
||||
- **Agency** option details: "I want to do billable AI work for others" (VIBN helps you find local businesses that you can build custom solutions for).
|
||||
- **Personal** option details: "I want to build my own ideas" (Go from idea to market, and beyond).
|
||||
|
||||
### Step contents
|
||||
1. **Your agency** — name, **city** (global Places-API lookup, §6.x).
|
||||
2. **Your presence** — "What does your agency have today?" Checklist of assets
|
||||
(Website, social media accounts, blog, custom domain, existing billing).
|
||||
Light profiling to customize their dashboard experience.
|
||||
3. **Your ideal customer** *(the heart of it)* — a **free-text** box: *"Is there a
|
||||
certain type of business or business problem you are passionate about?"*
|
||||
(e.g. "I want to help dentists automate patient booking"). If they are undecided,
|
||||
clicking **"I'm not sure right now"** bypasses the step with a neutral default
|
||||
("help any local business automate workflows"). This replaces the old examples list.
|
||||
Vibn will help them match with potential customers in their area, and drive awareness of their brand.
|
||||
4. **→ Dashboard.** CTA "Open my dashboard →" finishes onboarding with
|
||||
`{ profile, expertise, tools }` (AI-extracted tool categories, where `expertise`
|
||||
holds the ideal-customer string).
|
||||
|
||||
### The Local Business Category Lookup & Mapping Pipeline (onboarding)
|
||||
This is the core "Business Identity & Needs" pipeline run on Step 1 of the self-builder flow, designed to bypass Google's messy administrative category labels:
|
||||
1. **Step 1: City Geocoding & Radius Setup:**
|
||||
- The user selects their city in Step 1. The frontend retrieves their structured `CityRef` (holding `lat`/`lng` coordinates from Google Places) and sets a default radial search geofence of **50km**.
|
||||
2. **Step 2: Geofenced DataForSEO Business Search:**
|
||||
- The backend takes the business name (or URL) and queries the **DataForSEO Business Listings Search API** (`/v3/business_data/business_listings/search/live`) using geofenced `"location_coordinate"` radial search:
|
||||
`"location_coordinate": "{{lat}},{{lng}},50"`
|
||||
- This bypasses Google's strict SAB restrictions, pulling down the full business records (including mobile businesses with hidden addresses like "Wheely Clean Mobile Dental").
|
||||
- The server extracts GMB's main `"category"` and `"additional_categories"` arrays.
|
||||
- It joins them to our `smb_to_software_mapping_final.json` dataset (the 4,006-item database) to fetch their exact, customized software tool requirements.
|
||||
3. **Step 3: Unpacked Category Card Selection ("Which best describes your business?"):**
|
||||
- The frontend receives the matched business, and automatically unpacks **both its primary category and all discovered GMB alternative categories** into individual clickable cards.
|
||||
- The screen displays: **"Which best describes your business?"**
|
||||
- Selecting any card (e.g. *Dental hygienist* or *Teeth whitening service*) instantly loads that specific subcategory's custom presets and advances to Step 2!
|
||||
- *Fallback:* If DataForSEO or geocoding fails (e.g. offline dev), it gracefully triggers Google Places Text Search (New) + Gemini 2.5 Flash as an automated fallback reasoning bridge.
|
||||
|
||||
### The targeting engine (lives in the dashboard)
|
||||
|
||||
### The dashboard (the home screen they land on)
|
||||
- **Recommended targets** — the AI's local-business recommendations (above),
|
||||
refreshable; claim one to start a client/prospect.
|
||||
- **Clients / prospects** — each SMB; status (prospect → won → live → retainer).
|
||||
- **Projects** — per client (a custom tool build + hosting/support retainer).
|
||||
- **Revenue & margin** — what each client costs me vs. what I bill; retainer MRR
|
||||
(illustrative until metering lands, then live).
|
||||
- Building a client's tool is entered **from a client**, not the dashboard root.
|
||||
- *(To build next — reuses `extractTools` + `mockTargets`; light paper/ink theme.)*
|
||||
|
||||
---
|
||||
|
||||
## 5. Modes — capability surfaces with enforced tool gating
|
||||
|
||||
Three modes (capability surfaces, not vibes). **Refine is not a mode** — it's Build
|
||||
against an already-live project.
|
||||
|
||||
| Mode | Engagement role | Stop condition |
|
||||
|---|---|---|
|
||||
| **Collab** | Discover — research + pitch + spec | PRD + decisions + backlog (the spine) |
|
||||
| **Build** | Deliver the site + refine | A clickable preview / shipped `fqdn` |
|
||||
| **Grow** | The retainer — distribute + monitor | Scheduled content + live analytics |
|
||||
|
||||
### 5.1 Tool gating is enforced, not described
|
||||
Today "DO NOT WRITE CODE" is a prompt *request* while `fs_write`/`ship`/`shell_exec`
|
||||
stay in the tool list → the constraint is soft and the prompt re-teaches the
|
||||
forbidden workflow. Fix: one `MODE_TOOLS: Record<Mode, ToolName[]>` map, read by:
|
||||
1. **Prompt builder** — filters exposed tool schemas per mode (model can't see what
|
||||
it can't call; also cuts ~88 schemas → ~20–30 = token/latency win).
|
||||
2. **Dispatcher** — rejects out-of-mode calls (guards hallucinated names).
|
||||
Applied in **both** `vibn-frontend/app/api/chat/route.ts` and the agent-runner.
|
||||
|
||||
### 5.2 Allowlist sketch
|
||||
| Capability | Collab | Build | Grow |
|
||||
|---|---|---|---|
|
||||
| Reads (`projects_get`, `apps_*` reads, `get_design_template`) | ✅ | ✅ | ✅ |
|
||||
| Research (`market_*` 💲, `github_*`, `http_fetch`) | ✅ | ❌ | ✅ (seo/insights) |
|
||||
| Spine docs — `fs_*` **scoped to `.vibncode/specs/`** | ✅ | ✅ (+ repo) | ✅ (blog/SEO) |
|
||||
| Design kit / `apps_templates_scaffold` | propose | ✅ full | theme marketing |
|
||||
| Engineering (`shell_exec`, `dev_server_*`, `apps_create`, `ship`, `databases_*`) | ❌ | ✅ | `apps_create { repo }` only |
|
||||
| Distribution (`missinglettr_*`, `generate_media`) | ❌ | ❌ | ✅ |
|
||||
| Destructive (`*_delete`, `apps_volumes_wipe`) | ❌ | ⚠️ confirm | ❌ |
|
||||
|
||||
Gating gives each guardrail a home: **money gate** in Collab, **destructive-confirm**
|
||||
in Build, **untrusted-content rule** in BASE (Collab + Grow read the open web).
|
||||
|
||||
### 5.3 Prompt composition
|
||||
```
|
||||
BASE identity · voice · spine/task-ledger contract · infra model · hard rules ·
|
||||
untrusted-content rule · project + client/agency state
|
||||
+
|
||||
MODE { Collab | Build | Grow } — behavior + stop condition + protocols
|
||||
+
|
||||
CONTEXT design kit · decisions/backlog · stage seed · SMB-domain template guidance
|
||||
```
|
||||
BASE + MODE must be **shared modules imported by both** the chat route and the
|
||||
agent-runner (today there are three drifted copies — root of the "loops on task 1"
|
||||
bug).
|
||||
|
||||
### 5.4 Visibility
|
||||
Modes **auto-select** by stage + project state. The toggle remains a power-user
|
||||
override. Nobody picks a mode manually.
|
||||
|
||||
---
|
||||
|
||||
## 6. Build-first door & design-first delivery
|
||||
|
||||
The build flow is no longer the front door — but it's still how work gets
|
||||
*delivered* (and how a self-builder enters). It must be **design-first, not
|
||||
code-first.**
|
||||
|
||||
- **Stop** scaffolding from `create-next-app` + hand-building UI (slow, generic,
|
||||
the source of visual-QA loops).
|
||||
- **Start** from a polished, CSS-variable-themed template family, reskin to the
|
||||
SMB's brand via the design-kit token system, then populate content.
|
||||
|
||||
### 6.1 Template families (assets in `design-templates/VIBN (2)/`)
|
||||
| Family | Use | SMB domain |
|
||||
|---|---|---|
|
||||
| `vibn-ai-templates` | Shared base library (components + 4 themes) | foundation |
|
||||
| `vibn-app` | Marketing / landing / lead capture + payments | most local SMB sites |
|
||||
| `vibn-crm` (Cadence) | Ops: contacts, scheduling, dashboard | trades, salons, clinics |
|
||||
| `vibn-marketplace` (Atlas) | Listings / booking / two-sided | directories, multi-vendor |
|
||||
|
||||
Themes (`minimal` / `dark` / `glass` / `editorial`) + accent come from the SMB's
|
||||
brand. **Demos must look visibly local-SMB** (a plumber, a salon, a dental office),
|
||||
never a generic SaaS dashboard.
|
||||
|
||||
### 6.2 Wired vs. to-build
|
||||
- ✅ Design-kit registry, Design tab, token injection, `apps_templates_scaffold`, `get_design_template`.
|
||||
- ❌ The four families ingested as registered kits (`DESIGN.md` + `tokens.css` + `SKILL.md`).
|
||||
- ❌ Build recipe rewritten to "scaffold-from-kit first."
|
||||
- ❌ Build entered per client from the console; SMB brand → kit selection.
|
||||
- ❌ Real session streamed to a real themed preview (today: `setTimeout` animation + fake URL, answers discarded).
|
||||
|
||||
### 6.3 Fork, don't depend
|
||||
Fork the kit into the client's repo (the READMEs say "fork it") — self-contained,
|
||||
fully editable.
|
||||
|
||||
---
|
||||
|
||||
## 7. Grow — the retainer (Missinglettr)
|
||||
|
||||
Grow is the consultant's **recurring revenue.** Once a client's site is live, the
|
||||
consultant runs their marketing as a monthly retainer.
|
||||
|
||||
- **Missinglettr API** = one API to post/schedule across 12 platforms (incl. Google
|
||||
Business) with shortening, analytics, webhooks. The engine of Grow.
|
||||
- **My Business Business Information API (GMB)** = used specifically via OAuth 2.0 to manage verified locations, publish Google Local Business posts, and retrieve and reply to reviews (reputation management). Combined with Missinglettr, it forms the core Grow suite.
|
||||
- **DataForSEO OnPage API Website Auditor** = crawlers that fetch a client's website and return full on-page diagnostics: `cms` auto-detection (Wix, WordPress, Squarespace), SSL status, mobile responsiveness, and broken link counts.
|
||||
- Capabilities: AI-generated social + blog + **local SEO pages**, styled to match
|
||||
the client's design kit, scheduled via Missinglettr; reviews; analytics reporting.
|
||||
- Existing blocks: `market_seo_analyze`, `generate_media`, `project_recent_errors`
|
||||
(monitoring), and `vibn-attribution-package` (UTM → first-touch attribution).
|
||||
- To build: `missinglettr_*` tool wrapper, content/SEO generation, analytics loop,
|
||||
and a **client-facing monthly report** (the retainer's visible value).
|
||||
|
||||
---
|
||||
|
||||
## 8. Agency / billing layer — "Harvest for AI vibe coding" (CORE, not a fast-follow)
|
||||
|
||||
For the consultant ICP, **getting paid is half the value prop.** It cannot be an
|
||||
afterthought. The most important primitive is the **monthly retainer** (the Grow
|
||||
fee = the consultant's MRR), not just the one-off project invoice.
|
||||
|
||||
### 8.1 Cost sources (all metered, tagged by client/project)
|
||||
| Cost type | Source |
|
||||
|---|---|
|
||||
| AI usage (tokens) | `lib/ai/llm-client.ts` |
|
||||
| Compute / infra (apps, dev containers, DBs) | Coolify / `lib/dev-container.ts` |
|
||||
| Domains | `domains_register` / `lib/opensrs.ts` |
|
||||
| Market research 💲 | `market_research_run` (DataForSEO) |
|
||||
| Distribution | Missinglettr (usage / subscription) |
|
||||
| Media | `generate_media` |
|
||||
|
||||
### 8.2 Components
|
||||
1. **Metering ledger** — `{ workspaceId, clientId, projectId, costType, quantity,
|
||||
rawCost, ts }`. Seeded by `lib/quotas.ts` + telemetry. Every cost-incurring tool
|
||||
emits an event.
|
||||
2. **Client ↔ project mapping** — agency workspace holds many client projects.
|
||||
3. **Pricing engine** — per client: **retainer** (recurring), cost-plus markup %,
|
||||
or fixed project price. Roll up raw cost → apply pricing → billable.
|
||||
4. **Invoicing & retainers** — Stripe (in stack): one-off invoices **and recurring
|
||||
subscriptions** for retainers; optional client statement/portal.
|
||||
5. **In-agent cost transparency** — AI surfaces estimated cost *before* spending
|
||||
(the money-gate guardrail as a platform concept); every spend is attributable.
|
||||
|
||||
### 8.3 Consequence
|
||||
Accurate per-client cost accounting is needed regardless of when invoicing UI ships,
|
||||
so **the metering ledger is launch-foundation work** — retrofitting attribution is
|
||||
painful. Onboarding's "your margins" can be *illustrative* until metering is live,
|
||||
provided the numbers are honestly labeled.
|
||||
|
||||
---
|
||||
|
||||
## 9. The spine — single source of truth
|
||||
|
||||
**`.vibncode/specs/*.md` (markdown) is the law.** Retire DB `plan_*` tools (or make
|
||||
them thin markdown writers). The desktop Interactive Backlog already reads markdown;
|
||||
the session runner already toggles checkboxes; it's git-tracked; it's the artifact
|
||||
the user sees and edits.
|
||||
- Task state = `- [ ]` / `- [x]`; all brains obey this.
|
||||
- `.vibncode/` **must be committed and never removable by `git clean -fd`** (the
|
||||
earlier unattended-loop bug).
|
||||
- The **design kit travels in the spine** — styling source of truth from Build → Grow.
|
||||
- The spine carries the engagement: Discover writes the spec/pitch, Build consumes
|
||||
it + adds the kit, Grow reads product + kit to generate matching marketing.
|
||||
|
||||
---
|
||||
|
||||
## 10. Hardening (from the prompt/tool audit)
|
||||
|
||||
- **Phantom tools:** `apps_envs_set`→`apps_envs_upsert`; `apps_containers_list`→
|
||||
`apps_containers_ps`; `plan_decision_log` (Architect; doesn't exist) → remove.
|
||||
- **Template-literal leak:** `route.ts` ~L306 escaped `\${activeProject.slug}` →
|
||||
un-escape so it interpolates.
|
||||
- **Task-tracking civil war:** route vs shared-body vs runner disagree → markdown
|
||||
checkboxes everywhere (§9).
|
||||
- **Hardcoded project specs:** route ships one project's private spec list
|
||||
(COPPA / Missinglettr / Dracula) to every user → generalize/derive.
|
||||
- **Architect contradiction:** solved by mode gating (§5) + composition (§5.3).
|
||||
- **Sentry snippet path** unreachable from dev container → inline / ship in scaffold.
|
||||
- **`request_visual_qa` "Always"** → "for UI work."
|
||||
- **Infra clarity:** add infra model + first-deploy recipe (resolve `ship` "if
|
||||
linked" + `ship` vs `apps_create { repo }`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Go-to-market sequencing
|
||||
|
||||
| Milestone | Scope | Why |
|
||||
|---|---|---|
|
||||
| **0 · Foundation** | Spine = markdown everywhere; BASE+MODE shared modules; `MODE_TOOLS` gating; phantom/leak fixes; de-contaminate specs; **metering ledger** | Nothing safe until brains agree on the ledger; metering before any spend |
|
||||
| **1 · Contractor onboarding (launch front door)** | "Start an AI agency" flow: opportunity → identity (Places city lookup) → presence → **free-text expertise** → dashboard. ✅ FE built against mocks | What the hero ICP evaluates first; short and contained |
|
||||
| **2 · The dashboard + targeting** | Land them in the dashboard; AI extracts tools from expertise (`analyze-expertise`) → recommends local businesses to target (`targets` via Places Aggregate); claim → prospect | The "gold rush" payoff + ongoing home |
|
||||
| **3 · Deliver (design-first build)** | Ingest 4 kits → kit-first build recipe → build the client's **custom tool** per client → real preview | Turns a claimed target into a delivered tool |
|
||||
| **4 · Grow retainer + Missinglettr** | `missinglettr_*`, content/local-SEO, analytics loop, client monthly report | Recurring-revenue hook |
|
||||
| **5 · Full billing** | Retainers + one-off invoicing (Stripe) + client statements on the metering ledger | Completes "bill for it" |
|
||||
|
||||
**Launch line:** Milestones 0 + 1 + 2 (onboarding → dashboard with real
|
||||
recommendations) + a slice of 3 (one custom tool built end-to-end). Grow/billing
|
||||
shown as the model, delivered next.
|
||||
|
||||
**Build status:** onboarding FE (steps 1–4) is built and compiles clean against
|
||||
mock data + typed contracts. Backend endpoints + the dashboard are next (see
|
||||
`VIBN_HANDOFF_TICKETS.md`).
|
||||
|
||||
---
|
||||
|
||||
## 12. The journeys (consultant serving a local SMB)
|
||||
|
||||
### Sofia — the new consultant (the hero path)
|
||||
Sofia wants to start a side-business building custom tools for local businesses.
|
||||
She signs up, selects the "Agency" option, names it, sets her city (a global Places lookup),
|
||||
and declares what assets she has today. Then the
|
||||
one question that matters: *"Is there a certain type of business or business problem you are passionate about?"* She types
|
||||
"I want to help local dentists automate patient booking" and clicks **Open my dashboard**. She's
|
||||
in. Her dashboard already shows local businesses that fit — salons, gyms, auto
|
||||
shops, dentists — each flagged as stuck on disconnected SaaS and ripe for one
|
||||
custom tool. She hasn't done any work and already has a target list.
|
||||
|
||||
### Joe's Plumbing — the client gets a custom tool (Build)
|
||||
Sofia claims "plumbers," picks Joe as a client, and builds him a custom
|
||||
scheduling + invoicing + reporting tool — one app around his workflow, replacing
|
||||
the three half-fitting SaaS subscriptions he was paying for. Design-first from a
|
||||
kit, in his colors, live in a day instead of weeks.
|
||||
|
||||
### The SMB owner — self-serve (secondary door)
|
||||
An owner who finds Vibn directly selects the "Personal" option, enters their business name, city, and optional website, gets their business type auto-analyzed by the AI, and goes straight to building:
|
||||
### The retainer — recurring revenue (Grow)
|
||||
With Joe live, Sofia adds a marketing retainer. Vibn generates his Google Business
|
||||
posts, a couple of local-SEO pages, and social content in his brand voice, schedules
|
||||
it across platforms via Missinglettr, and produces a monthly report Sofia forwards to
|
||||
Joe. Joe sees leads; Sofia bills a recurring fee.
|
||||
|
||||
### Getting paid (Billing)
|
||||
Every cost on Joe's projects — AI, hosting, his domain, the market research, the
|
||||
social scheduling — is metered and attributed. Sofia applies her markup, and Vibn
|
||||
turns the build into a one-off invoice and the marketing into a recurring Stripe
|
||||
subscription. She's not just delivering work; she's running a business with real margin.
|
||||
|
||||
### The SMB owner — self-serve (secondary door)
|
||||
An owner who finds Vibn directly skips the agency setup and goes build-first:
|
||||
"a booking tool for my salon" → a quick look confirm → watches it build → lands
|
||||
in the chat refining it. Same engine, no markup, no dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open decisions
|
||||
1. ~~**Front-door fork**~~ — **DECIDED:** explicit "Agency" vs "Personal" (neutral presentation, no pre-selection bias).
|
||||
2. **Targeting spend** — the dashboard's recommendations need per-city counts
|
||||
(Places Aggregate API) and may touch paid `market_research_run`. Use a
|
||||
free/cached read for the first dashboard view; gate any paid run on consent.
|
||||
3. **`MODE_TOOLS`** as the single source of truth for prompt + dispatcher. (Lean: yes.)
|
||||
4. **Collab write scope** path-scoped to `.vibncode/`. (Lean: yes.)
|
||||
5. **Kit ingestion** — one kit per family, themes as overrides. (Lean: yes.)
|
||||
6. **Pricing model first** — retainer vs cost-plus vs fixed: which to ship first.
|
||||
(Lean: retainer + simple markup.)
|
||||
7. **Launch SMB vertical** — which single domain to polish end-to-end first
|
||||
(trades? salon? dental?).
|
||||
8. ~~**Core brand color**~~ — **DECIDED** (see §14).
|
||||
|
||||
---
|
||||
|
||||
## 14. Design system (aligned — build everything to this)
|
||||
|
||||
The canonical visual spec. All net-new frontend is built to it; the cheaper model
|
||||
inherits it too.
|
||||
|
||||
- **Foundation: warm paper-and-ink** (the differentiator). Product is light
|
||||
(`--vibn-paper` bg, `--vibn-ink` text, the warm-neutral ramp); marketing may be
|
||||
dramatically dark.
|
||||
- **One brand color: matured clay-coral**, threaded through *both* marketing and
|
||||
product, used **sparingly** (primary action, active state, brand mark, progress,
|
||||
the single "next thing"). No second chromatic color.
|
||||
- Tokens in `app/globals.css`: `--vibn-coral` `oklch(0.68 0.16 35)` (actions),
|
||||
`--vibn-coral-hover`, `--vibn-coral-glow` (the brighter `0.74 0.175 35` for
|
||||
glow/focus), `--vibn-coral-soft`, `--vibn-coral-fg`.
|
||||
- Added **additively** — existing `--primary`/`--accent` not rewired blind;
|
||||
new surfaces use the coral tokens directly and get visual QA.
|
||||
- **Hard rule:** Vibn chrome accent (coral) ≠ a client app's accent. The product
|
||||
chrome is coral; each *built client site* uses its own design-kit accent.
|
||||
- **Two contexts:** onboarding wizard = dark + coral (existing primitives in
|
||||
`app/(onboarding)/onboarding/onboarding-primitives.tsx`); product/console =
|
||||
light paper/ink + coral.
|
||||
- **Client builds** come from the 4 design-kit families (base + app/crm/
|
||||
marketplace), CSS-var themed (minimal/dark/glass/editorial), forked into the
|
||||
client repo. Demos must look visibly local-SMB.
|
||||
- **Type:** product = Inter (`--font-inter`); editorial moments may use a serif
|
||||
display per the kit.
|
||||
162
ai-new-thread.md
@@ -39,9 +39,11 @@ graph TD
|
||||
|
||||
## 2. Directory Structure & Individual Git Repositories
|
||||
|
||||
Your local directory `master-ai` is a **unified workspace** housing folders that map directly to **individual, independent repositories on Gitea** (`https://git.vibnai.com/mark`).
|
||||
> **`master-ai` is a LOCAL development workspace on Mark's Mac. It does not exist in production and is never accessed by any running cloud service.** Production runs entirely from the individual Gitea repositories → Coolify builds → running containers on `34.19.250.135`. Once a change is pushed to the matching Gitea remote, `master-ai` is completely out of the picture.
|
||||
|
||||
DO NOT treat `master-ai` as a single monorepo on Gitea. You must push changes inside specific directories to their matching Gitea remote targets.
|
||||
The local `master-ai` directory houses folders that each map to an **independent Gitea repository**. The `master-ai` git repo itself is just a convenience — a single place to commit and track changes across all sub-projects before pushing each one to its own Gitea remote.
|
||||
|
||||
DO NOT treat `master-ai` as a single monorepo on Gitea — it is not deployed as one. You must push changes inside specific directories to their matching Gitea remote targets.
|
||||
|
||||
```
|
||||
/Users/markhenderson/master-ai/ <-- Local Parent Directory
|
||||
@@ -51,16 +53,55 @@ DO NOT treat `master-ai` as a single monorepo on Gitea. You must push changes in
|
||||
│ Remote 'coolify_agent_gitea' -> https://git.vibnai.com/mark/vibn-agent-runner.git
|
||||
├── vibn-frontend/ <-- Subfolder of master-ai. Pushes via:
|
||||
│ Remote 'coolify_gitea' -> https://git.vibnai.com/mark/vibn-frontend.git
|
||||
└── vibn-api/ <-- Subfolder of master-ai. Pushes via:
|
||||
Remote 'coolify_api_gitea' -> https://git.vibnai.com/mark/vibn-api.git
|
||||
├── vibn-api/ <-- Subfolder of master-ai. Pushes via:
|
||||
│ Remote 'coolify_api_gitea' -> https://git.vibnai.com/mark/vibn-api.git
|
||||
└── vibn-telemetry-service/ <-- Subfolder of master-ai (Training Data Microservice). Pushes via:
|
||||
Remote 'coolify_telemetry_gitea' -> https://git.vibnai.com/mark/vibn-telemetry-service.git
|
||||
```
|
||||
|
||||
### Git Remotes Reference (Configured in `/Users/markhenderson/master-ai`):
|
||||
* `coolify_agent_gitea` : `https://git.vibnai.com/mark/vibn-agent-runner.git`
|
||||
* `coolify_gitea` : `https://git.vibnai.com/mark/vibn-frontend.git`
|
||||
* `coolify_api_gitea` : `https://git.vibnai.com/mark/vibn-api.git`
|
||||
* `gitea` : `https://git.vibnai.com/mark/master-ai.git`
|
||||
* `origin` : `https://github.com/MawkOne/master-ai.git`
|
||||
### Git Remotes Reference (local Mac remotes — these exist only on Mark's machine):
|
||||
|
||||
These are git remotes configured in the local `master-ai` repo. They are the **one-way bridge** between local development and production. Production Coolify services pull directly from the Gitea URLs; they have no knowledge of `master-ai`.
|
||||
|
||||
| Remote | Gitea URL | What it deploys |
|
||||
|---|---|---|
|
||||
| `coolify_gitea` | `https://git.vibnai.com/mark/vibn-frontend.git` | vibn-frontend (Next.js platform) |
|
||||
| `coolify_agent_gitea` | `https://git.vibnai.com/mark/vibn-agent-runner.git` | vibn-agent-runner |
|
||||
| `coolify_api_gitea` | `https://git.vibnai.com/mark/vibn-api.git` | vibn-api |
|
||||
| `coolify_telemetry_gitea` | `https://git.vibnai.com/mark/vibn-telemetry-service.git` | vibn-telemetry-service |
|
||||
| `gitea` | `https://git.vibnai.com/mark/master-ai.git` | *(share-only — coworker local setup; **builds do NOT use this**)* |
|
||||
| `origin` | `https://github.com/MawkOne/master-ai.git` | *(GitHub mirror only — not used by Coolify)* |
|
||||
|
||||
**The full deploy lifecycle:**
|
||||
```
|
||||
Local Mac (master-ai) → git push <remote> HEAD:main → Gitea repo → Coolify build → Production
|
||||
↑ ↑
|
||||
master-ai ends here Production begins here
|
||||
```
|
||||
|
||||
1. Make changes in `master-ai/vibn-frontend/` (or whichever subfolder).
|
||||
2. `git commit` in `master-ai`.
|
||||
3. `git push coolify_gitea HEAD:main` (or relevant remote) — **this is the complete hand-off**.
|
||||
4. Coolify detects the push, builds a Docker image from the Gitea repo, and deploys it.
|
||||
5. `master-ai` is no longer involved. Production runs entirely from the Gitea repo + Coolify.
|
||||
|
||||
`vibn-code` is a nested submodule with its own `.git` — commit & push it via its own `origin`.
|
||||
Secret `.env*` files at the repo root are gitignored — never commit them.
|
||||
|
||||
**⚠️ NEVER use `git subtree push` for these remotes.** Coolify is configured with `vibn-frontend` as its **base directory**, so it expects the full `master-ai` repo structure at the Gitea root and resolves the Dockerfile at `vibn-frontend/Dockerfile`. A subtree push flattens the repo to just the subfolder contents, making `vibn-frontend/` disappear and breaking the build with `open Dockerfile: no such file or directory`. Always use:
|
||||
```bash
|
||||
git push <remote> HEAD:main # normal
|
||||
git push <remote> HEAD:main --force # if remote has diverged
|
||||
```
|
||||
|
||||
### Deploying the Telemetry Service manually via Coolify UI:
|
||||
Because Coolify's API strictly blocks the programmatic creation of GitHub/Gitea Apps, the Telemetry service must be linked manually once:
|
||||
1. Open [Coolify Dashboard -> vibn-infrastructure -> production](https://coolify.vibnai.com/project/f4owwggokksgw0ogo0844os0/environment/foskksoccksk0kc4g8sk88ok)
|
||||
2. Click **+ Add -> Application -> Private Repository (with Gitea)**.
|
||||
3. Select `vibn-telemetry-service` and branch `main`.
|
||||
4. Set Build Pack to `Dockerfile` and Ports Exposes to `4000`.
|
||||
5. Under Environment Variables, add `DATABASE_URL=postgresql://<user>:<password>@<host>/<database>`
|
||||
6. Deploy it, then add `TELEMETRY_SERVICE_URL=http://<the-new-coolify-url>:4000` to the `vibn-frontend` environments.
|
||||
|
||||
---
|
||||
|
||||
@@ -123,18 +164,97 @@ VibnCode overrides local OS actions to communicate with your cloud containers (o
|
||||
```bash
|
||||
git commit -m "commit message" --no-verify
|
||||
```
|
||||
3. **Push to Individual remotes**:
|
||||
Always commit inside the specific project folder, and push to the matching Gitea remote (e.g., `git push coolify_agent_gitea branch-name` for `vibn-agent-runner`).
|
||||
3. **Push to Individual remotes (the ONLY way changes reach production)**:
|
||||
Commit in `master-ai`, then push the relevant subfolder's remote. Production never reads from `master-ai` directly — the push to Gitea is the complete hand-off.
|
||||
```bash
|
||||
git push coolify_gitea HEAD:main # deploy vibn-frontend
|
||||
git push coolify_agent_gitea HEAD:main # deploy vibn-agent-runner
|
||||
git push coolify_api_gitea HEAD:main # deploy vibn-api
|
||||
git push coolify_telemetry_gitea HEAD:main # deploy vibn-telemetry-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Where We Left Off (As of May 28, 2026)
|
||||
## 6. Where We Left Off (As of May 31, 2026)
|
||||
|
||||
* **Deep-Link Protocol Scheme Resolved**:
|
||||
Fixed `src-tauri/Info.plist` which was still configured with `com.talkcody` / `talkcody`. macOS Launch Services now correctly maps `vibncode://` deep links directly to the local dev app.
|
||||
* **Rust Compiling Errors Resolved**:
|
||||
Patched cargo clippy errors in `dashscope.rs`, `openai_responses_protocol.rs`, and `openai_responses_ws.rs` (collapsed match statements and annotated unused structs).
|
||||
* **Repositories Synchronized**:
|
||||
Merged, committed, and pushed all updated code:
|
||||
* `vibn-code` pushed to Gitea `origin main`.
|
||||
* `vibn-agent-runner` and `vibn-frontend` modifications pushed to `coolify_agent_gitea` and `coolify_gitea` on branch `frontend-deploy-13`.
|
||||
**Read `VIBNCODE_THIN_CLIENT_CHANGES.md` first** — it is the live, prioritized change list with exact files,
|
||||
steps, and acceptance criteria for the thin-client conversion, plus a STATUS section of what's done.
|
||||
|
||||
**Chat works end-to-end.** A desktop message → `POST /api/projects/:id/agent/sessions` → cloud runner executes
|
||||
the Coder agent (Gemini) → output polled back into the Monaco chat. Recent fixes that got it there:
|
||||
|
||||
* **Local SQLite was wiping chats (fixed):** `database-service.ts` used `INSERT OR REPLACE INTO projects`, which
|
||||
(via `ON DELETE CASCADE`) deleted the active conversation mid-run. Switched to UPSERT; made `task-service`
|
||||
persistence non-blocking. The cloud is the source of truth; local SQLite is just a cache.
|
||||
* **Empty `appPath` broke every run (fixed):** the desktop sent `appPath: ""`; the runner's `/agent/execute`
|
||||
rejects falsy `appPath` with HTTP 400 and does nothing (no logs). Desktop now sends `appPath: "."`.
|
||||
* **Agent tools `fetch failed` (fixed, pushed):** the runner's `buildContext()` hardcoded
|
||||
`vibnApiUrl: 'http://localhost:3000'` and an empty `mcpToken`, so tool calls fetched a dead port. Now
|
||||
`/agent/execute` reads `mcpToken` from the body and sets `ctx.vibnApiUrl` (from `VIBN_API_URL`) + `mcpToken`.
|
||||
Pushed to `coolify_agent_gitea/main` — confirm the runner redeploy.
|
||||
* **Single model:** desktop model picker restricted to the VibnAI model, relabeled "Gemini 3.5 Flash". The
|
||||
runner's real model is set by `GEMINI_MODEL` env (currently `gemini-3.1-pro-preview`); the desktop label is
|
||||
cosmetic until model-passthrough is wired (CHANGE 4.1 in the change doc).
|
||||
|
||||
**Known open items (in the change doc):** the desktop still has a hardcoded `vibn_sk_` API key to remove;
|
||||
`/agent/sessions/:id/stop` returns 401 to the desktop (uses browser-session auth, not the workspace key); runner
|
||||
early-failures are silently swallowed (failure PATCHes omit the `x-agent-runner-secret` header).
|
||||
|
||||
**Earlier (still true):** `vibncode://` deep link scheme is registered in `src-tauri/Info.plist`; Rust clippy is
|
||||
treated as errors on commit.
|
||||
|
||||
---
|
||||
|
||||
## 7. Fetching Production Logs (Coolify apps)
|
||||
|
||||
The Coolify dashboard (`https://coolify.vibnai.com/...`) is login-walled, so to read an app's logs
|
||||
programmatically use one of the two paths below. Both read credentials from `vibn-frontend/.env.local`
|
||||
(`COOLIFY_URL`, `COOLIFY_API_TOKEN`, and `COOLIFY_SSH_HOST` / `COOLIFY_SSH_PORT` / `COOLIFY_SSH_USER` /
|
||||
`COOLIFY_SSH_PRIVATE_KEY_B64`).
|
||||
|
||||
**The `<appUuid>` is the last path segment of the Coolify app URL:**
|
||||
`.../application/y4cscsc8s08c8808go0448s0` -> appUuid = `y4cscsc8s08c8808go0448s0`.
|
||||
|
||||
| App | appUuid | Build pack | Notes |
|
||||
|---|---|---|---|
|
||||
| `vibn-frontend` | `y4cscsc8s08c8808go0448s0` | dockerfile | Next.js, port 3000, fqdn vibnai.com |
|
||||
| `vibn-telemetry` | `hou4vy5mtyg5mrx3w4nl2lxv` | dockerfile | port 4000; usage data lives in its **DB**, not stdout |
|
||||
|
||||
### Method A - Coolify REST API (simplest)
|
||||
`GET {COOLIFY_URL}/api/v1/applications/{uuid}/logs?lines=N` with `Authorization: Bearer {COOLIFY_API_TOKEN}`.
|
||||
Returns `{ logs: "..." }`. Works for dockerfile / nixpacks / static apps; returns **empty for `dockercompose`**
|
||||
(Coolify can't pick which service to tail). Helper script:
|
||||
```bash
|
||||
cd vibn-frontend
|
||||
node scripts/fetch-app-logs.mjs <appUuid> [lines] # reads .env.local itself
|
||||
```
|
||||
|
||||
### Method B - SSH + `docker logs` (full history, timestamps, date filter)
|
||||
Use when the REST endpoint returns little/nothing (compose apps, or quiet services). Connects to the host with
|
||||
the `ssh2` lib and runs `docker logs` against the app's container(s). Coolify names containers
|
||||
`{appUuid}-{hash}`; a zero-downtime deploy briefly leaves TWO containers (old draining + new). Helper script:
|
||||
```bash
|
||||
cd vibn-frontend
|
||||
# everything since the start of a UTC day (note: logs are UTC):
|
||||
node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid> 2026-06-12
|
||||
# last 500 lines, no date filter:
|
||||
node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid> "" 500
|
||||
# target a specific container during a rollout (substring match):
|
||||
node --env-file=.env.local scripts/fetch-app-logs-ssh.mjs <appUuid>-003647723804 2026-06-12
|
||||
```
|
||||
Under the hood (reusable one-off): `runOnCoolifyHost()` in `lib/coolify-ssh.ts`, or for the compose-aware
|
||||
unified fetcher, `getApplicationRuntimeLogs()` in `lib/coolify-logs.ts` (API first, SSH `docker logs` fallback).
|
||||
List an app's containers: `docker ps -a --filter name=<appUuid> --format '{{.Names}}\t{{.Status}}'`.
|
||||
|
||||
### Important caveat - container logs are NOT "usage logs"
|
||||
Both the Next.js frontend (production server) and the telemetry service only emit **startup lines + explicit
|
||||
`console.error`** to stdout - they do NOT log per-request activity. So `docker logs` is the right tool for
|
||||
**deploy health and crashes/errors**, but for actual product **usage** you must query the data store:
|
||||
- **Telemetry / usage** is written to Postgres by `vibn-telemetry-service` (its own `DATABASE_URL`). The existing
|
||||
extractors (`vibn-frontend/scripts/extract-live-telemetry.ts`, `extract-ui-telemetry.ts`) pull telemetry by
|
||||
querying `fs_chat_threads` / `fs_chat_messages` via `DATABASE_URL` - copy their pattern for date-ranged usage.
|
||||
- **Runtime errors** at scale are captured by **Sentry** (auto-provisioned per project), not container stdout.
|
||||
|
||||
### Verifying a deploy landed
|
||||
`GET /api/v1/applications/{uuid}` returns `status` (`running:healthy` when good). On a fresh deploy the new
|
||||
container shows `Up About a minute (healthy)` and the previous one disappears once draining completes.
|
||||
|
||||
1998
compiled_system_prompts_audit.json
Normal file
1
design-templates/VIBN (2)/.design-canvas.state.json
Normal file
@@ -0,0 +1 @@
|
||||
{"sections":{"app-navs":{"labels":{"sidebar":"01 · Sidebar w/ workspaces"}}}}
|
||||
BIN
design-templates/VIBN (2)/.thumbnail
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
86
design-templates/VIBN (2)/Atlas Marketplace Templates.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Atlas — Two-sided marketplace templates</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Inter+Tight:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
|
||||
<link rel="stylesheet" href="vibn-marketplace/marketplace-tokens.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<!-- Vibn base library -->
|
||||
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
|
||||
<!-- Marketplace extension -->
|
||||
<script type="text/babel" src="vibn-marketplace/marketplace-components.jsx"></script>
|
||||
<script type="text/babel" src="vibn-marketplace/marketplace-shells.jsx"></script>
|
||||
|
||||
<script type="text/babel" src="atlas-pages.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const {
|
||||
AtlasHome, AtlasSearch, AtlasListing, AtlasCheckout,
|
||||
AtlasMessages, AtlasGuestDash, AtlasHostDash, AtlasNewListing,
|
||||
} = window;
|
||||
|
||||
// Wrap each page in .theme-flux — modern dark-glass aesthetic
|
||||
// with violet/fuchsia aurora backdrop. The marketplace components
|
||||
// are theme-aware, so swapping the class to `theme-atlas` or any
|
||||
// other theme re-skins the whole tree.
|
||||
const Atlas = ({ children }) => (
|
||||
<div className="theme-flux" style={{ width: "100%", height: "100%" }}>{children}</div>
|
||||
);
|
||||
|
||||
const W = 1440, H = 900;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection
|
||||
id="public"
|
||||
title="Public-facing · discovery → booking"
|
||||
subtitle="The guest's path from landing to confirmed booking."
|
||||
>
|
||||
<DCArtboard id="home" label="01 · Home / discovery" width={W} height={1500}><Atlas><AtlasHome/></Atlas></DCArtboard>
|
||||
<DCArtboard id="search" label="02 · Search results + map" width={W} height={H}><Atlas><AtlasSearch/></Atlas></DCArtboard>
|
||||
<DCArtboard id="listing" label="03 · Listing detail" width={W} height={2400}><Atlas><AtlasListing/></Atlas></DCArtboard>
|
||||
<DCArtboard id="checkout" label="04 · Checkout" width={W} height={1100}><Atlas><AtlasCheckout/></Atlas></DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="guest"
|
||||
title="Guest (demand-side) experience"
|
||||
subtitle="Post-booking: managing trips and talking to hosts."
|
||||
>
|
||||
<DCArtboard id="g-trips" label="05 · Guest dashboard · trips" width={W} height={H}><Atlas><AtlasGuestDash/></Atlas></DCArtboard>
|
||||
<DCArtboard id="messages" label="06 · Messages inbox" width={W} height={H}><Atlas><AtlasMessages/></Atlas></DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="host"
|
||||
title="Host (supply-side) experience"
|
||||
subtitle="Earnings, calendar and listing creation."
|
||||
>
|
||||
<DCArtboard id="h-today" label="07 · Host dashboard · today" width={W} height={H}><Atlas><AtlasHostDash/></Atlas></DCArtboard>
|
||||
<DCArtboard id="h-new" label="08 · New listing · step 3 of 6" width={W} height={H}><Atlas><AtlasNewListing/></Atlas></DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
67
design-templates/VIBN (2)/Auth Screens by Style.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Auth screens · 3 aesthetics × 3 flows</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="auth-style-a.jsx"></script>
|
||||
<script type="text/babel" src="auth-style-b.jsx"></script>
|
||||
<script type="text/babel" src="auth-style-c.jsx"></script>
|
||||
<script type="text/babel">
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 1440, H = 900;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection
|
||||
id="style-a"
|
||||
title="A · Light minimal"
|
||||
subtitle="Centered card on warm neutral. Pairs with the Sidebar nav style."
|
||||
>
|
||||
<DCArtboard id="a-signin" label="Sign in" width={W} height={H}><ASignIn/></DCArtboard>
|
||||
<DCArtboard id="a-signup" label="Sign up" width={W} height={H}><ASignUp/></DCArtboard>
|
||||
<DCArtboard id="a-onboarding" label="Onboarding · workspace" width={W} height={H}><AOnboarding/></DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="style-b"
|
||||
title="B · Dark split-hero"
|
||||
subtitle="Storytelling panel + form. Pairs with the Top horizontal / ⌘K nav."
|
||||
>
|
||||
<DCArtboard id="b-signin" label="Sign in" width={W} height={H}><BSignIn/></DCArtboard>
|
||||
<DCArtboard id="b-signup" label="Sign up" width={W} height={H}><BSignUp/></DCArtboard>
|
||||
<DCArtboard id="b-onboarding" label="Onboarding · personalise" width={W} height={H}><BOnboarding/></DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="style-c"
|
||||
title="C · Glass aurora"
|
||||
subtitle="Vibrant gradient + frosted card. Pairs with the Floating-pill marketing nav."
|
||||
>
|
||||
<DCArtboard id="c-signin" label="Sign in" width={W} height={H}><CSignIn/></DCArtboard>
|
||||
<DCArtboard id="c-signup" label="Sign up" width={W} height={H}><CSignUp/></DCArtboard>
|
||||
<DCArtboard id="c-onboarding" label="Onboarding · invite team" width={W} height={H}><COnboarding/></DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
73
design-templates/VIBN (2)/Cadence CRM Templates.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Cadence CRM — Sidebar template package</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
|
||||
<script type="text/babel" src="vibn-crm/crm-onboarding.jsx"></script>
|
||||
<script type="text/babel" src="vibn-crm/crm-pages.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { DesignCanvas, DCSection, DCArtboard,
|
||||
CRMSignUp, CRMSignIn,
|
||||
CRMOnbWorkspace, CRMOnbAbout, CRMOnbImport, CRMOnbInvite,
|
||||
CRMHome, CRMPeople, CRMRecord, CRMPipeline, CRMInbox, CRMReports, CRMSettings } = window;
|
||||
|
||||
// Everything renders in the light/minimal theme (the sidebar style).
|
||||
const M = ({ children }) => (
|
||||
<div className="theme-minimal" style={{ width: "100%", height: "100%" }}>{children}</div>
|
||||
);
|
||||
|
||||
const W = 1440, H = 900;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection id="auth" title="Sign up & sign in"
|
||||
subtitle="Full-screen, same minimal aesthetic as the app.">
|
||||
<DCArtboard id="signup" label="Sign up" width={W} height={H}><M><CRMSignUp/></M></DCArtboard>
|
||||
<DCArtboard id="signin" label="Sign in" width={W} height={H}><M><CRMSignIn/></M></DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="onboarding" title="Onboarding · 4 steps"
|
||||
subtitle="Workspace → about you → import → invite. Stepper-driven.">
|
||||
<DCArtboard id="onb-1" label="01 · Name workspace" width={W} height={H}><M><CRMOnbWorkspace/></M></DCArtboard>
|
||||
<DCArtboard id="onb-2" label="02 · About your team" width={W} height={H}><M><CRMOnbAbout/></M></DCArtboard>
|
||||
<DCArtboard id="onb-3" label="03 · Import contacts" width={W} height={H}><M><CRMOnbImport/></M></DCArtboard>
|
||||
<DCArtboard id="onb-4" label="04 · Invite team" width={W} height={H}><M><CRMOnbInvite/></M></DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="app" title="In-app · Sidebar style"
|
||||
subtitle="The far-left sidebar nav across every core CRM screen.">
|
||||
<DCArtboard id="home" label="Home" width={W} height={H}><M><CRMHome/></M></DCArtboard>
|
||||
<DCArtboard id="people" label="People · table" width={W} height={H}><M><CRMPeople/></M></DCArtboard>
|
||||
<DCArtboard id="record" label="Company record" width={W} height={H}><M><CRMRecord/></M></DCArtboard>
|
||||
<DCArtboard id="pipeline" label="Deals · pipeline" width={W} height={H}><M><CRMPipeline/></M></DCArtboard>
|
||||
<DCArtboard id="inbox" label="Inbox" width={W} height={H}><M><CRMInbox/></M></DCArtboard>
|
||||
<DCArtboard id="reports" label="Reports" width={W} height={H}><M><CRMReports/></M></DCArtboard>
|
||||
<DCArtboard id="settings" label="Settings · members" width={W} height={H}><M><CRMSettings/></M></DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
71
design-templates/VIBN (2)/Modern Website Styles.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Modern website design styles · 2026 sampler</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="styles.jsx"></script>
|
||||
<script type="text/babel">
|
||||
const { DesignCanvas, DCSection, DCArtboard, DCPostIt } = window;
|
||||
|
||||
// Each artboard is a 1280×800 desktop hero so the styles read as full
|
||||
// landing pages, not crops. They're laid out in three thematic rows so
|
||||
// the user can compare neighbours and skim the whole field at once.
|
||||
|
||||
const W = 1280, H = 800;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection
|
||||
id="restrained"
|
||||
title="The restrained school"
|
||||
subtitle="Type-led, gridded, lots of white space — the editorial revival."
|
||||
>
|
||||
<DCArtboard id="editorial" label="01 · Editorial Swiss" width={W} height={H}><StyleEditorial /></DCArtboard>
|
||||
<DCArtboard id="minimal" label="02 · Minimal mono" width={W} height={H}><StyleMinimal /></DCArtboard>
|
||||
<DCArtboard id="organic" label="03 · Organic / warm serif" width={W} height={H}><StyleOrganic /></DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="product"
|
||||
title="The product-led school"
|
||||
subtitle="Dark UI, bento grids, frosted glass — what modern SaaS sites look like in 2026."
|
||||
>
|
||||
<DCArtboard id="bento" label="04 · Dark bento" width={W} height={H}><StyleBento /></DCArtboard>
|
||||
<DCArtboard id="aurora" label="05 · Glass / Aurora" width={W} height={H}><StyleAurora /></DCArtboard>
|
||||
<DCArtboard id="terminal" label="06 · Terminal mono" width={W} height={H}><StyleTerminal /></DCArtboard>
|
||||
<DCArtboard id="cyber" label="07 · Cyber / neon grid" width={W} height={H}><StyleCyber /></DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="expressive"
|
||||
title="The expressive school"
|
||||
subtitle="Loud, opinionated, hand-feeling. Pushback against grid-perfect SaaS."
|
||||
>
|
||||
<DCArtboard id="brutalist" label="08 · Neo-brutalism" width={W} height={H}><StyleBrutalist /></DCArtboard>
|
||||
<DCArtboard id="maximalist" label="09 · Maximalist Y2K" width={W} height={H}><StyleMaximalist /></DCArtboard>
|
||||
<DCArtboard id="anti" label="10 · Anti-design" width={W} height={H}><StyleAntiDesign /></DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
61
design-templates/VIBN (2)/SaaS Nav Layouts.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>4 modern SaaS nav layouts</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="nav-styles.jsx"></script>
|
||||
<script type="text/babel">
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 1440, H = 900;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection
|
||||
id="app-navs"
|
||||
title="App navigation"
|
||||
subtitle="In-product chrome for an authenticated workspace."
|
||||
>
|
||||
<DCArtboard id="sidebar" label="01 · Sidebar w/ workspaces" width={W} height={H}>
|
||||
<NavSidebar />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="rail" label="02 · Icon rail + secondary panel" width={W} height={H}>
|
||||
<NavIconRail />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="topbar" label="03 · Top horizontal + ⌘K bar" width={W} height={H}>
|
||||
<NavTopHorizontal />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="marketing-nav"
|
||||
title="Marketing navigation"
|
||||
subtitle="Public-facing homepage chrome."
|
||||
>
|
||||
<DCArtboard id="glasspill" label="04 · Floating glass pill" width={W} height={H}>
|
||||
<NavFloatingGlass />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
220
design-templates/VIBN (2)/SaaS Pages by Nav Style.html
Normal file
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SaaS pages × 3 nav styles · Lattice</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="app-chrome.jsx"></script>
|
||||
<script type="text/babel" src="page-customer.jsx"></script>
|
||||
<script type="text/babel" src="page-dashboard.jsx"></script>
|
||||
<script type="text/babel" src="page-admin.jsx"></script>
|
||||
<script type="text/babel">
|
||||
const { DesignCanvas, DCSection, DCArtboard,
|
||||
SidebarChrome, RailChrome, RailItem, RailSectionHeader, TopbarChrome,
|
||||
Icon, P } = window;
|
||||
|
||||
const W = 1440, H = 900;
|
||||
|
||||
// ── Secondary panel content per page, for the dark rail chrome ─────
|
||||
// Companies list — relevant context next to a customer/company page
|
||||
const CompaniesPanel = () => (
|
||||
<>
|
||||
<RailSectionHeader action={<Icon d={P.plus} size={12} />}>
|
||||
Pinned
|
||||
</RailSectionHeader>
|
||||
{[
|
||||
["NS", "Northstar Logistics", "Tier 1 · EMEA", "#f6c560", true],
|
||||
["HC", "Halcyon", "Renewal Q3", "#a8c8e8"],
|
||||
["KS", "Kestrel", "Pilot", "#c8e8a8"],
|
||||
].map(([i, n, s, col, active]) => (
|
||||
<RailItem key={i} active={active}
|
||||
leading={<div style={{
|
||||
width: 24, height: 24, borderRadius: 5, background: col,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#3a2210", fontSize: 10, fontWeight: 700,
|
||||
}}>{i}</div>}
|
||||
label={n} sub={s} />
|
||||
))}
|
||||
|
||||
<RailSectionHeader>All companies · 248</RailSectionHeader>
|
||||
{[
|
||||
["BF","Brooke Foods", "added 2 days", "#e8c8a8"],
|
||||
["MV","Moss & Verra", "added 5 days", "#c8a8e8"],
|
||||
["TD","Tide Co.", "added a week", "#a8e8c8"],
|
||||
["VR","Verra Tech", "added a week", "#e8a87c"],
|
||||
["LW","Lowell Works", "added 2 weeks", "#a8c8e8"],
|
||||
["OK","Okra Studios", "added 3 weeks", "#e8a8c8"],
|
||||
].map(([i, n, s, col]) => (
|
||||
<RailItem key={i}
|
||||
leading={<div style={{
|
||||
width: 24, height: 24, borderRadius: 5, background: col,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#3a2210", fontSize: 10, fontWeight: 700,
|
||||
}}>{i}</div>}
|
||||
label={n} sub={s} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
// Saved dashboards / reports — context for the dashboard page
|
||||
const DashboardsPanel = () => (
|
||||
<>
|
||||
<RailSectionHeader action={<Icon d={P.plus} size={12} />}>
|
||||
My dashboards
|
||||
</RailSectionHeader>
|
||||
{[
|
||||
["Workspace overview", "default", true],
|
||||
["Revenue · weekly", "shared by Theo"],
|
||||
["Pipeline health", "auto-refresh 5m"],
|
||||
["Team performance", "private"],
|
||||
].map(([n, s, active]) => (
|
||||
<RailItem key={n} active={active}
|
||||
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
|
||||
<Icon d={P.bar} size={14} />
|
||||
</span>}
|
||||
label={n} sub={s} />
|
||||
))}
|
||||
|
||||
<RailSectionHeader>Shared with me</RailSectionHeader>
|
||||
{[
|
||||
["Q2 board review", "from Mira"],
|
||||
["Marketing funnel", "from Devi"],
|
||||
["Customer success", "from Sun"],
|
||||
["Churn watch", "from Theo"],
|
||||
].map(([n, s]) => (
|
||||
<RailItem key={n}
|
||||
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
|
||||
<Icon d={P.bar} size={14} />
|
||||
</span>}
|
||||
label={n} sub={s} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
// Settings tree — context for the admin page
|
||||
const SettingsPanel = () => (
|
||||
<>
|
||||
<RailSectionHeader>Workspace</RailSectionHeader>
|
||||
{[
|
||||
["General", P.settings],
|
||||
["Members", P.people, true],
|
||||
["Roles", P.check],
|
||||
["Teams", P.people],
|
||||
["Integrations", P.workflow],
|
||||
["Billing", P.target],
|
||||
["API & Webhooks", P.workflow],
|
||||
["Audit log", P.doc],
|
||||
].map(([n, ico, active]) => (
|
||||
<RailItem key={n} active={active}
|
||||
leading={<span style={{
|
||||
color: active ? "#fff" : "#9a9aa6", display: "flex",
|
||||
}}><Icon d={ico} size={14} /></span>}
|
||||
label={n} />
|
||||
))}
|
||||
<RailSectionHeader>Personal</RailSectionHeader>
|
||||
{[
|
||||
["Profile", P.people],
|
||||
["Notifications", P.bell],
|
||||
["Sessions", P.target],
|
||||
].map(([n, ico]) => (
|
||||
<RailItem key={n}
|
||||
leading={<span style={{ color: "#9a9aa6", display: "flex" }}>
|
||||
<Icon d={ico} size={14} />
|
||||
</span>}
|
||||
label={n} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
// Tabs per page for the dark top bar
|
||||
const customerTabs = ["Overview", "Activity", "People", "Notes", "Files"];
|
||||
const dashboardTabs = ["Overview", "Reports", "Goals", "Anomalies", "Custom"];
|
||||
const adminTabs = ["General", "Members", "Roles", "Integrations", "Billing", "API"];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection
|
||||
id="customer"
|
||||
title="Customer / company page"
|
||||
subtitle="A CRM record — same content, three nav shells."
|
||||
>
|
||||
<DCArtboard id="cust-sidebar" label="Sidebar nav" width={W} height={H}>
|
||||
<SidebarChrome active="companies"><CustomerBody theme="light"/></SidebarChrome>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="cust-rail" label="Icon rail + secondary" width={W} height={H}>
|
||||
<RailChrome active="companies" secondary={<CompaniesPanel/>}>
|
||||
<CustomerBody theme="dark"/>
|
||||
</RailChrome>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="cust-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
|
||||
<TopbarChrome tabs={customerTabs} activeTab="Activity"
|
||||
breadcrumb="northstar-logistics">
|
||||
<CustomerBody theme="light"/>
|
||||
</TopbarChrome>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="dashboard"
|
||||
title="Dashboard page"
|
||||
subtitle="KPIs, time-series, funnel and activity."
|
||||
>
|
||||
<DCArtboard id="dash-sidebar" label="Sidebar nav" width={W} height={H}>
|
||||
<SidebarChrome active="home"><DashboardBody theme="light"/></SidebarChrome>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="dash-rail" label="Icon rail + secondary" width={W} height={H}>
|
||||
<RailChrome active="home" secondary={<DashboardsPanel/>}>
|
||||
<DashboardBody theme="dark"/>
|
||||
</RailChrome>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="dash-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
|
||||
<TopbarChrome tabs={dashboardTabs} activeTab="Overview"
|
||||
breadcrumb="dashboard">
|
||||
<DashboardBody theme="light"/>
|
||||
</TopbarChrome>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="admin"
|
||||
title="Admin page"
|
||||
subtitle="Workspace settings → Members table."
|
||||
>
|
||||
<DCArtboard id="admin-sidebar" label="Sidebar nav" width={W} height={H}>
|
||||
<SidebarChrome active="settings"><AdminBody theme="light"/></SidebarChrome>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="admin-rail" label="Icon rail + secondary" width={W} height={H}>
|
||||
<RailChrome active="settings" secondary={<SettingsPanel/>}>
|
||||
<AdminBody theme="dark"/>
|
||||
</RailChrome>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="admin-topbar" label="Top horizontal + ⌘K" width={W} height={H}>
|
||||
<TopbarChrome tabs={adminTabs} activeTab="Members"
|
||||
breadcrumb="settings">
|
||||
<AdminBody theme="light"/>
|
||||
</TopbarChrome>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
38
design-templates/VIBN (2)/Sign In.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vibn — Sign in</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" type="image/png" href="assets/logo-black.png" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="auth.css" />
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<template id="__bundler_thumbnail">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<rect width="200" height="200" fill="#27201d"/>
|
||||
<circle cx="100" cy="100" r="56" fill="url(#g)"/>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ff6b47"/>
|
||||
<stop offset="100%" stop-color="#c2410c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(72 78)" fill="#1a0f0a" stroke="#1a0f0a" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path d="M0 0 L11 0 L14 24 L17 0 L28 0 L17 44 Z"/>
|
||||
<rect x="33" y="36" width="18" height="7" rx="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="auth-shared.jsx"></script>
|
||||
<script type="text/babel" src="signin.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
design-templates/VIBN (2)/Sign Up.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vibn — Create your account</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" type="image/png" href="assets/logo-black.png" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="auth.css" />
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="auth-shared.jsx"></script>
|
||||
<script type="text/babel" src="signup.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
687
design-templates/VIBN (2)/Vibn UI Showcase.html
Normal file
@@ -0,0 +1,687 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Vibn AI Templates — UI showcase</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; min-height: 100%; background: #f0eee9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { DesignCanvas, DCSection, DCArtboard,
|
||||
Button, IconButton, Field, Input, Textarea, Select, FieldGroup,
|
||||
Checkbox, Radio, Switch, Card, CardHeader, Divider,
|
||||
Badge, Avatar, AvatarStack, Tabs, Table, Modal, Banner, KBD, Spinner,
|
||||
SidebarShell, TopbarShell, RailShell,
|
||||
AuthCenteredShell, AuthSplitShell, AuthGlassShell,
|
||||
Icon, icons, VibnMark } = window;
|
||||
|
||||
// ─── Section helpers ────────────────────────────────────────
|
||||
const SubHeading = ({ children }) => (
|
||||
<div style={{
|
||||
fontSize: "var(--text-xs)", color: "var(--text-3)",
|
||||
letterSpacing: "0.08em", textTransform: "uppercase",
|
||||
fontWeight: 500, marginBottom: 10,
|
||||
}}>{children}</div>
|
||||
);
|
||||
|
||||
const ThemeFrame = ({ theme, children }) => (
|
||||
// Themed wrapper — note: the artboard contents must be wrapped in
|
||||
// a theme class so all CSS-var reads inside re-bind to that theme.
|
||||
<div className={`theme-${theme}`} style={{ width: "100%", height: "100%" }}>
|
||||
<div className="vibn-app" style={{ width: "100%", height: "100%", overflow: "auto" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── 1 · Foundations / token swatches ───────────────────────
|
||||
const Foundations = ({ theme }) => (
|
||||
<div style={{ padding: 32 }}>
|
||||
<h1 style={{
|
||||
margin: 0, fontFamily: "var(--font-display)",
|
||||
fontSize: "var(--text-3xl)", letterSpacing: "-0.02em", fontWeight: 500,
|
||||
}}>Theme · {theme}</h1>
|
||||
<p style={{ color: "var(--text-2)", marginTop: 6, fontSize: "var(--text-md)" }}>
|
||||
Same components, four CSS-variable themes. Tokens, type and surfaces.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 28, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
|
||||
<Card>
|
||||
<CardHeader title="Surface" subtitle="Page chrome + cards"/>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 10 }}>
|
||||
{[
|
||||
["--bg", "Page bg"],
|
||||
["--surface", "Card"],
|
||||
["--surface-2", "Card alt"],
|
||||
["--surface-alt", "Sidebar"],
|
||||
["--border", "Border"],
|
||||
].map(([v, l]) => (
|
||||
<div key={v}>
|
||||
<div style={{
|
||||
height: 56, borderRadius: "var(--radius)",
|
||||
background: `var(${v})`, border: "1px solid var(--border)",
|
||||
}}/>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginTop: 6 }}>{l}</div>
|
||||
<div style={{ fontSize: 10, fontFamily: "var(--font-mono)", color: "var(--text-3)" }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Accents & semantics"/>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 10 }}>
|
||||
{[
|
||||
["--accent", "Accent"],
|
||||
["--accent-2", "Accent 2"],
|
||||
["--success", "Success"],
|
||||
["--warn", "Warn"],
|
||||
["--danger", "Danger"],
|
||||
].map(([v, l]) => (
|
||||
<div key={v}>
|
||||
<div style={{ height: 56, borderRadius: "var(--radius)", background: `var(${v})` }}/>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginTop: 6 }}>{l}</div>
|
||||
<div style={{ fontSize: 10, fontFamily: "var(--font-mono)", color: "var(--text-3)" }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Type scale"/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[
|
||||
["Display · 38", { fontSize: 38, fontWeight: 500, fontFamily: "var(--font-display)", letterSpacing: "-0.02em" }],
|
||||
["Heading · 22", { fontSize: 22, fontWeight: 600, letterSpacing: "-0.01em" }],
|
||||
["Body · 13", { fontSize: 13 }],
|
||||
["Caption · 11", { fontSize: 11, color: "var(--text-3)" }],
|
||||
["Mono · 12", { fontFamily: "var(--font-mono)", fontSize: 12 }],
|
||||
].map(([l, s], i) => (
|
||||
<div key={i} style={{ ...s }}>{l} — Modern SaaS, designed for everyone.</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Radii, shadows, motion"/>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12, marginBottom: 14 }}>
|
||||
{["sm", "", "lg"].map(s => (
|
||||
<div key={s} style={{
|
||||
height: 50,
|
||||
background: "var(--surface-2)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: `var(--radius${s ? `-${s}` : ""})`,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, color: "var(--text-3)",
|
||||
}}>radius-{s || "default"}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12 }}>
|
||||
{[
|
||||
["shadow-sm", "var(--shadow-sm)"],
|
||||
["shadow", "var(--shadow)"],
|
||||
["shadow-lg", "var(--shadow-lg)"],
|
||||
].map(([l, sh]) => (
|
||||
<div key={l} style={{
|
||||
height: 50, background: "var(--surface)",
|
||||
border: "1px solid var(--border)", borderRadius: "var(--radius)",
|
||||
boxShadow: sh, display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, color: "var(--text-3)",
|
||||
}}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── 2 · Form atoms ─────────────────────────────────────────
|
||||
const FormAtoms = () => {
|
||||
const [tab, setTab] = React.useState("Account");
|
||||
const [sw1, setSw1] = React.useState(true);
|
||||
const [sw2, setSw2] = React.useState(false);
|
||||
const [chk, setChk] = React.useState(true);
|
||||
const [seg, setSeg] = React.useState("Week");
|
||||
return (
|
||||
<div style={{ padding: 32 }}>
|
||||
<h1 style={{
|
||||
margin: 0, fontFamily: "var(--font-display)", fontSize: "var(--text-2xl)",
|
||||
fontWeight: 500, letterSpacing: "-0.02em",
|
||||
}}>Forms & buttons</h1>
|
||||
|
||||
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
|
||||
<Card>
|
||||
<CardHeader title="Buttons" subtitle="Variants, sizes, states"/>
|
||||
<SubHeading>Variants</SubHeading>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
|
||||
<Button>Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button loading>Loading</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
</div>
|
||||
<SubHeading>Sizes & icons</SubHeading>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Button size="sm" leadingIcon={<Icon name="plus" size={12}/>}>New deal</Button>
|
||||
<Button>Sign in <Icon name="arrow" size={13}/></Button>
|
||||
<Button size="lg" variant="secondary">Get a demo</Button>
|
||||
<IconButton name="bell" label="Notifications"/>
|
||||
<IconButton name="settings" variant="secondary" label="Settings"/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Fields" subtitle="Input, hint, error, password"/>
|
||||
<Field label="Work email" hint="We'll send a 6-digit code.">
|
||||
<Input value="mira@acme.io" leadingIcon={<Icon name="inbox" size={14}/>} autofocus/>
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<Input type="password" value="••••••••••"
|
||||
trailingIcon={<Icon name="eye" size={14}/>}/>
|
||||
</Field>
|
||||
<Field label="Workspace name" error="That name is taken.">
|
||||
<Input value="lattice" invalid/>
|
||||
</Field>
|
||||
<Field label="Notes" optional>
|
||||
<Textarea placeholder="Anything we should know?" rows={3}/>
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
<Select value="Admin" options={["Owner", "Admin", "Member", "Guest"]}/>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Controls"/>
|
||||
<SubHeading>Switches</SubHeading>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginBottom: 14 }}>
|
||||
<Switch checked={sw1} onChange={setSw1}
|
||||
label="Email me digests" hint="Weekly summary every Monday at 9am."/>
|
||||
<Switch checked={sw2} onChange={setSw2}
|
||||
label="Show beta features"/>
|
||||
</div>
|
||||
<SubHeading>Checkboxes & radios</SubHeading>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 14 }}>
|
||||
<Checkbox checked={chk} onChange={setChk} label="I agree to the Terms" hint="And the Privacy Policy."/>
|
||||
<Checkbox checked indeterminate label="Select some items"/>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Radio checked={true} label="Monthly"/>
|
||||
<Radio checked={false} label="Yearly · save 20%"/>
|
||||
</div>
|
||||
</div>
|
||||
<SubHeading>Segmented</SubHeading>
|
||||
<FieldGroup options={["Day", "Week", "Month", "Quarter"]} value={seg} onChange={setSeg}/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Tabs"/>
|
||||
<SubHeading>Underline</SubHeading>
|
||||
<Tabs items={[
|
||||
{ label: "Account" }, { label: "Members", count: 8 },
|
||||
{ label: "Billing" }, { label: "API" },
|
||||
]} active={tab} onChange={setTab}/>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<SubHeading>Pill</SubHeading>
|
||||
<Tabs variant="pill" items={[
|
||||
{ label: "Day" }, { label: "Week" }, { label: "Month" }, { label: "Year" },
|
||||
]} active="Week"/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 3 · Display atoms ──────────────────────────────────────
|
||||
const DisplayAtoms = () => {
|
||||
const [modalOpen, setModalOpen] = React.useState(false);
|
||||
return (
|
||||
<div style={{ padding: 32 }}>
|
||||
<h1 style={{
|
||||
margin: 0, fontFamily: "var(--font-display)", fontSize: "var(--text-2xl)",
|
||||
fontWeight: 500, letterSpacing: "-0.02em",
|
||||
}}>Display & feedback</h1>
|
||||
|
||||
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
|
||||
<Card>
|
||||
<CardHeader title="Badges & avatars"/>
|
||||
<SubHeading>Tones</SubHeading>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
|
||||
<Badge>Neutral</Badge>
|
||||
<Badge tone="accent" dot>Accent</Badge>
|
||||
<Badge tone="success" dot>Active</Badge>
|
||||
<Badge tone="warn" dot>Invited</Badge>
|
||||
<Badge tone="danger" dot>Suspended</Badge>
|
||||
<Badge tone="info">v4.2.1</Badge>
|
||||
</div>
|
||||
<SubHeading>Avatars</SubHeading>
|
||||
<div style={{ display: "flex", gap: 14, alignItems: "center" }}>
|
||||
<Avatar name="Mira Reyes" size={24}/>
|
||||
<Avatar name="Theo Roux" size={32}/>
|
||||
<Avatar name="Devi Patel" size={40} status="online"/>
|
||||
<Avatar name="Sun Kim" size={48} status="busy"/>
|
||||
<AvatarStack items={[
|
||||
{name:"Mira Reyes"},{name:"Theo Roux"},{name:"Devi Patel"},
|
||||
{name:"Sun Kim"},{name:"Ade Nwosu"},{name:"Linnea Berg"},
|
||||
{name:"Jamal Frost"}
|
||||
]}/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Banners"/>
|
||||
<Banner title="Workspace upgrade pending" tone="warn"
|
||||
action={<Button size="sm" variant="secondary">Review</Button>}>
|
||||
1 invitation hasn't been accepted yet — sent 3 days ago.
|
||||
</Banner>
|
||||
<div style={{ height: 10 }}/>
|
||||
<Banner tone="success" title="Saved">Your changes were saved.</Banner>
|
||||
<div style={{ height: 10 }}/>
|
||||
<Banner tone="danger" title="Couldn't connect">Please check your network and try again.</Banner>
|
||||
</Card>
|
||||
|
||||
<Card style={{ gridColumn: "span 2" }}>
|
||||
<CardHeader title="Table" subtitle="Members of the workspace"
|
||||
action={<Button size="sm" leadingIcon={<Icon name="plus" size={12}/>}>Invite</Button>}/>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: "name", label: "Name", render: r => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Avatar name={r.name} size={26}/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{r.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{r.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
)},
|
||||
{ key: "role", label: "Role", render: r => <Badge tone="accent">{r.role}</Badge> },
|
||||
{ key: "status", label: "Status", render: r =>
|
||||
<Badge dot tone={r.status === "Active" ? "success" :
|
||||
r.status === "Invited" ? "warn" : "danger"}>{r.status}</Badge> },
|
||||
{ key: "last", label: "Last active" },
|
||||
{ key: "act", label: "", align: "right", width: 32,
|
||||
render: () => <IconButton name="more" size="sm" label="More"/> },
|
||||
]}
|
||||
rows={[
|
||||
{ id: 1, name: "Mira Reyes", email: "mira@vibn.co", role: "Owner", status: "Active", last: "now" },
|
||||
{ id: 2, name: "Theo Roux", email: "theo@vibn.co", role: "Admin", status: "Active", last: "12 min" },
|
||||
{ id: 3, name: "Devi Patel", email: "devi@vibn.co", role: "Admin", status: "Active", last: "1 hour" },
|
||||
{ id: 4, name: "Linnea Berg", email: "linnea@vibn.co", role: "Member", status: "Invited", last: "—" },
|
||||
{ id: 5, name: "Elin Roos", email: "elin@vibn.co", role: "Member", status: "Suspended", last: "14 days" },
|
||||
]}
|
||||
selectable
|
||||
selected={[1, 2]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={{ gridColumn: "span 2" }}>
|
||||
<CardHeader title="Modal"/>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
<Button onClick={() => setModalOpen(true)}>Open modal</Button>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>
|
||||
Press <KBD>⌘ + Enter</KBD> to confirm
|
||||
</span>
|
||||
</div>
|
||||
<Modal
|
||||
open={modalOpen} onClose={() => setModalOpen(false)}
|
||||
title="Delete workspace?"
|
||||
description="This will permanently remove all data in Lattice Studio. This action cannot be undone."
|
||||
footer={<>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button variant="destructive">Yes, delete it</Button>
|
||||
</>}
|
||||
>
|
||||
<Field label="Type the workspace name to confirm">
|
||||
<Input placeholder="lattice-studio"/>
|
||||
</Field>
|
||||
</Modal>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 4 · In-product shells ──────────────────────────────────
|
||||
const SidebarDemo = () => (
|
||||
<SidebarShell
|
||||
brand={{ name: "Lattice Studio" }}
|
||||
sections={[
|
||||
{ items: [
|
||||
{ id: "home", label: "Home", icon: "home" },
|
||||
{ id: "inbox", label: "Inbox", icon: "inbox", count: 12 },
|
||||
{ id: "tasks", label: "Tasks", icon: "check", count: 3 },
|
||||
]},
|
||||
{ title: "Views", items: [
|
||||
{ id: "co", label: "Companies", icon: "building", active: true },
|
||||
{ id: "people", label: "People", icon: "people" },
|
||||
{ id: "deals", label: "Opportunities", icon: "target" },
|
||||
]},
|
||||
{ title: "Tools", items: [
|
||||
{ id: "i", label: "Insights", icon: "bar" },
|
||||
{ id: "f", label: "Automations", icon: "workflow"},
|
||||
{ id: "d", label: "Docs", icon: "doc" },
|
||||
]},
|
||||
{ title: "Admin", items: [
|
||||
{ id: "s", label: "Settings", icon: "settings" },
|
||||
]},
|
||||
]}
|
||||
user={{ name: "Mira Reyes", email: "mira@vibn.co" }}
|
||||
>
|
||||
<div style={{ padding: 28 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 26, fontWeight: 600 }}>Companies</h1>
|
||||
<p style={{ color: "var(--text-2)", fontSize: 13, marginTop: 6 }}>
|
||||
248 records · last sync 4 minutes ago
|
||||
</p>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<Banner tone="info" title="Vibn 4.0 is live">
|
||||
Workspace-wide rollout begins next Monday. Read the changelog →
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarShell>
|
||||
);
|
||||
|
||||
const TopbarDemo = () => {
|
||||
const [tab, setTab] = React.useState("Activity");
|
||||
return (
|
||||
<TopbarShell
|
||||
brand={{ name: "Lattice" }}
|
||||
breadcrumb={[
|
||||
{ avatar: "Mira Reyes", label: "mira-reyes" },
|
||||
{ label: "northstar-logistics", badge: "Pro" },
|
||||
]}
|
||||
tabs={[
|
||||
{ label: "Overview" }, { label: "Activity", count: 18 },
|
||||
{ label: "People" }, { label: "Notes" }, { label: "Files" },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onTabChange={setTab}
|
||||
user={{ name: "Mira Reyes" }}
|
||||
>
|
||||
<div style={{ padding: 32 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em" }}>Northstar Logistics</h1>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
|
||||
<Badge tone="success" dot>Customer</Badge>
|
||||
<Badge tone="accent">Tier 1</Badge>
|
||||
<Badge>EMEA</Badge>
|
||||
</div>
|
||||
<div style={{ marginTop: 24, display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
|
||||
{[
|
||||
{ l: "Pipeline", v: "€146k", s: "+€12k 30d"},
|
||||
{ l: "Closed-won", v: "€220k", s: "lifetime"},
|
||||
{ l: "Health", v: "82", s: "stable"},
|
||||
].map(k => (
|
||||
<Card key={k.l} padding={18}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em" }}>{k.l}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600, marginTop: 6 }}>{k.v}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-2)", marginTop: 2 }}>{k.s}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TopbarShell>
|
||||
);
|
||||
};
|
||||
|
||||
const RailDemo = () => (
|
||||
<RailShell
|
||||
brand={{ name: "Vibn" }}
|
||||
items={[
|
||||
{ id: "home", icon: "home" },
|
||||
{ id: "inbox", icon: "inbox", badge: 9 },
|
||||
{ id: "co", icon: "building" },
|
||||
{ id: "ppl", icon: "people" },
|
||||
{ id: "deals", icon: "target", badge: 2 },
|
||||
]}
|
||||
activeRail="co"
|
||||
secondaryTitle="Companies"
|
||||
secondary={
|
||||
<div>
|
||||
{["Northstar Logistics", "Halcyon", "Kestrel", "Mossbank", "Verra", "Brooke Foods"].map((n, i) => (
|
||||
<div key={n} style={{
|
||||
padding: "8px 10px", borderRadius: "var(--radius-sm)", fontSize: 13,
|
||||
background: i === 0 ? "var(--surface-alt)" : "transparent",
|
||||
color: i === 0 ? "var(--text)" : "var(--text-2)",
|
||||
display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
|
||||
}}>
|
||||
<Avatar name={n} size={22}/>
|
||||
<span style={{ flex: 1 }}>{n}</span>
|
||||
{i === 0 && <Badge tone="success" dot>active</Badge>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
user={{ name: "Mira Reyes" }}
|
||||
>
|
||||
<div style={{ padding: 32 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>Northstar Logistics</h1>
|
||||
<p style={{ color: "var(--text-2)", fontSize: 13, marginTop: 6 }}>
|
||||
Customer since Aug 2024 · 6 people · €146k pipeline
|
||||
</p>
|
||||
</div>
|
||||
</RailShell>
|
||||
);
|
||||
|
||||
// ─── 5 · Auth shells ────────────────────────────────────────
|
||||
const SocialRow = () => (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button variant="secondary" full>Google</Button>
|
||||
<Button variant="secondary" full>Microsoft</Button>
|
||||
<Button variant="secondary" full>SSO</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AuthCenteredDemo = () => (
|
||||
<AuthCenteredShell brand={{ name: "Lattice" }}>
|
||||
<h1 style={{
|
||||
margin: 0, fontFamily: "var(--font-display)",
|
||||
fontSize: "var(--text-xl)", fontWeight: 600, letterSpacing: "-0.01em",
|
||||
}}>Welcome back</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "6px 0 22px" }}>
|
||||
Sign in to your Lattice workspace.
|
||||
</p>
|
||||
<SocialRow/>
|
||||
<Divider label="or with email"/>
|
||||
<Field label="Email"><Input value="mira@lattice.co" autofocus/></Field>
|
||||
<Field label="Password"><Input type="password" value="••••••••••"
|
||||
trailingIcon={<Icon name="eye" size={14}/>}/></Field>
|
||||
<Button full>Sign in <Icon name="arrow" size={13}/></Button>
|
||||
<div style={{ fontSize: 12, textAlign: "center", marginTop: 18, color: "var(--text-2)" }}>
|
||||
New here? <span style={{ color: "var(--text)", fontWeight: 500 }}>Create an account</span>
|
||||
</div>
|
||||
</AuthCenteredShell>
|
||||
);
|
||||
|
||||
const AuthSplitDemo = () => (
|
||||
<AuthSplitShell
|
||||
brand={{ name: "Lattice" }}
|
||||
hero={{
|
||||
badge: "Lattice 4.0 · agents that draft for you",
|
||||
headline: "The workspace where good ideas compound.",
|
||||
sub: "One luminous surface for docs, canvases, contacts and pipelines.",
|
||||
quote: {
|
||||
body: "Replaced three tools in our first week. Lattice is what every CRM should have been.",
|
||||
author: "Devi Patel", role: "Head of Sales, Halcyon",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h1 style={{
|
||||
margin: 0, fontFamily: "var(--font-display)", fontSize: 26,
|
||||
fontWeight: 600, letterSpacing: "-0.02em",
|
||||
}}>Sign in to Lattice</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-2)", margin: "6px 0 22px" }}>
|
||||
Welcome back. Pick how you'd like to continue.
|
||||
</p>
|
||||
<SocialRow/>
|
||||
<Divider label="or with email"/>
|
||||
<Field label="Email"><Input value="mira@lattice.co" autofocus/></Field>
|
||||
<Field label="Password"><Input type="password" value="••••••••••"
|
||||
trailingIcon={<Icon name="eye" size={14}/>}/></Field>
|
||||
<Button full>Sign in <Icon name="arrow" size={13}/></Button>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<Banner tone="info" title="SAML / SSO for your company?"
|
||||
action={<Button size="sm" variant="ghost">Use SSO →</Button>}>
|
||||
Single sign-on is available on the Pro plan.
|
||||
</Banner>
|
||||
</div>
|
||||
</AuthSplitShell>
|
||||
);
|
||||
|
||||
const AuthGlassDemo = () => (
|
||||
<AuthGlassShell brand={{ name: "Lattice" }} eyebrow="BETA · early access">
|
||||
<h1 style={{
|
||||
margin: 0, fontFamily: "var(--font-display)", fontSize: 32,
|
||||
fontWeight: 500, letterSpacing: "-0.03em",
|
||||
}}>Start your workspace.</h1>
|
||||
<p style={{ fontSize: 14, color: "var(--text-2)", margin: "10px 0 22px" }}>
|
||||
Free for 10 people. No card. 60 seconds to set up.
|
||||
</p>
|
||||
<SocialRow/>
|
||||
<Divider label="or with email"/>
|
||||
<Field label="Work email"><Input value="mira@lattice.co" autofocus/></Field>
|
||||
<Field label="Password" hint="10+ chars · 1 number · 1 symbol">
|
||||
<Input type="password" value="••••••••••" trailingIcon={<Icon name="eye" size={14}/>}/>
|
||||
</Field>
|
||||
<Checkbox checked label="I agree to Vibn's Terms and Privacy Policy."
|
||||
style={{ margin: "4px 0 16px" }}/>
|
||||
<Button full>Create my workspace <Icon name="arrow" size={13}/></Button>
|
||||
</AuthGlassShell>
|
||||
);
|
||||
|
||||
// ─── App: design canvas with all 4 themes side-by-side ──────
|
||||
const W = 1300, H = 800;
|
||||
const themes = ["minimal", "dark", "glass", "editorial"];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection
|
||||
id="foundations"
|
||||
title="Foundations"
|
||||
subtitle="The same token surfaces, in four themes. tokens.css is the source of truth."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`f-${t}`} label={`${t}`} width={W} height={H}>
|
||||
<ThemeFrame theme={t}><Foundations theme={t}/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="forms"
|
||||
title="Forms & buttons"
|
||||
subtitle="Button variants, every field type, controls, tabs."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`forms-${t}`} label={`${t}`} width={W} height={H}>
|
||||
<ThemeFrame theme={t}><FormAtoms/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="display"
|
||||
title="Display & feedback"
|
||||
subtitle="Badges, avatars, banners, table, modal."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`display-${t}`} label={`${t}`} width={W} height={H + 100}>
|
||||
<ThemeFrame theme={t}><DisplayAtoms/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="shells-app"
|
||||
title="In-product shells · Sidebar"
|
||||
subtitle="One shell, four themes."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`side-${t}`} label={`Sidebar · ${t}`} width={W} height={H}>
|
||||
<ThemeFrame theme={t}><SidebarDemo/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="shells-topbar"
|
||||
title="In-product shells · Topbar"
|
||||
subtitle="Breadcrumb + ⌘K + tabs."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`top-${t}`} label={`Topbar · ${t}`} width={W} height={H}>
|
||||
<ThemeFrame theme={t}><TopbarDemo/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="shells-rail"
|
||||
title="In-product shells · Rail"
|
||||
subtitle="Icon rail + secondary panel."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`rail-${t}`} label={`Rail · ${t}`} width={W} height={H}>
|
||||
<ThemeFrame theme={t}><RailDemo/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="auth-centered"
|
||||
title="Auth shells · Centered card"
|
||||
subtitle="Sign-in card on a soft background."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`auth-c-${t}`} label={`Centered · ${t}`} width={W} height={H}>
|
||||
<ThemeFrame theme={t}><AuthCenteredDemo/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="auth-split"
|
||||
title="Auth shells · Split hero"
|
||||
subtitle="Storytelling panel on the left, form on the right."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`auth-s-${t}`} label={`Split · ${t}`} width={W} height={H}>
|
||||
<ThemeFrame theme={t}><AuthSplitDemo/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="auth-glass"
|
||||
title="Auth shells · Glass card"
|
||||
subtitle="Frosted card on a vibrant backdrop."
|
||||
>
|
||||
{themes.map(t => (
|
||||
<DCArtboard key={t} id={`auth-g-${t}`} label={`Glass · ${t}`} width={W} height={H}>
|
||||
<ThemeFrame theme={t}><AuthGlassDemo/></ThemeFrame>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
440
design-templates/VIBN (2)/app-chrome.jsx
Normal file
@@ -0,0 +1,440 @@
|
||||
// ============================================================
|
||||
// app-chrome.jsx — three reusable in-product nav shells.
|
||||
// Each exposes `<children>` as the main content slot so page
|
||||
// bodies (Customer/Dashboard/Admin) can be dropped into any
|
||||
// nav style.
|
||||
//
|
||||
// All three share the invented brand "Lattice" + same workspace
|
||||
// name + same user, so swapping the chrome reads as one product
|
||||
// in three nav layouts.
|
||||
// ============================================================
|
||||
|
||||
// ── Tiny stroke-icon helper ─────────────────────────────────
|
||||
const Icon = ({ d, size = 16, sw = 1.6 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
|
||||
);
|
||||
|
||||
// Common Tabler-style paths
|
||||
const P = {
|
||||
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
|
||||
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
|
||||
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
|
||||
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
|
||||
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
|
||||
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
|
||||
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
|
||||
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
|
||||
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/></>,
|
||||
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
|
||||
settings: <><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8L4.2 7a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/><circle cx="12" cy="12" r="3"/></>,
|
||||
plus: <path d="M12 5v14M5 12h14"/>,
|
||||
chevron: <path d="m6 9 6 6 6-6"/>,
|
||||
chevR: <path d="m9 6 6 6-6 6"/>,
|
||||
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
|
||||
hash: <path d="M9 3l-2 18M17 3l-2 18M3 9h18M2 15h18"/>,
|
||||
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
|
||||
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
|
||||
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
|
||||
dot: <circle cx="12" cy="12" r="3"/>,
|
||||
};
|
||||
|
||||
const SANS = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif";
|
||||
|
||||
// ── Brand mark, shared ───────────────────────────────────────
|
||||
const LatticeMark = ({ size = 18 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<linearGradient id={`lg${size}`} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#6e6cff"/>
|
||||
<stop offset="100%" stopColor="#b15bff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#lg${size})`}/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// SHELL 1 — Sidebar (Linear/Notion/Twenty school)
|
||||
// ============================================================
|
||||
const navItems = [
|
||||
{ id: "home", label: "Home", icon: P.home },
|
||||
{ id: "inbox", label: "Inbox", icon: P.inbox, count: "12" },
|
||||
{ id: "tasks", label: "My tasks", icon: P.check, count: "3" },
|
||||
{ id: "_views", section: "Views" },
|
||||
{ id: "companies", label: "Companies", icon: P.building },
|
||||
{ id: "people", label: "People", icon: P.people },
|
||||
{ id: "deals", label: "Opportunities", icon: P.target },
|
||||
{ id: "_tools", section: "Tools" },
|
||||
{ id: "insights", label: "Insights", icon: P.bar },
|
||||
{ id: "flows", label: "Automations", icon: P.workflow },
|
||||
{ id: "docs", label: "Docs", icon: P.doc },
|
||||
{ id: "_admin", section: "Admin" },
|
||||
{ id: "settings", label: "Settings", icon: P.settings },
|
||||
];
|
||||
|
||||
const SidebarChrome = ({ active = "home", children }) => {
|
||||
const SideItem = ({ id, label, icon, count }) => {
|
||||
const isActive = id === active;
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "6px 10px", borderRadius: 6, fontSize: 13,
|
||||
color: isActive ? "#111" : "#5a5a5e",
|
||||
background: isActive ? "#ffffff" : "transparent",
|
||||
boxShadow: isActive ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
|
||||
fontWeight: isActive ? 500 : 400, cursor: "pointer",
|
||||
}}>
|
||||
<span style={{ color: isActive ? "#5e5cff" : "#8a8a90", display: "flex" }}>
|
||||
<Icon d={icon} size={15} />
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
{count && <span style={{
|
||||
fontSize: 11, color: "#8a8a90", fontVariantNumeric: "tabular-nums",
|
||||
}}>{count}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", display: "grid",
|
||||
gridTemplateColumns: "248px 1fr",
|
||||
background: "#fcfcfb", fontFamily: SANS, color: "#111",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<aside style={{
|
||||
background: "#f5f5f2", borderRight: "1px solid #e8e8e3",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}>
|
||||
<div style={{
|
||||
padding: "12px 12px", display: "flex", alignItems: "center", gap: 10,
|
||||
borderBottom: "1px solid #e8e8e3",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: 6,
|
||||
background: "linear-gradient(135deg, #6e6cff 0%, #b15bff 100%)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 700, fontSize: 13,
|
||||
}}>L</div>
|
||||
<div style={{ flex: 1, lineHeight: 1.2 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Lattice Studio</div>
|
||||
<div style={{ fontSize: 11, color: "#8a8a90" }}>Free · 4 members</div>
|
||||
</div>
|
||||
<span style={{ color: "#8a8a90", display: "flex" }}>
|
||||
<Icon d={P.chevron} size={14} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "10px 12px" }}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
|
||||
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 6,
|
||||
fontSize: 12, color: "#8a8a90",
|
||||
}}>
|
||||
<Icon d={P.search} size={14} />
|
||||
<span style={{ flex: 1 }}>Search…</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 5px", border: "1px solid #e0e0d8",
|
||||
borderRadius: 3, fontFamily: "monospace",
|
||||
}}>⌘K</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
|
||||
{navItems.map(item => item.section ? (
|
||||
<div key={item.id} style={{
|
||||
fontSize: 11, color: "#8a8a90", letterSpacing: "0.04em",
|
||||
padding: "14px 10px 6px", textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
}}>{item.section}</div>
|
||||
) : (
|
||||
<SideItem key={item.id} {...item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{
|
||||
padding: "10px 12px", borderTop: "1px solid #e8e8e3",
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: "50%", background: "#d4b8a8",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, fontWeight: 600, color: "#5a3e34",
|
||||
}}>MR</div>
|
||||
<div style={{ flex: 1, fontSize: 12 }}>
|
||||
<div style={{ fontWeight: 500 }}>Mira Reyes</div>
|
||||
<div style={{ color: "#8a8a90", fontSize: 11 }}>mira@lattice.co</div>
|
||||
</div>
|
||||
<span style={{ color: "#8a8a90", display: "flex" }}>
|
||||
<Icon d={P.chevron} size={14} />
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main style={{ overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// SHELL 2 — Icon rail + secondary panel (Slack/Discord/mail school)
|
||||
// ============================================================
|
||||
const railItems = [
|
||||
{ id: "home", icon: P.home, label: "Home" },
|
||||
{ id: "inbox", icon: P.inbox, label: "Inbox", badge: "9" },
|
||||
{ id: "companies", icon: P.building, label: "Companies" },
|
||||
{ id: "people", icon: P.people, label: "People" },
|
||||
{ id: "deals", icon: P.target, label: "Opportunities", badge: "2" },
|
||||
{ id: "insights", icon: P.bar, label: "Insights" },
|
||||
{ id: "settings", icon: P.settings, label: "Settings" },
|
||||
];
|
||||
|
||||
// Secondary panel content per active rail item — wrapper passes
|
||||
// in `secondary` so each page can ship its own.
|
||||
const RailChrome = ({ active = "home", secondary, children }) => {
|
||||
const activeItem = railItems.find(r => r.id === active) || railItems[0];
|
||||
|
||||
const RailIcon = ({ icon, isActive, badge }) => (
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: isActive ? "#5e5cff" : "transparent",
|
||||
color: isActive ? "#fff" : "#9a9aa6",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
cursor: "pointer", position: "relative",
|
||||
}}>
|
||||
<Icon d={icon} size={18} sw={2} />
|
||||
{badge && (
|
||||
<span style={{
|
||||
position: "absolute", top: -2, right: -2, minWidth: 16, height: 16,
|
||||
padding: "0 4px", background: "#ff4d5e", color: "#fff",
|
||||
borderRadius: 8, fontSize: 10, fontWeight: 600,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
border: "2px solid #08080c",
|
||||
}}>{badge}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", display: "grid",
|
||||
gridTemplateColumns: "72px 260px 1fr",
|
||||
background: "#0f0f14", color: "#e8e8ee", fontFamily: SANS,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{/* Icon rail */}
|
||||
<div style={{
|
||||
background: "#08080c", borderRight: "1px solid #ffffff08",
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
padding: "12px 0", gap: 6,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: "linear-gradient(135deg, #5e5cff 0%, #b15bff 100%)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 800, fontSize: 16, marginBottom: 6,
|
||||
}}>L</div>
|
||||
<div style={{ width: 24, height: 1, background: "#ffffff10", margin: "4px 0" }}></div>
|
||||
{railItems.map(r => (
|
||||
<RailIcon key={r.id} icon={r.icon} isActive={r.id === active} badge={r.badge} />
|
||||
))}
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<RailIcon icon={P.spark} />
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: "50%", marginTop: 4,
|
||||
background: "#d4b8a8", display: "flex", alignItems: "center",
|
||||
justifyContent: "center", fontSize: 12, fontWeight: 600, color: "#5a3e34",
|
||||
border: "2px solid #08080c", boxShadow: "0 0 0 2px #5e5cff",
|
||||
position: "relative",
|
||||
}}>MR
|
||||
<span style={{
|
||||
position: "absolute", bottom: -2, right: -2, width: 11, height: 11,
|
||||
background: "#22c55e", borderRadius: "50%", border: "2px solid #08080c",
|
||||
}}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary panel */}
|
||||
<div style={{
|
||||
background: "#13131a", borderRight: "1px solid #ffffff08",
|
||||
display: "flex", flexDirection: "column", overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
padding: "16px 16px 12px", borderBottom: "1px solid #ffffff08",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<span style={{ fontSize: 15, fontWeight: 600 }}>{activeItem.label}</span>
|
||||
<span style={{ color: "#9a9aa6", display: "flex" }}>
|
||||
<Icon d={P.more} size={16} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "7px 10px", background: "#08080c",
|
||||
borderRadius: 7, fontSize: 12, color: "#9a9aa6",
|
||||
}}>
|
||||
<Icon d={P.search} size={13} />
|
||||
<span style={{ flex: 1 }}>Jump to…</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 5px",
|
||||
background: "#ffffff08", borderRadius: 3, fontFamily: "monospace",
|
||||
}}>⌘K</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: "10px 8px", flex: 1, overflowY: "auto" }}>
|
||||
{secondary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<main style={{ overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Convenience list item for the rail's secondary panel (dark theme)
|
||||
const RailItem = ({ leading, label, sub, trailing, active }) => (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "8px 10px", borderRadius: 6, fontSize: 13,
|
||||
color: active ? "#fff" : "#dcdce4",
|
||||
background: active ? "#ffffff14" : "transparent",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
{leading}
|
||||
<div style={{ flex: 1, lineHeight: 1.2, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: active ? 500 : 400,
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
}}>{label}</div>
|
||||
{sub && <div style={{ fontSize: 11, color: "#7a7a85", marginTop: 1 }}>{sub}</div>}
|
||||
</div>
|
||||
{trailing}
|
||||
</div>
|
||||
);
|
||||
|
||||
const RailSectionHeader = ({ children, action }) => (
|
||||
<div style={{
|
||||
fontSize: 11, color: "#6a6a78", padding: "12px 10px 4px",
|
||||
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
}}>
|
||||
<span>{children}</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// SHELL 3 — Top horizontal + ⌘K (Vercel/Stripe school)
|
||||
// ============================================================
|
||||
const TopbarChrome = ({ tabs, activeTab, breadcrumb, children }) => {
|
||||
const TabItem = ({ label, isActive }) => (
|
||||
<div style={{
|
||||
padding: "16px 2px", margin: "0 12px", fontSize: 13, fontWeight: 500,
|
||||
color: isActive ? "#fff" : "#9a9aa6", whiteSpace: "nowrap",
|
||||
borderBottom: isActive ? "2px solid #fff" : "2px solid transparent",
|
||||
cursor: "pointer", position: "relative", top: 1,
|
||||
}}>{label}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", background: "#fafaf9",
|
||||
color: "#111", fontFamily: SANS, display: "flex", flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<header style={{ background: "#0a0a0a", color: "#fff" }}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 14,
|
||||
padding: "12px 24px",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14,
|
||||
}}>
|
||||
<LatticeMark size={20} />
|
||||
Lattice
|
||||
</div>
|
||||
{breadcrumb && (
|
||||
<>
|
||||
<span style={{ color: "#3a3a3a" }}>/</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: "50%", background: "#e8a87c",
|
||||
fontSize: 9, fontWeight: 700, color: "#5a3e34",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>MR</div>
|
||||
<span>mira-reyes</span>
|
||||
<span style={{ color: "#5a5a5e", display: "flex" }}><Icon d={P.chevron} size={12}/></span>
|
||||
</div>
|
||||
<span style={{ color: "#3a3a3a" }}>/</span>
|
||||
<div style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ whiteSpace: "nowrap" }}>{breadcrumb}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }}></div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "6px 12px", borderRadius: 8,
|
||||
background: "#1a1a1a", border: "1px solid #2a2a2a",
|
||||
color: "#9a9aa6", fontSize: 12, minWidth: 280,
|
||||
}}>
|
||||
<Icon d={P.search} size={13} />
|
||||
<span style={{ flex: 1 }}>Find or jump to anything…</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 5px", background: "#2a2a2a",
|
||||
borderRadius: 3, fontFamily: "monospace",
|
||||
}}>⌘K</span>
|
||||
</div>
|
||||
|
||||
<button style={{
|
||||
background: "transparent", border: "1px solid #2a2a2a",
|
||||
color: "#fff", padding: "5px 12px", borderRadius: 6,
|
||||
fontSize: 12, fontFamily: SANS, cursor: "pointer", whiteSpace: "nowrap",
|
||||
}}>Feedback</button>
|
||||
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer", position: "relative" }}>
|
||||
<Icon d={P.bell} size={16}/>
|
||||
<span style={{
|
||||
position: "absolute", top: -2, right: -2, width: 7, height: 7,
|
||||
background: "#5e5cff", borderRadius: "50%",
|
||||
}}></span>
|
||||
</span>
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: "50%", background: "#d4b8a8",
|
||||
fontSize: 11, fontWeight: 600, color: "#5a3e34",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
}}>MR</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center",
|
||||
padding: "0 16px", borderBottom: "1px solid #1a1a1a",
|
||||
overflowX: "auto",
|
||||
}}>
|
||||
{(tabs || []).map(t => (
|
||||
<TabItem key={t} label={t} isActive={t === activeTab} />
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, {
|
||||
Icon, P, SANS, LatticeMark,
|
||||
SidebarChrome, RailChrome, RailItem, RailSectionHeader, TopbarChrome,
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"accent": ["#ff6b47", "#ffae9a", "#9c3a1f"],
|
||||
"heroVariant": "promise",
|
||||
"heroVariant": "owner",
|
||||
"showStopMarker": true,
|
||||
"showLivePill": false
|
||||
}/*EDITMODE-END*/;
|
||||
@@ -55,8 +55,10 @@ function App() {
|
||||
<Hero onStart={handleStart} variant={t.heroVariant} />
|
||||
<Wall />
|
||||
<CrossedOut />
|
||||
<Stack />
|
||||
<Journey />
|
||||
<Audience />
|
||||
<Mission />
|
||||
<Closing />
|
||||
</main>
|
||||
<Footer />
|
||||
@@ -80,12 +82,13 @@ function App() {
|
||||
/>
|
||||
</TweakSection>
|
||||
<TweakSection label="Hero">
|
||||
<TweakRadio
|
||||
<TweakSelect
|
||||
label="Headline"
|
||||
value={t.heroVariant}
|
||||
options={[
|
||||
{ value: "quote", label: "Reddit quote" },
|
||||
{ value: "promise", label: "The promise" },
|
||||
{ value: "owner", label: "Owner — 'Stop paying rent'" },
|
||||
{ value: "promise", label: "Promise — 'Keep vibing'" },
|
||||
{ value: "quote", label: "Quote — 'I built my product…'" },
|
||||
]}
|
||||
onChange={(v) => setTweak("heroVariant", v)}
|
||||
/>
|
||||
@@ -125,7 +128,7 @@ function Nav({ scrolled }) {
|
||||
<a href="#">Stories</a>
|
||||
</div>
|
||||
<div className="nav-cta">
|
||||
<a href="#" className="btn btn-ghost" style={{ display: "inline-flex" }}>Sign in</a>
|
||||
<a href="Sign In.html" className="btn btn-ghost" style={{ display: "inline-flex" }}>Sign in</a>
|
||||
<a href="Beta Signup.html" className="btn btn-primary">
|
||||
Request invite <Arrow size={12} />
|
||||
</a>
|
||||
BIN
design-templates/VIBN (2)/assets/logo-black.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
1314
design-templates/VIBN (2)/atlas-pages.jsx
Normal file
@@ -5,23 +5,26 @@ const AUDIENCE = [
|
||||
{
|
||||
label: "Small business owners",
|
||||
icon: "shop",
|
||||
headline: "Stop renting. Build the tool that actually fits.",
|
||||
quote: "I'm paying $312/month for software that does 60% of what I need and zero of the rest.",
|
||||
source: "u/coffeeshop_owner · r/smallbusiness",
|
||||
answer: "Build the tool that actually fits your shop — exactly your workflow, no monthly fee bleed.",
|
||||
answer: "Replace the whole stack with one tool that matches your workflow — bookings, customers, invoicing, all in one place. Owned by you.",
|
||||
},
|
||||
{
|
||||
label: "Freelancers building for clients",
|
||||
label: "Freelancers & local builders",
|
||||
icon: "spark",
|
||||
headline: "Become the craftsman who builds for businesses in your town.",
|
||||
quote: "My client wants a quote tool. I can mock the frontend in a day. The backend? Two weeks I don't have.",
|
||||
source: "u/agency_of_one · r/freelance",
|
||||
answer: "Deliver the whole thing — login, data, hosting — in the same chat where you built the screens.",
|
||||
answer: "Deliver the whole thing — login, data, hosting, polish — in the same chat where you built the screens. Bill for the system, not the hours.",
|
||||
},
|
||||
{
|
||||
label: "Anyone with an idea",
|
||||
label: "Quiet entrepreneurs",
|
||||
icon: "spark2",
|
||||
quote: "I built the homepage in an afternoon. Then the AI told me to 'just deploy it' and I cried.",
|
||||
source: "u/first_time_builder · r/sideproject",
|
||||
answer: "No deploys. No GitHub. No fear. The thing you described is online, with logins, ready for users.",
|
||||
headline: "Build a business without ever picking up the phone.",
|
||||
quote: "I want to build my thing, ship my thing, and find my customers — without doing sales calls or talking to a developer.",
|
||||
source: "u/asynchronous_human · r/indiehackers",
|
||||
answer: "No deploys. No GitHub. No cold outreach. The thing you described is online, with logins, marketing on autopilot — ready for the right people to find it.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -81,6 +84,15 @@ function Audience() {
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.a-headline {
|
||||
margin: 8px 0 0;
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.005em;
|
||||
font-weight: 500;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.a-quote {
|
||||
margin: 18px 0 0;
|
||||
@@ -132,7 +144,7 @@ function Audience() {
|
||||
People who have an idea — not a stack.
|
||||
</h2>
|
||||
<p className="audience-sub">
|
||||
If you've ever felt this, Vibn was built for you.
|
||||
Three people who feel the same thing — different ways to fix it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -141,6 +153,7 @@ function Audience() {
|
||||
<div className="a-card" key={a.label}>
|
||||
<div className="a-icon"><AudienceIcon name={a.icon} /></div>
|
||||
<div className="a-label">{a.label}</div>
|
||||
<p className="a-headline">{a.headline}</p>
|
||||
|
||||
<div className="a-quote">
|
||||
"{a.quote}"
|
||||
123
design-templates/VIBN (2)/auth-shared.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// Shared auth-page primitives. Both Sign In and Sign Up use these.
|
||||
|
||||
function Logo({ size = 26 }) {
|
||||
return (
|
||||
<a href="index.html" className="logo">
|
||||
<span className="logo-mark" style={{ width: size, height: size }}>
|
||||
<svg viewBox="0 0 36 32" width="74%" height="74%" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
|
||||
<rect x="22.5" y="23" width="9.5" height="3.8" rx="0.7" className="logo-caret" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>vibn</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ rightLink }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<Logo />
|
||||
{rightLink && (
|
||||
<a href={rightLink.href} className="topbar-back">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M13 8H3M7 4 3 8l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
{rightLink.label}
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Glows() {
|
||||
return (
|
||||
<>
|
||||
<div className="auth-glow" style={{
|
||||
width: 700, height: 700,
|
||||
top: -150, left: "50%", transform: "translateX(-50%)",
|
||||
background: "radial-gradient(circle at center, oklch(0.74 0.175 35 / 0.22) 0%, transparent 62%)",
|
||||
}} />
|
||||
<div className="auth-glow" style={{
|
||||
width: 500, height: 500,
|
||||
bottom: -100, left: 0,
|
||||
background: "radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.20) 0%, transparent 62%)",
|
||||
}} />
|
||||
<div className="auth-glow" style={{
|
||||
width: 450, height: 450,
|
||||
top: "50%", right: -150,
|
||||
background: "radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.15) 0%, transparent 62%)",
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow({ size = 14 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Google "G" mark — inline SVG so we don't need to bundle an asset.
|
||||
function GoogleIcon({ size = 16 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 18 18" aria-hidden="true">
|
||||
<path fill="#EA4335" d="M9 3.6c1.3 0 2.5.5 3.4 1.3l2.5-2.5C13.4 1 11.3.1 9 .1 5.5.1 2.4 2.1.9 5.1l2.9 2.3C4.5 5.2 6.6 3.6 9 3.6Z"/>
|
||||
<path fill="#34A853" d="M17.6 9.2c0-.6-.1-1.2-.2-1.8H9v3.4h4.9c-.2 1.1-.9 2-1.9 2.6l2.9 2.3c1.7-1.6 2.7-3.9 2.7-6.5Z"/>
|
||||
<path fill="#FBBC05" d="M3.8 10.7c-.2-.6-.3-1.1-.3-1.7s.1-1.2.3-1.7L.9 5C.3 6.2 0 7.5 0 9s.3 2.8.9 4l2.9-2.3Z"/>
|
||||
<path fill="#4285F4" d="M9 17.9c2.4 0 4.4-.8 5.9-2.2l-2.9-2.3c-.8.5-1.8.9-3 .9-2.3 0-4.3-1.6-5-3.7L1.1 12.9C2.6 15.9 5.6 17.9 9 17.9Z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Apple mark (filled apple silhouette)
|
||||
function AppleIcon({ size = 16 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M14.7 13.1c-.4 1-1 1.9-1.7 2.5-.5.5-1.1.7-1.7.7-.6 0-1-.2-1.7-.5-.7-.3-1.3-.5-1.8-.5s-1.2.2-1.9.5c-.7.3-1.2.4-1.6.5-.6 0-1.2-.2-1.7-.7C2 14.8 1.4 13.6.9 12c-.5-1.7-.7-3.3-.6-4.7.1-1.6.7-2.9 1.7-3.9C2.8 2.7 3.8 2.2 5 2.2c.4 0 .9.1 1.5.4s1 .4 1.2.4c.2 0 .7-.1 1.4-.4.7-.3 1.3-.4 1.7-.4 1 .1 1.9.5 2.6 1.3-.9.6-1.4 1.5-1.4 2.6 0 .9.3 1.6 1 2.2.3.3.6.5 1 .6-.1.2-.2.5-.3.7Zm-3-12c0 .8-.3 1.6-.9 2.4-.7.9-1.6 1.5-2.6 1.4 0-.1 0-.2 0-.3 0-.8.3-1.6.9-2.3.3-.4.7-.7 1.1-.9.4-.2.9-.4 1.4-.4 0 .1 0 .1.1.1Z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MailIcon({ size = 18 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<rect x="2.5" y="4.5" width="15" height="11" rx="1.5"/>
|
||||
<path d="M3.5 6 10 11l6.5-5"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrustStrip({ items }) {
|
||||
return (
|
||||
<div className="auth-trust">
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="sep">·</span>}
|
||||
<span>{item}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// useResendTimer — manages a countdown for the "Resend in 30s" CTA after the
|
||||
// magic-link confirmation state. Returns [remaining, restart].
|
||||
function useResendTimer(initialSeconds = 30) {
|
||||
const [left, setLeft] = React.useState(initialSeconds);
|
||||
React.useEffect(() => {
|
||||
if (left <= 0) return undefined;
|
||||
const t = setTimeout(() => setLeft(left - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [left]);
|
||||
const restart = () => setLeft(initialSeconds);
|
||||
return [left, restart];
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
Logo, TopBar, Glows, Arrow,
|
||||
GoogleIcon, AppleIcon, MailIcon,
|
||||
TrustStrip, useResendTimer,
|
||||
});
|
||||
431
design-templates/VIBN (2)/auth-style-a.jsx
Normal file
@@ -0,0 +1,431 @@
|
||||
// ============================================================
|
||||
// auth-screens.jsx — Sign-in / Sign-up / Onboarding for the
|
||||
// Lattice brand, in three aesthetic directions that match the
|
||||
// three nav styles from the prior file:
|
||||
//
|
||||
// A · Light minimal ← Sidebar / Notion school
|
||||
// B · Dark split-hero ← Topbar / Vercel school
|
||||
// C · Glass aurora ← Floating-pill marketing school
|
||||
//
|
||||
// Each style ships all three screens. Shared <LatticeMark>,
|
||||
// <SocialBtn>, <Field> components keep the family resemblance.
|
||||
// ============================================================
|
||||
|
||||
// ── Shared atoms ────────────────────────────────────────────
|
||||
// Branded "G" / "MS" social logos drawn inline as little glyphs
|
||||
// so there are no missing-asset placeholders. They're recognizable
|
||||
// without using actual brand marks.
|
||||
const GoogleGlyph = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M21.6 12.2c0-.7-.1-1.4-.2-2H12v3.8h5.4c-.2 1.2-.9 2.2-2 2.9v2.4h3.2c1.9-1.7 3-4.3 3-7.1z"/>
|
||||
<path fill="#34A853" d="M12 22c2.7 0 5-.9 6.6-2.4l-3.2-2.4c-.9.6-2 1-3.4 1-2.6 0-4.8-1.7-5.6-4.1H3.1v2.5C4.8 19.8 8.2 22 12 22z"/>
|
||||
<path fill="#FBBC05" d="M6.4 14.1c-.2-.6-.3-1.3-.3-2s.1-1.4.3-2V7.6H3.1C2.4 9 2 10.4 2 12s.4 3 1.1 4.4l3.3-2.3z"/>
|
||||
<path fill="#EA4335" d="M12 6c1.5 0 2.8.5 3.8 1.5l2.9-2.9C16.9 3.1 14.7 2 12 2 8.2 2 4.8 4.2 3.1 7.6l3.3 2.5C7.2 7.7 9.4 6 12 6z"/>
|
||||
</svg>
|
||||
);
|
||||
const MicrosoftGlyph = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="1" y="1" width="10" height="10" fill="#F25022"/>
|
||||
<rect x="13" y="1" width="10" height="10" fill="#7FBA00"/>
|
||||
<rect x="1" y="13" width="10" height="10" fill="#00A4EF"/>
|
||||
<rect x="13" y="13" width="10" height="10" fill="#FFB900"/>
|
||||
</svg>
|
||||
);
|
||||
const AppleGlyph = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16.4 12.7c0-2.6 2.1-3.8 2.2-3.9-1.2-1.7-3-2-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.9-1.7 0-3.3 1-4.2 2.6-1.8 3.1-.5 7.7 1.3 10.2.9 1.2 1.9 2.6 3.2 2.5 1.3-.1 1.8-.8 3.4-.8 1.6 0 2 .8 3.4.8 1.4 0 2.3-1.2 3.1-2.5.7-1 1.1-2 1.4-3-2.6-1-3.1-3.7-3.1-3.9zM13.5 5c.7-.9 1.2-2.1 1.1-3.4-1 .1-2.3.7-3 1.6-.7.8-1.3 2-1.1 3.2 1.2.1 2.3-.6 3-1.4z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const sansAuth = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
|
||||
|
||||
// Tiny stroke icon helper (re-defining locally so this file is standalone)
|
||||
const Icn = ({ d, size = 16, sw = 1.6 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
|
||||
);
|
||||
const Pa = {
|
||||
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
|
||||
check: <path d="M5 12l5 5L20 7"/>,
|
||||
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
|
||||
chevR: <path d="m9 6 6 6-6 6"/>,
|
||||
chevL: <path d="m15 6-6 6 6 6"/>,
|
||||
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
|
||||
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
|
||||
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
|
||||
shield: <path d="M12 2 4 5v7c0 5 3.5 9 8 10 4.5-1 8-5 8-10V5z"/>,
|
||||
briefcase: <><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/></>,
|
||||
};
|
||||
|
||||
// Brand mark (gradient triangle), shared
|
||||
const Mark = ({ size = 20, mono }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
{mono ? (
|
||||
<path d="M3 20 L12 4 L21 20 Z" fill="currentColor"/>
|
||||
) : (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id={`mk${size}`} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#6e6cff"/>
|
||||
<stop offset="100%" stopColor="#b15bff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mk${size})`}/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// STYLE A — LIGHT MINIMAL (Notion / Linear school)
|
||||
// Centered card on warm neutral, no flourish, lots of air.
|
||||
// ============================================================
|
||||
const a = {
|
||||
bg: "#f5f5f2", surface: "#ffffff",
|
||||
border: "#e8e8e3", borderStrong: "#d8d8d2",
|
||||
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
|
||||
accent: "#5e5cff", accentText: "#fff",
|
||||
};
|
||||
const fontA = sansAuth;
|
||||
|
||||
const AFieldLabel = ({ children, optional }) => (
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
fontSize: 12, fontWeight: 500, color: a.text, marginBottom: 6,
|
||||
}}>
|
||||
<span>{children}</span>
|
||||
{optional && <span style={{ color: a.muted, fontWeight: 400 }}>optional</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AField = ({ label, value, placeholder, hint, type = "text", icon, optional }) => (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{label && <AFieldLabel optional={optional}>{label}</AFieldLabel>}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "10px 12px", borderRadius: 7,
|
||||
background: "#fff", border: `1px solid ${a.border}`,
|
||||
fontSize: 13, color: value ? a.text : a.muted,
|
||||
boxShadow: "0 1px 0 #00000004",
|
||||
}}>
|
||||
{icon && <span style={{ color: a.muted, display: "flex" }}>{icon}</span>}
|
||||
<span style={{ flex: 1 }}>{value || placeholder}</span>
|
||||
{type === "password" && <span style={{ color: a.muted, display: "flex" }}>
|
||||
<Icn d={Pa.eye} size={14} /></span>}
|
||||
</div>
|
||||
{hint && <div style={{ fontSize: 11, color: a.muted, marginTop: 5 }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ASocial = ({ children, glyph }) => (
|
||||
<button style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
padding: "10px 12px", borderRadius: 7, background: "#fff",
|
||||
border: `1px solid ${a.border}`, color: a.text, fontSize: 13,
|
||||
fontFamily: fontA, fontWeight: 500, cursor: "pointer",
|
||||
}}>
|
||||
{glyph}
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const APrimary = ({ children, full = true }) => (
|
||||
<button style={{
|
||||
width: full ? "100%" : "auto",
|
||||
padding: "11px 18px", borderRadius: 7,
|
||||
background: "#111", color: "#fff", border: "none",
|
||||
fontSize: 13, fontWeight: 500, fontFamily: fontA, cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
|
||||
}}>{children}</button>
|
||||
);
|
||||
|
||||
const ACardShell = ({ children, foot }) => (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", background: a.bg,
|
||||
color: a.text, fontFamily: fontA,
|
||||
display: "grid", gridTemplateRows: "auto 1fr auto",
|
||||
}}>
|
||||
{/* Top bar: brand on left, support on right */}
|
||||
<header style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "20px 28px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14 }}>
|
||||
<Mark size={20} />
|
||||
Lattice
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: a.subtext, display: "flex", gap: 18 }}>
|
||||
<span>Status</span>
|
||||
<span>Docs</span>
|
||||
<span>Sign in ↗</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Centered card */}
|
||||
<main style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
|
||||
<div style={{
|
||||
width: 420, padding: "32px 36px", borderRadius: 12,
|
||||
background: a.surface, border: `1px solid ${a.border}`,
|
||||
boxShadow: "0 1px 2px #0000000a, 0 8px 32px -12px #0000000f",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer band */}
|
||||
<footer style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "16px 28px", fontSize: 11, color: a.muted,
|
||||
}}>
|
||||
<span>© 2026 Lattice Studio · Made in Copenhagen</span>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<span>Privacy</span><span>Terms</span><span>Security</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ASignIn = () => (
|
||||
<ACardShell>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em" }}>
|
||||
Welcome back
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
|
||||
Sign in to your Lattice workspace.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
|
||||
<ASocial glyph={<GoogleGlyph/>}>Google</ASocial>
|
||||
<ASocial glyph={<MicrosoftGlyph/>}>Microsoft</ASocial>
|
||||
<ASocial glyph={<span style={{ color: a.text, display: "flex" }}><AppleGlyph/></span>}>Apple</ASocial>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
fontSize: 11, color: a.muted, margin: "0 0 18px",
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: a.border }}></div>
|
||||
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
|
||||
<div style={{ flex: 1, height: 1, background: a.border }}></div>
|
||||
</div>
|
||||
|
||||
<AField label="Email" value="mira@lattice.co" />
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
fontSize: 12, fontWeight: 500, color: a.text, marginBottom: 6,
|
||||
}}>
|
||||
<span>Password</span>
|
||||
<span style={{ color: a.accent, cursor: "pointer", fontWeight: 400 }}>Forgot?</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "10px 12px", borderRadius: 7, background: "#fff",
|
||||
border: `1px solid ${a.border}`, fontSize: 13, color: a.text,
|
||||
letterSpacing: "0.2em",
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>••••••••••</span>
|
||||
<span style={{ color: a.muted, display: "flex" }}><Icn d={Pa.eye} size={14}/></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: 3, background: "#111",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#fff",
|
||||
}}><Icn d={Pa.check} size={10} sw={2.4}/></div>
|
||||
<span style={{ fontSize: 12, color: a.subtext }}>Keep me signed in for 30 days</span>
|
||||
</div>
|
||||
|
||||
<APrimary>Sign in →</APrimary>
|
||||
|
||||
<div style={{ fontSize: 12, color: a.subtext, marginTop: 18, textAlign: "center" }}>
|
||||
New here? <span style={{ color: a.text, fontWeight: 500, cursor: "pointer" }}>
|
||||
Create an account
|
||||
</span>
|
||||
</div>
|
||||
</ACardShell>
|
||||
);
|
||||
|
||||
const ASignUp = () => (
|
||||
<ACardShell>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em" }}>
|
||||
Create your workspace
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
|
||||
Free for up to 10 people. No card needed.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
|
||||
<ASocial glyph={<GoogleGlyph/>}>Continue with Google</ASocial>
|
||||
<ASocial glyph={<MicrosoftGlyph/>}>Microsoft</ASocial>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
fontSize: 11, color: a.muted, margin: "0 0 18px",
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: a.border }}></div>
|
||||
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
|
||||
<div style={{ flex: 1, height: 1, background: a.border }}></div>
|
||||
</div>
|
||||
|
||||
<AField label="Full name" placeholder="Mira Reyes" />
|
||||
<AField label="Work email" value="mira@lattice.co"
|
||||
hint="We'll send a 6-digit code to confirm." />
|
||||
<AField label="Password" value="••••••••••" type="password"
|
||||
hint="At least 10 characters, including a number." />
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "4px 0 18px" }}>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: 3, marginTop: 2,
|
||||
background: "#fff", border: `1px solid ${a.borderStrong}`,
|
||||
}}></div>
|
||||
<span style={{ fontSize: 12, color: a.subtext, lineHeight: 1.5 }}>
|
||||
I agree to Lattice's <span style={{ color: a.text, fontWeight: 500 }}>Terms</span> and{" "}
|
||||
<span style={{ color: a.text, fontWeight: 500 }}>Privacy Policy</span>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<APrimary>Create workspace →</APrimary>
|
||||
|
||||
<div style={{ fontSize: 12, color: a.subtext, marginTop: 18, textAlign: "center" }}>
|
||||
Already have one? <span style={{ color: a.text, fontWeight: 500, cursor: "pointer" }}>
|
||||
Sign in
|
||||
</span>
|
||||
</div>
|
||||
</ACardShell>
|
||||
);
|
||||
|
||||
const AOnboarding = () => {
|
||||
const Step = ({ n, label, state }) => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: "50%",
|
||||
background: state === "done" ? "#22c55e" : state === "active" ? "#111" : "transparent",
|
||||
color: state === "todo" ? a.muted : "#fff",
|
||||
border: state === "todo" ? `1px solid ${a.borderStrong}` : "none",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
||||
}}>{state === "done" ? <Icn d={Pa.check} size={12} sw={2.4} /> : n}</div>
|
||||
<div style={{ fontSize: 12, color: state === "todo" ? a.muted : a.text,
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Tile = ({ title, sub, selected, icon }) => (
|
||||
<div style={{
|
||||
padding: 14, borderRadius: 8, cursor: "pointer", textAlign: "left",
|
||||
border: selected ? `1.5px solid ${a.accent}` : `1px solid ${a.border}`,
|
||||
background: selected ? "#f6f5ff" : "#fff",
|
||||
boxShadow: selected ? `0 0 0 3px ${a.accent}1a` : "0 1px 0 #00000004",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 7, marginBottom: 10,
|
||||
background: selected ? a.accent : "#f1f0eb",
|
||||
color: selected ? "#fff" : a.subtext,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>{icon}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 2 }}>{title}</div>
|
||||
<div style={{ fontSize: 11, color: a.muted, lineHeight: 1.4 }}>{sub}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", background: a.bg, color: a.text,
|
||||
fontFamily: fontA, display: "grid", gridTemplateRows: "auto 1fr auto",
|
||||
}}>
|
||||
<header style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "20px 28px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14 }}>
|
||||
<Mark size={20} /> Lattice
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: a.subtext }}>Step 2 of 4 · ⌘. to skip</div>
|
||||
</header>
|
||||
|
||||
<main style={{
|
||||
padding: "12px 28px 28px",
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 640, padding: "30px 36px 36px", borderRadius: 14,
|
||||
background: a.surface, border: `1px solid ${a.border}`,
|
||||
boxShadow: "0 1px 2px #0000000a, 0 8px 32px -12px #0000000f",
|
||||
}}>
|
||||
{/* Stepper */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, marginBottom: 22 }}>
|
||||
<Step n="1" label="Account" state="done" />
|
||||
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
|
||||
<Step n="2" label="Workspace" state="active" />
|
||||
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
|
||||
<Step n="3" label="Invite team" state="todo" />
|
||||
<div style={{ flex: 1, height: 1, background: a.border, margin: "0 6px" }}></div>
|
||||
<Step n="4" label="Import" state="todo" />
|
||||
</div>
|
||||
|
||||
<h1 style={{ fontSize: 24, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
|
||||
Tell us about your work
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: a.subtext, margin: "6px 0 22px" }}>
|
||||
We'll tailor your workspace based on this. You can change it later.
|
||||
</p>
|
||||
|
||||
<AField label="Workspace name" value="Lattice Studio"
|
||||
hint="This is how your team will see it." />
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 500, margin: "16px 0 8px" }}>
|
||||
What do you do?
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
|
||||
<Tile title="Sales & Revenue" sub="Pipeline, contacts, deals" selected icon={<Icn d={Pa.bolt} size={15}/>} />
|
||||
<Tile title="Operations" sub="Vendors, ops, suppliers" icon={<Icn d={Pa.shield} size={15}/>} />
|
||||
<Tile title="Product" sub="Customers, feedback, research" icon={<Icn d={Pa.spark} size={15}/>} />
|
||||
<Tile title="Recruiting" sub="Candidates, pipeline" icon={<Icn d={Pa.briefcase} size={15}/>} />
|
||||
<Tile title="Just exploring" sub="I'll figure it out" icon={<Icn d={Pa.star} size={15}/>} />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 500, margin: "20px 0 8px" }}>How big is your team?</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{["Just me", "2–10", "11–50", "51–200", "200+"].map((s, i) => (
|
||||
<div key={s} style={{
|
||||
flex: 1, padding: "9px 8px", textAlign: "center", borderRadius: 7,
|
||||
fontSize: 12, fontWeight: 500, cursor: "pointer",
|
||||
border: i === 1 ? `1.5px solid ${a.accent}` : `1px solid ${a.border}`,
|
||||
background: i === 1 ? "#f6f5ff" : "#fff",
|
||||
color: i === 1 ? a.accent : a.subtext,
|
||||
}}>{s}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", marginTop: 26, alignItems: "center",
|
||||
}}>
|
||||
<button style={{
|
||||
background: "transparent", border: "none", color: a.subtext,
|
||||
fontSize: 13, fontFamily: fontA, cursor: "pointer", padding: 0,
|
||||
}}>← Back</button>
|
||||
<APrimary full={false}>Continue <Icn d={Pa.arrow} size={13}/></APrimary>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "16px 28px", fontSize: 11, color: a.muted,
|
||||
}}>
|
||||
<span>Press <code style={{
|
||||
background: "#fff", padding: "1px 5px", borderRadius: 3,
|
||||
border: `1px solid ${a.border}`, fontFamily: "monospace",
|
||||
}}>⌘ + Enter</code> to continue</span>
|
||||
<span>Need help? <span style={{ color: a.text, fontWeight: 500 }}>support@lattice.co</span></span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { ASignIn, ASignUp, AOnboarding });
|
||||
548
design-templates/VIBN (2)/auth-style-b.jsx
Normal file
@@ -0,0 +1,548 @@
|
||||
// ============================================================
|
||||
// auth-style-b.jsx — Dark split-hero auth (Vercel / Stripe school).
|
||||
// Two-column: marketing/storytelling on the left, form on the right.
|
||||
// Inverts gracefully for onboarding (single dark surface, full width).
|
||||
// ============================================================
|
||||
|
||||
const b = {
|
||||
bg: "#0a0a0a", left: "#0f0f14",
|
||||
surface: "#101015", surface2: "#16161d",
|
||||
border: "#1f1f25", borderStrong: "#2a2a32",
|
||||
text: "#fafafa", subtext: "#a8a8b0", muted: "#6a6a72",
|
||||
accent: "#ffffff", accentText: "#0a0a0a",
|
||||
brandA: "#5e5cff", brandB: "#b15bff",
|
||||
};
|
||||
const fontB = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
|
||||
|
||||
// Local icon helper (kept independent of other auth files)
|
||||
const IcnB = ({ d, size = 16, sw = 1.6 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
|
||||
);
|
||||
const PB = {
|
||||
check: <path d="M5 12l5 5L20 7"/>,
|
||||
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
|
||||
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
|
||||
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
|
||||
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
|
||||
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
|
||||
};
|
||||
|
||||
const MarkB = ({ size = 22 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<linearGradient id={`mkb${size}`} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#6e6cff"/>
|
||||
<stop offset="100%" stopColor="#b15bff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mkb${size})`}/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BField = ({ label, value, placeholder, type, icon, hint, optional, autofocus }) => (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{label && (
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
fontSize: 12, fontWeight: 500, color: b.subtext, marginBottom: 6,
|
||||
}}>
|
||||
<span>{label}</span>
|
||||
{optional && <span style={{ color: b.muted, fontWeight: 400 }}>optional</span>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "10px 12px", borderRadius: 8,
|
||||
background: b.surface2,
|
||||
border: `1px solid ${autofocus ? b.brandA : b.border}`,
|
||||
boxShadow: autofocus ? `0 0 0 3px ${b.brandA}33` : "none",
|
||||
fontSize: 13, color: value ? b.text : b.muted,
|
||||
}}>
|
||||
{icon && <span style={{ color: b.muted, display: "flex" }}>{icon}</span>}
|
||||
<span style={{ flex: 1, letterSpacing: type === "password" ? "0.2em" : "0" }}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
{type === "password" && <span style={{ color: b.muted, display: "flex" }}><IcnB d={PB.eye} size={14}/></span>}
|
||||
</div>
|
||||
{hint && <div style={{ fontSize: 11, color: b.muted, marginTop: 5 }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const BSocial = ({ children, glyph }) => (
|
||||
<button style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
padding: "10px 14px", borderRadius: 8, background: b.surface2,
|
||||
border: `1px solid ${b.border}`, color: b.text, fontSize: 13,
|
||||
fontFamily: fontB, fontWeight: 500, cursor: "pointer",
|
||||
}}>{glyph}<span>{children}</span></button>
|
||||
);
|
||||
|
||||
const BPrimary = ({ children, full = true }) => (
|
||||
<button style={{
|
||||
width: full ? "100%" : "auto",
|
||||
padding: "11px 18px", borderRadius: 8,
|
||||
background: b.accent, color: b.accentText, border: "none",
|
||||
fontSize: 13, fontWeight: 600, fontFamily: fontB, cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
}}>{children}</button>
|
||||
);
|
||||
|
||||
// LEFT hero panel — short storytelling
|
||||
const HeroPanel = ({ headline, sub, badge }) => (
|
||||
<div style={{
|
||||
background: b.left, color: b.text, padding: "32px 44px 36px",
|
||||
display: "flex", flexDirection: "column", height: "100%",
|
||||
position: "relative", overflow: "hidden",
|
||||
borderRight: `1px solid ${b.border}`,
|
||||
}}>
|
||||
{/* Decorative grid + glow */}
|
||||
<div style={{
|
||||
position: "absolute", inset: 0, pointerEvents: "none", opacity: 0.5,
|
||||
backgroundImage: `linear-gradient(${b.border} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${b.border} 1px, transparent 1px)`,
|
||||
backgroundSize: "40px 40px",
|
||||
maskImage: "radial-gradient(circle at 50% 30%, #000 40%, transparent 80%)",
|
||||
}}></div>
|
||||
<div style={{
|
||||
position: "absolute", top: -180, left: -120, width: 540, height: 540,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${b.brandA}40, transparent 60%)`,
|
||||
filter: "blur(60px)",
|
||||
}}></div>
|
||||
<div style={{
|
||||
position: "absolute", bottom: -200, right: -120, width: 500, height: 500,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${b.brandB}40, transparent 60%)`,
|
||||
filter: "blur(60px)",
|
||||
}}></div>
|
||||
|
||||
{/* Brand */}
|
||||
<div style={{
|
||||
position: "relative", display: "flex", alignItems: "center", gap: 10,
|
||||
fontWeight: 600, fontSize: 16,
|
||||
}}>
|
||||
<MarkB size={22} />
|
||||
Lattice
|
||||
</div>
|
||||
|
||||
{/* Mid */}
|
||||
<div style={{ position: "relative", marginTop: "auto" }}>
|
||||
{badge && (
|
||||
<div style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 8,
|
||||
padding: "4px 12px 4px 4px", borderRadius: 999,
|
||||
background: "#ffffff08", border: "1px solid #ffffff14",
|
||||
fontSize: 11, color: b.subtext, marginBottom: 22,
|
||||
}}>
|
||||
<span style={{
|
||||
padding: "2px 8px", background: b.brandA, color: "#fff",
|
||||
borderRadius: 999, fontWeight: 600, fontSize: 10,
|
||||
}}>NEW</span>
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
<h2 style={{
|
||||
fontSize: 38, lineHeight: 1.05, margin: 0, letterSpacing: "-0.03em",
|
||||
fontWeight: 500, textWrap: "balance", maxWidth: 360,
|
||||
}}>{headline}</h2>
|
||||
<p style={{ fontSize: 14, color: b.subtext, marginTop: 14, lineHeight: 1.5, maxWidth: 340 }}>
|
||||
{sub}
|
||||
</p>
|
||||
|
||||
{/* Trust row */}
|
||||
<div style={{
|
||||
marginTop: 32, paddingTop: 22, borderTop: `1px solid ${b.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 11, color: b.muted, letterSpacing: "0.1em",
|
||||
textTransform: "uppercase", fontWeight: 500, marginBottom: 12,
|
||||
}}>Used by teams at</div>
|
||||
<div style={{
|
||||
display: "flex", gap: 22, alignItems: "center",
|
||||
fontWeight: 600, fontSize: 15, color: b.subtext,
|
||||
}}>
|
||||
<span>Halcyon</span><span>·</span><span>Kestrel</span>
|
||||
<span>·</span><span>Mossbank</span><span>·</span><span>Verra</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom quote */}
|
||||
<div style={{
|
||||
position: "relative", marginTop: 28, padding: "16px 18px",
|
||||
borderRadius: 12, background: "#ffffff06",
|
||||
border: `1px solid ${b.border}`,
|
||||
}}>
|
||||
<p style={{ fontSize: 13, color: b.text, margin: 0, lineHeight: 1.5 }}>
|
||||
"Replaced three tools in our first week. Lattice is what every
|
||||
CRM should have been."
|
||||
</p>
|
||||
<div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: "50%", background: "#a8c8e8",
|
||||
fontSize: 11, fontWeight: 600, color: "#1a3a5e",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>DP</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ fontWeight: 500 }}>Devi Patel</div>
|
||||
<div style={{ color: b.muted, fontSize: 11 }}>Head of Sales, Halcyon</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 2-col shell: hero on left, form on right
|
||||
const BSplitShell = ({ hero, children }) => (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", background: b.bg,
|
||||
color: b.text, fontFamily: fontB,
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr",
|
||||
}}>
|
||||
{hero}
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", padding: "32px 56px",
|
||||
position: "relative",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "flex-end", fontSize: 13, color: b.subtext,
|
||||
}}>
|
||||
<span>Need help? <span style={{ color: b.text, fontWeight: 500 }}>support</span></span>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
<div style={{ width: 380 }}>{children}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", gap: 18, fontSize: 11, color: b.muted, justifyContent: "flex-end",
|
||||
}}>
|
||||
<span>Privacy</span><span>Terms</span><span>Security</span>
|
||||
<span>v4.2.1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BSignIn = () => (
|
||||
<BSplitShell hero={
|
||||
<HeroPanel
|
||||
badge="Lattice 4.0 · agents that draft for you"
|
||||
headline="The workspace where good ideas compound."
|
||||
sub="One luminous surface for docs, canvases, contacts and pipelines. Built by people tired of switching tabs."
|
||||
/>}>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
|
||||
Sign in to Lattice
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: b.subtext, margin: "6px 0 24px" }}>
|
||||
Welcome back. Pick how you'd like to continue.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 18 }}>
|
||||
<BSocial glyph={<GoogleGlyph/>}>Continue with Google</BSocial>
|
||||
<BSocial glyph={<MicrosoftGlyph/>}>Continue with Microsoft</BSocial>
|
||||
<BSocial glyph={<span style={{ color: b.text, display: "flex" }}><AppleGlyph/></span>}>Continue with Apple</BSocial>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
fontSize: 11, color: b.muted, margin: "0 0 18px",
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: b.border }}></div>
|
||||
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
|
||||
<div style={{ flex: 1, height: 1, background: b.border }}></div>
|
||||
</div>
|
||||
|
||||
<BField label="Email" value="mira@lattice.co" autofocus />
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
fontSize: 12, fontWeight: 500, color: b.subtext, marginBottom: 6,
|
||||
}}>
|
||||
<span>Password</span>
|
||||
<span style={{ color: b.text, cursor: "pointer", fontWeight: 500 }}>Forgot?</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "10px 12px", borderRadius: 8,
|
||||
background: b.surface2, border: `1px solid ${b.border}`,
|
||||
fontSize: 13, color: b.text, letterSpacing: "0.2em",
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>••••••••••</span>
|
||||
<span style={{ color: b.muted, display: "flex" }}><IcnB d={PB.eye} size={14}/></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BPrimary>Sign in <IcnB d={PB.arrow} size={13}/></BPrimary>
|
||||
|
||||
<div style={{
|
||||
marginTop: 22, padding: "10px 14px", borderRadius: 8,
|
||||
background: b.surface2, border: `1px solid ${b.border}`,
|
||||
fontSize: 12, color: b.subtext, display: "flex",
|
||||
alignItems: "center", gap: 10,
|
||||
}}>
|
||||
<IcnB d={PB.bolt} size={14}/>
|
||||
<span style={{ flex: 1 }}>SAML / SSO for your company?</span>
|
||||
<span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>Use SSO →</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: b.subtext, marginTop: 22, textAlign: "center" }}>
|
||||
New here? <span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>
|
||||
Create an account
|
||||
</span>
|
||||
</div>
|
||||
</BSplitShell>
|
||||
);
|
||||
|
||||
const BSignUp = () => (
|
||||
<BSplitShell hero={
|
||||
<HeroPanel
|
||||
headline="Start a Lattice workspace in 30 seconds."
|
||||
sub="Free for up to 10 people. No card required. SSO and SCIM on the Pro plan."
|
||||
/>}>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
|
||||
Create your account
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: b.subtext, margin: "6px 0 24px" }}>
|
||||
You'll set up your workspace in the next step.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 18 }}>
|
||||
<BSocial glyph={<GoogleGlyph/>}>Continue with Google</BSocial>
|
||||
<BSocial glyph={<MicrosoftGlyph/>}>Continue with Microsoft</BSocial>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
fontSize: 11, color: b.muted, margin: "0 0 18px",
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: b.border }}></div>
|
||||
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
|
||||
<div style={{ flex: 1, height: 1, background: b.border }}></div>
|
||||
</div>
|
||||
|
||||
<BField label="Full name" value="Mira Reyes" autofocus />
|
||||
<BField label="Work email" value="mira@lattice.co"
|
||||
hint="We'll send a 6-digit code to confirm." />
|
||||
<BField label="Password" value="••••••••••" type="password"
|
||||
hint="At least 10 chars · 1 number · 1 symbol." />
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "8px 0 18px" }}>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: 3, marginTop: 2,
|
||||
background: b.surface2, border: `1px solid ${b.borderStrong}`,
|
||||
}}></div>
|
||||
<span style={{ fontSize: 12, color: b.subtext, lineHeight: 1.5 }}>
|
||||
I agree to the <span style={{ color: b.text, fontWeight: 500 }}>Terms</span> and{" "}
|
||||
<span style={{ color: b.text, fontWeight: 500 }}>Privacy Policy</span>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<BPrimary>Create account <IcnB d={PB.arrow} size={13}/></BPrimary>
|
||||
|
||||
<div style={{ fontSize: 12, color: b.subtext, marginTop: 22, textAlign: "center" }}>
|
||||
Already have an account? <span style={{ color: b.text, fontWeight: 500, cursor: "pointer" }}>
|
||||
Sign in
|
||||
</span>
|
||||
</div>
|
||||
</BSplitShell>
|
||||
);
|
||||
|
||||
const BOnboarding = () => {
|
||||
// Full-bleed dark onboarding screen (workspace customization step)
|
||||
const Step = ({ n, label, state }) => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: "50%",
|
||||
background: state === "done" ? b.brandA : state === "active" ? b.text : "transparent",
|
||||
color: state === "active" ? b.bg : state === "done" ? "#fff" : b.muted,
|
||||
border: state === "todo" ? `1px solid ${b.borderStrong}` : "none",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, fontWeight: 600,
|
||||
}}>{state === "done" ? <IcnB d={PB.check} size={12} sw={2.4}/> : n}</div>
|
||||
<div style={{
|
||||
fontSize: 12, color: state === "todo" ? b.muted : b.text, whiteSpace: "nowrap",
|
||||
}}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ColorSwatch = ({ color, selected }) => (
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, background: color, cursor: "pointer",
|
||||
boxShadow: selected ? `0 0 0 2px ${b.bg}, 0 0 0 4px ${b.text}` : "none",
|
||||
}}></div>
|
||||
);
|
||||
|
||||
const Template = ({ title, sub, icon, selected, color }) => (
|
||||
<div style={{
|
||||
padding: 18, borderRadius: 12, cursor: "pointer", textAlign: "left",
|
||||
border: selected ? `1.5px solid ${color}` : `1px solid ${b.border}`,
|
||||
background: selected ? `${color}10` : b.surface,
|
||||
position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8, marginBottom: 12,
|
||||
background: selected ? color : "#ffffff10",
|
||||
color: selected ? "#fff" : b.subtext,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>{icon}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4 }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: b.muted, lineHeight: 1.4 }}>{sub}</div>
|
||||
{selected && (
|
||||
<div style={{
|
||||
position: "absolute", top: 14, right: 14,
|
||||
width: 18, height: 18, borderRadius: "50%", background: color,
|
||||
color: "#fff", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}><IcnB d={PB.check} size={11} sw={2.4}/></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", background: b.bg, color: b.text,
|
||||
fontFamily: fontB, display: "grid", gridTemplateRows: "auto 1fr auto",
|
||||
position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
{/* Decorative aurora */}
|
||||
<div style={{
|
||||
position: "absolute", top: -200, right: -150, width: 600, height: 600,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${b.brandA}33, transparent 60%)`,
|
||||
filter: "blur(80px)", pointerEvents: "none",
|
||||
}}></div>
|
||||
|
||||
{/* Top stepper bar */}
|
||||
<header style={{
|
||||
padding: "20px 56px", display: "flex", alignItems: "center", gap: 14,
|
||||
borderBottom: `1px solid ${b.border}`, background: "#0a0a0d", position: "relative",
|
||||
}}>
|
||||
<MarkB size={22} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Lattice</span>
|
||||
<div style={{ width: 1, height: 18, background: b.border, margin: "0 12px" }}></div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, flex: 1 }}>
|
||||
<Step n="1" label="Account" state="done" />
|
||||
<div style={{ width: 32, height: 1, background: b.border }}></div>
|
||||
<Step n="2" label="Workspace" state="done" />
|
||||
<div style={{ width: 32, height: 1, background: b.border }}></div>
|
||||
<Step n="3" label="Personalise" state="active" />
|
||||
<div style={{ width: 32, height: 1, background: b.border }}></div>
|
||||
<Step n="4" label="Invite" state="todo" />
|
||||
<div style={{ width: 32, height: 1, background: b.border }}></div>
|
||||
<Step n="5" label="Import" state="todo" />
|
||||
</div>
|
||||
|
||||
<button style={{
|
||||
background: "transparent", border: "none", color: b.subtext,
|
||||
fontSize: 12, fontFamily: fontB, cursor: "pointer",
|
||||
}}>Skip setup →</button>
|
||||
</header>
|
||||
|
||||
<main style={{
|
||||
padding: "44px 64px 24px", position: "relative", overflowY: "auto",
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 11, color: b.muted, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", fontWeight: 500, marginBottom: 12,
|
||||
}}>Step 3 of 5 · Personalise</div>
|
||||
<h1 style={{
|
||||
fontSize: 40, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
|
||||
textAlign: "center", textWrap: "balance",
|
||||
}}>
|
||||
Pick a template to get going.
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: 14, color: b.subtext, margin: "12px 0 36px",
|
||||
textAlign: "center", maxWidth: 540, lineHeight: 1.5,
|
||||
}}>
|
||||
We'll pre-fill your workspace with the right objects, views and
|
||||
fields. Everything is editable later.
|
||||
</p>
|
||||
|
||||
{/* Template grid */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14,
|
||||
width: "100%", maxWidth: 1080,
|
||||
}}>
|
||||
<Template title="Sales CRM" sub="Pipeline, contacts, deals, activity" icon={<IcnB d={PB.bolt} size={16}/>} selected color="#5e5cff" />
|
||||
<Template title="Operations" sub="Vendors, suppliers, contracts" icon={<IcnB d={PB.spark} size={16}/>} color="#22c55e" />
|
||||
<Template title="Recruiting" sub="Candidates, roles, interview loops" icon={<IcnB d={PB.star} size={16}/>} color="#f6c560" />
|
||||
<Template title="Blank workspace" sub="Start from zero — I'll define my own objects" icon={<IcnB d={PB.spark} size={16}/>} color="#b15bff" />
|
||||
</div>
|
||||
|
||||
{/* Theme + accent strip */}
|
||||
<div style={{
|
||||
marginTop: 32, padding: "20px 24px", borderRadius: 14,
|
||||
background: b.surface, border: `1px solid ${b.border}`,
|
||||
width: "100%", maxWidth: 1080,
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr", gap: 32,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Theme</div>
|
||||
<div style={{ fontSize: 12, color: b.muted, marginBottom: 14 }}>
|
||||
Light, dark, or follow the system.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{[
|
||||
["Light", "#fafaf9", "#111"],
|
||||
["Dark", "#0f0f14", "#fafafa"],
|
||||
["System", "linear-gradient(135deg, #fafaf9 50%, #0f0f14 50%)", "#888"],
|
||||
].map(([n, bg, ink], i) => (
|
||||
<div key={n} style={{
|
||||
flex: 1, padding: 4, borderRadius: 10, cursor: "pointer",
|
||||
border: i === 1 ? `1.5px solid ${b.brandA}` : `1px solid ${b.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
height: 56, borderRadius: 6, background: bg,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: ink, fontSize: 11, fontWeight: 500,
|
||||
}}>{n}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Accent</div>
|
||||
<div style={{ fontSize: 12, color: b.muted, marginBottom: 14 }}>
|
||||
The color of your CTAs, links and focus rings.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<ColorSwatch color="#5e5cff" selected />
|
||||
<ColorSwatch color="#22c55e" />
|
||||
<ColorSwatch color="#f6c560" />
|
||||
<ColorSwatch color="#ff5b6b" />
|
||||
<ColorSwatch color="#b15bff" />
|
||||
<ColorSwatch color="#06b6d4" />
|
||||
<ColorSwatch color="#fafafa" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer style={{
|
||||
padding: "16px 56px", display: "flex", justifyContent: "space-between",
|
||||
alignItems: "center", borderTop: `1px solid ${b.border}`,
|
||||
background: "#0a0a0d", position: "relative",
|
||||
}}>
|
||||
<button style={{
|
||||
background: "transparent", border: `1px solid ${b.border}`, color: b.text,
|
||||
padding: "9px 16px", borderRadius: 8, fontSize: 13, fontFamily: fontB, cursor: "pointer",
|
||||
}}>← Back</button>
|
||||
<span style={{ fontSize: 12, color: b.muted }}>
|
||||
Press <code style={{
|
||||
background: b.surface2, padding: "1px 6px", borderRadius: 3,
|
||||
border: `1px solid ${b.border}`, fontFamily: "monospace",
|
||||
}}>⌘ + Enter</code> to continue
|
||||
</span>
|
||||
<BPrimary full={false}>Continue <IcnB d={PB.arrow} size={13}/></BPrimary>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { BSignIn, BSignUp, BOnboarding });
|
||||
535
design-templates/VIBN (2)/auth-style-c.jsx
Normal file
@@ -0,0 +1,535 @@
|
||||
// ============================================================
|
||||
// auth-style-c.jsx — Glass aurora auth (marketing-flavoured).
|
||||
// Vibrant gradient background, frosted card, soft floating
|
||||
// chrome. Onboarding becomes a kept-it-light, swipey wizard.
|
||||
// ============================================================
|
||||
|
||||
const cc = {
|
||||
bg: "#08081a", text: "#ffffff",
|
||||
subtext: "rgba(255,255,255,0.7)", muted: "rgba(255,255,255,0.5)",
|
||||
glass: "rgba(255,255,255,0.06)",
|
||||
glassStrong: "rgba(255,255,255,0.1)",
|
||||
glassBorder: "rgba(255,255,255,0.14)",
|
||||
brandA: "#7a78ff", brandB: "#b15bff", brandC: "#00e5b3",
|
||||
};
|
||||
const fontC = "'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
|
||||
|
||||
const IcnC = ({ d, size = 16, sw = 1.6 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
|
||||
);
|
||||
const PC = {
|
||||
check: <path d="M5 12l5 5L20 7"/>,
|
||||
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
|
||||
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
|
||||
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
|
||||
upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5M12 3v12"/></>,
|
||||
pen: <><path d="m12 19 7-7 3 3-7 7-3-1z"/><path d="m18 13-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></>,
|
||||
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
|
||||
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
|
||||
};
|
||||
|
||||
const MarkC = ({ size = 22 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<linearGradient id={`mkc${size}`} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#7a78ff"/>
|
||||
<stop offset="100%" stopColor="#b15bff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#mkc${size})`}/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Reusable aurora background — also used by onboarding
|
||||
const AuroraBg = () => (
|
||||
<>
|
||||
<div style={{
|
||||
position: "absolute", top: -250, left: -150, width: 700, height: 700,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${cc.brandA} 0%, transparent 60%)`,
|
||||
filter: "blur(100px)", opacity: 0.55, pointerEvents: "none",
|
||||
}}></div>
|
||||
<div style={{
|
||||
position: "absolute", top: 100, right: -200, width: 600, height: 600,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${cc.brandB} 0%, transparent 60%)`,
|
||||
filter: "blur(100px)", opacity: 0.5, pointerEvents: "none",
|
||||
}}></div>
|
||||
<div style={{
|
||||
position: "absolute", bottom: -200, left: "30%", width: 600, height: 600,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${cc.brandC} 0%, transparent 60%)`,
|
||||
filter: "blur(100px)", opacity: 0.35, pointerEvents: "none",
|
||||
}}></div>
|
||||
{/* Grain overlay */}
|
||||
<div style={{
|
||||
position: "absolute", inset: 0, pointerEvents: "none", opacity: 0.6,
|
||||
backgroundImage: `radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px)`,
|
||||
backgroundSize: "3px 3px",
|
||||
}}></div>
|
||||
</>
|
||||
);
|
||||
|
||||
// Frosted glass top nav (the pill from the marketing nav, smaller)
|
||||
const GlassTopNav = ({ right }) => (
|
||||
<header style={{
|
||||
position: "absolute", top: 22, left: "50%", transform: "translateX(-50%)",
|
||||
zIndex: 10, width: "max-content", whiteSpace: "nowrap",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
padding: "8px 8px 8px 18px",
|
||||
background: cc.glass, backdropFilter: "blur(24px)", WebkitBackdropFilter: "blur(24px)",
|
||||
border: `1px solid ${cc.glassBorder}`, borderRadius: 999,
|
||||
boxShadow: "0 18px 50px -20px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.1)",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
marginRight: 16, fontWeight: 600, fontSize: 14, color: "#fff",
|
||||
}}>
|
||||
<MarkC size={18} /> Lattice
|
||||
</div>
|
||||
{["Product", "Pricing", "Docs"].map(l => (
|
||||
<button key={l} style={{
|
||||
background: "transparent", border: "none", color: "#fff",
|
||||
padding: "7px 12px", borderRadius: 999, fontSize: 13,
|
||||
fontFamily: fontC, cursor: "pointer",
|
||||
}}>{l}</button>
|
||||
))}
|
||||
<div style={{
|
||||
width: 1, height: 18, background: cc.glassBorder, margin: "0 6px",
|
||||
}}></div>
|
||||
{right}
|
||||
</header>
|
||||
);
|
||||
|
||||
const CField = ({ label, value, placeholder, type, hint, optional, autofocus }) => (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{label && (
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
fontSize: 12, fontWeight: 500, color: cc.subtext, marginBottom: 6,
|
||||
}}>
|
||||
<span>{label}</span>
|
||||
{optional && <span style={{ color: cc.muted, fontWeight: 400 }}>optional</span>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "11px 14px", borderRadius: 10,
|
||||
background: cc.glass,
|
||||
backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
|
||||
border: `1px solid ${autofocus ? cc.brandA : cc.glassBorder}`,
|
||||
boxShadow: autofocus ? `0 0 0 3px ${cc.brandA}33` : "none",
|
||||
fontSize: 13, color: value ? cc.text : cc.muted,
|
||||
}}>
|
||||
<span style={{ flex: 1, letterSpacing: type === "password" ? "0.2em" : "0" }}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
{type === "password" && <span style={{ color: cc.muted, display: "flex" }}><IcnC d={PC.eye} size={14}/></span>}
|
||||
</div>
|
||||
{hint && <div style={{ fontSize: 11, color: cc.muted, marginTop: 5 }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CSocial = ({ children, glyph }) => (
|
||||
<button style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
padding: "10px 14px", borderRadius: 10,
|
||||
background: cc.glass, backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
|
||||
border: `1px solid ${cc.glassBorder}`, color: cc.text, fontSize: 13,
|
||||
fontFamily: fontC, fontWeight: 500, cursor: "pointer",
|
||||
}}>{glyph}<span>{children}</span></button>
|
||||
);
|
||||
|
||||
const CPrimary = ({ children, full = true }) => (
|
||||
<button style={{
|
||||
width: full ? "100%" : "auto", padding: "12px 22px", borderRadius: 999,
|
||||
background: "#fff", color: "#08081a", border: "none",
|
||||
fontSize: 13, fontWeight: 600, fontFamily: fontC, cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
}}>{children}</button>
|
||||
);
|
||||
|
||||
const CSecondary = ({ children, full = false }) => (
|
||||
<button style={{
|
||||
width: full ? "100%" : "auto", padding: "12px 22px", borderRadius: 999,
|
||||
background: cc.glass, color: cc.text,
|
||||
backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)",
|
||||
border: `1px solid ${cc.glassBorder}`,
|
||||
fontSize: 13, fontWeight: 500, fontFamily: fontC, cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
}}>{children}</button>
|
||||
);
|
||||
|
||||
// Centered glass card shell — used by sign-in & sign-up
|
||||
const CCardShell = ({ children, eyebrow }) => (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", background: cc.bg, color: cc.text,
|
||||
fontFamily: fontC, position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
<AuroraBg/>
|
||||
<GlassTopNav right={
|
||||
<>
|
||||
<button style={{
|
||||
background: "transparent", border: "none", color: cc.text,
|
||||
padding: "7px 12px", borderRadius: 999, fontSize: 13,
|
||||
fontFamily: fontC, cursor: "pointer",
|
||||
}}>Sign in</button>
|
||||
<button style={{
|
||||
background: "#fff", color: "#08081a", border: "none",
|
||||
padding: "7px 14px", borderRadius: 999, fontSize: 13, fontWeight: 600,
|
||||
fontFamily: fontC, cursor: "pointer",
|
||||
}}>Get Lattice →</button>
|
||||
</>
|
||||
} />
|
||||
|
||||
<main style={{
|
||||
position: "relative", height: "100%",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: 24,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 460, padding: "32px 36px 36px", borderRadius: 22,
|
||||
background: cc.glass, backdropFilter: "blur(28px)", WebkitBackdropFilter: "blur(28px)",
|
||||
border: `1px solid ${cc.glassBorder}`,
|
||||
boxShadow: `0 30px 80px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12)`,
|
||||
}}>
|
||||
{eyebrow && (
|
||||
<div style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 8,
|
||||
padding: "4px 12px 4px 4px", borderRadius: 999,
|
||||
background: cc.glass, border: `1px solid ${cc.glassBorder}`,
|
||||
fontSize: 11, color: cc.subtext, marginBottom: 16,
|
||||
}}>
|
||||
<span style={{
|
||||
padding: "2px 8px", background: cc.brandA, color: "#fff",
|
||||
borderRadius: 999, fontWeight: 600, fontSize: 10,
|
||||
}}>BETA</span>
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer dots */}
|
||||
<div style={{
|
||||
position: "absolute", bottom: 22, left: "50%", transform: "translateX(-50%)",
|
||||
fontSize: 11, color: cc.muted, zIndex: 5,
|
||||
display: "flex", gap: 18,
|
||||
}}>
|
||||
<span>Privacy</span><span>Terms</span><span>Security</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: "50%", background: cc.brandC,
|
||||
boxShadow: `0 0 8px ${cc.brandC}`,
|
||||
}}></span>
|
||||
All systems normal
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CSignIn = () => (
|
||||
<CCardShell>
|
||||
<h1 style={{
|
||||
fontSize: 32, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
|
||||
}}>Welcome back.</h1>
|
||||
<p style={{ fontSize: 14, color: cc.subtext, margin: "10px 0 26px" }}>
|
||||
Sign in and pick up where you left off.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
|
||||
<CSocial glyph={<GoogleGlyph/>}>Google</CSocial>
|
||||
<CSocial glyph={<MicrosoftGlyph/>}>Microsoft</CSocial>
|
||||
<CSocial glyph={<span style={{ color: cc.text, display: "flex" }}><AppleGlyph/></span>}>Apple</CSocial>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
fontSize: 11, color: cc.muted, margin: "0 0 18px",
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
|
||||
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
|
||||
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
|
||||
</div>
|
||||
|
||||
<CField label="Email" value="mira@lattice.co" autofocus />
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
fontSize: 12, fontWeight: 500, color: cc.subtext, marginBottom: 6,
|
||||
}}>
|
||||
<span>Password</span>
|
||||
<span style={{ color: cc.text, cursor: "pointer", fontWeight: 500 }}>Forgot?</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "11px 14px", borderRadius: 10,
|
||||
background: cc.glass, backdropFilter: "blur(16px)",
|
||||
border: `1px solid ${cc.glassBorder}`,
|
||||
fontSize: 13, color: cc.text, letterSpacing: "0.2em",
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>••••••••••</span>
|
||||
<span style={{ color: cc.muted, display: "flex" }}><IcnC d={PC.eye} size={14}/></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CPrimary>Sign in <IcnC d={PC.arrow} size={13}/></CPrimary>
|
||||
|
||||
<div style={{ fontSize: 12, color: cc.subtext, marginTop: 22, textAlign: "center" }}>
|
||||
Don't have an account? <span style={{ color: cc.text, fontWeight: 500, cursor: "pointer" }}>
|
||||
Sign up for free
|
||||
</span>
|
||||
</div>
|
||||
</CCardShell>
|
||||
);
|
||||
|
||||
const CSignUp = () => (
|
||||
<CCardShell eyebrow="Lattice 4.0 · agents that draft for you">
|
||||
<h1 style={{
|
||||
fontSize: 32, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
|
||||
}}>
|
||||
Start your <span style={{
|
||||
background: `linear-gradient(90deg, ${cc.brandB}, ${cc.brandA}, ${cc.brandC})`,
|
||||
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
|
||||
fontStyle: "italic", fontWeight: 400,
|
||||
}}>workspace</span>.
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: cc.subtext, margin: "10px 0 22px" }}>
|
||||
Free for 10 people. No card. 60 seconds to set up.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
<CSocial glyph={<GoogleGlyph/>}>Google</CSocial>
|
||||
<CSocial glyph={<MicrosoftGlyph/>}>Microsoft</CSocial>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
fontSize: 11, color: cc.muted, margin: "0 0 16px",
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
|
||||
<span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>or with email</span>
|
||||
<div style={{ flex: 1, height: 1, background: cc.glassBorder }}></div>
|
||||
</div>
|
||||
|
||||
<CField label="Work email" value="mira@lattice.co" autofocus />
|
||||
<CField label="Password" value="••••••••••" type="password"
|
||||
hint="10+ chars · 1 number · 1 symbol — strong enough" />
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, margin: "10px 0 18px" }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, marginTop: 1,
|
||||
background: cc.brandA, color: "#fff",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}><IcnC d={PC.check} size={11} sw={2.4}/></div>
|
||||
<span style={{ fontSize: 12, color: cc.subtext, lineHeight: 1.5 }}>
|
||||
I agree to Lattice's <span style={{ color: cc.text, fontWeight: 500 }}>Terms</span> and{" "}
|
||||
<span style={{ color: cc.text, fontWeight: 500 }}>Privacy Policy</span>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CPrimary>Create my workspace <IcnC d={PC.arrow} size={13}/></CPrimary>
|
||||
|
||||
<div style={{ fontSize: 12, color: cc.subtext, marginTop: 22, textAlign: "center" }}>
|
||||
Already on Lattice? <span style={{ color: cc.text, fontWeight: 500, cursor: "pointer" }}>
|
||||
Sign in
|
||||
</span>
|
||||
</div>
|
||||
</CCardShell>
|
||||
);
|
||||
|
||||
const COnboarding = () => {
|
||||
// Glass invite-teammates step — a single big card on aurora bg.
|
||||
const ProgressDot = ({ state }) => (
|
||||
<div style={{
|
||||
width: state === "active" ? 26 : 10, height: 10,
|
||||
borderRadius: 999,
|
||||
background: state === "done" ? "#fff" :
|
||||
state === "active" ? cc.brandA : cc.glassStrong,
|
||||
transition: "all .2s",
|
||||
}}></div>
|
||||
);
|
||||
|
||||
const EmailRow = ({ email, role, status, color }) => (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
padding: "10px 12px", borderRadius: 10,
|
||||
background: cc.glass,
|
||||
backdropFilter: "blur(12px)",
|
||||
border: `1px solid ${cc.glassBorder}`,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: "50%", background: color,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, fontWeight: 600, color: "#3a2820",
|
||||
}}>{email.slice(0, 2).toUpperCase()}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13, color: cc.text, whiteSpace: "nowrap",
|
||||
overflow: "hidden", textOverflow: "ellipsis",
|
||||
}}>{email}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 6, padding: "3px 9px",
|
||||
borderRadius: 6, background: cc.glassStrong, fontSize: 11, color: cc.text,
|
||||
}}>{role} <IcnC d={<path d="m6 9 6 6 6-6"/>} size={11}/></div>
|
||||
{status && <span style={{
|
||||
fontSize: 11, padding: "2px 8px", borderRadius: 999,
|
||||
background: status === "queued" ? `${cc.brandA}33` : "#22c55e33",
|
||||
color: status === "queued" ? cc.brandA : "#7aff66",
|
||||
}}>{status}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", background: cc.bg, color: cc.text,
|
||||
fontFamily: fontC, position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
<AuroraBg/>
|
||||
|
||||
{/* Brand top-left + skip top-right */}
|
||||
<header style={{
|
||||
position: "absolute", top: 22, left: 32, zIndex: 10,
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
fontWeight: 600, fontSize: 14, color: cc.text,
|
||||
}}>
|
||||
<MarkC size={20} /> Lattice
|
||||
</header>
|
||||
<div style={{
|
||||
position: "absolute", top: 26, right: 32, zIndex: 10,
|
||||
fontSize: 12, color: cc.subtext,
|
||||
}}>
|
||||
Step 3 of 4 · <span style={{ color: cc.text, cursor: "pointer" }}>Skip</span>
|
||||
</div>
|
||||
|
||||
<main style={{
|
||||
position: "relative", height: "100%",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: 24,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 620, padding: "36px 40px 32px", borderRadius: 26,
|
||||
background: cc.glass, backdropFilter: "blur(28px)", WebkitBackdropFilter: "blur(28px)",
|
||||
border: `1px solid ${cc.glassBorder}`,
|
||||
boxShadow: `0 40px 100px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12)`,
|
||||
}}>
|
||||
{/* Progress dots */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
gap: 8, marginBottom: 26,
|
||||
}}>
|
||||
<ProgressDot state="done" />
|
||||
<ProgressDot state="done" />
|
||||
<ProgressDot state="active" />
|
||||
<ProgressDot state="todo" />
|
||||
</div>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: 34, fontWeight: 500, margin: 0, letterSpacing: "-0.03em",
|
||||
textAlign: "center", textWrap: "balance",
|
||||
}}>
|
||||
Lattice gets <em style={{
|
||||
fontStyle: "italic", fontWeight: 400,
|
||||
background: `linear-gradient(90deg, ${cc.brandB}, ${cc.brandC})`,
|
||||
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
|
||||
}}>better</em> with your team.
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: 14, color: cc.subtext, margin: "12px 0 26px",
|
||||
textAlign: "center", lineHeight: 1.5,
|
||||
}}>
|
||||
Invite the people you actually work with. You can always add more later.
|
||||
</p>
|
||||
|
||||
{/* Invite input + role */}
|
||||
<div style={{
|
||||
display: "flex", gap: 8, marginBottom: 14,
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "11px 14px", borderRadius: 12,
|
||||
background: cc.glass,
|
||||
backdropFilter: "blur(16px)",
|
||||
border: `1px solid ${cc.brandA}`,
|
||||
boxShadow: `0 0 0 3px ${cc.brandA}33`,
|
||||
fontSize: 13, color: cc.subtext,
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>name@company.com, separate with commas…</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
padding: "11px 14px", borderRadius: 12, fontSize: 13, color: cc.text,
|
||||
background: cc.glassStrong,
|
||||
backdropFilter: "blur(16px)",
|
||||
border: `1px solid ${cc.glassBorder}`,
|
||||
cursor: "pointer", whiteSpace: "nowrap",
|
||||
}}>
|
||||
Member <IcnC d={<path d="m6 9 6 6 6-6"/>} size={12}/>
|
||||
</div>
|
||||
<button style={{
|
||||
padding: "0 18px", borderRadius: 12, background: "#fff",
|
||||
color: "#08081a", border: "none", fontFamily: fontC,
|
||||
fontSize: 13, fontWeight: 600, cursor: "pointer",
|
||||
}}>Send</button>
|
||||
</div>
|
||||
|
||||
{/* Already queued list */}
|
||||
<div style={{
|
||||
fontSize: 11, color: cc.muted, letterSpacing: "0.08em",
|
||||
textTransform: "uppercase", fontWeight: 500, margin: "12px 0 8px",
|
||||
}}>To be invited · 3</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<EmailRow email="theo@lattice.co" role="Admin" status="queued" color="#c8e8a8" />
|
||||
<EmailRow email="devi@lattice.co" role="Admin" status="queued" color="#a8c8e8" />
|
||||
<EmailRow email="sun@lattice.co" role="Member" status="queued" color="#e8a87c" />
|
||||
</div>
|
||||
|
||||
{/* Shareable link */}
|
||||
<div style={{
|
||||
marginTop: 18, padding: "12px 14px", borderRadius: 12,
|
||||
background: cc.glass,
|
||||
backdropFilter: "blur(16px)",
|
||||
border: `1px dashed ${cc.glassBorder}`,
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
background: cc.glassStrong,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: cc.brandA,
|
||||
}}><IcnC d={PC.workflow} size={14}/></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>Or share an invite link</div>
|
||||
<div style={{
|
||||
fontSize: 12, color: cc.subtext, fontFamily: "monospace",
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
}}>lattice.app/join/mira-reyes-7f4ac…</div>
|
||||
</div>
|
||||
<button style={{
|
||||
padding: "6px 12px", borderRadius: 999, fontSize: 12,
|
||||
fontFamily: fontC, background: cc.glassStrong,
|
||||
border: `1px solid ${cc.glassBorder}`, color: cc.text, cursor: "pointer",
|
||||
}}>Copy link</button>
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", marginTop: 28, alignItems: "center",
|
||||
}}>
|
||||
<button style={{
|
||||
background: "transparent", border: "none", color: cc.subtext,
|
||||
fontSize: 13, fontFamily: fontC, cursor: "pointer", padding: 0,
|
||||
}}>I'll do this later</button>
|
||||
<CPrimary full={false}>Send invites & continue <IcnC d={PC.arrow} size={13}/></CPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { CSignIn, CSignUp, COnboarding });
|
||||
379
design-templates/VIBN (2)/auth.css
Normal file
@@ -0,0 +1,379 @@
|
||||
/* Shared auth styles — Sign In + Sign Up. Same tokens as the rest of the
|
||||
site; declared inline here so each auth page is self-sufficient. */
|
||||
|
||||
:root {
|
||||
--bg: oklch(0.155 0.008 60);
|
||||
--bg-1: oklch(0.185 0.009 60);
|
||||
--bg-2: oklch(0.225 0.010 60);
|
||||
--hairline: oklch(0.32 0.010 60 / 0.55);
|
||||
--hairline-2: oklch(0.40 0.012 60 / 0.35);
|
||||
--fg: oklch(0.97 0.005 80);
|
||||
--fg-dim: oklch(0.78 0.006 80);
|
||||
--fg-mute: oklch(0.58 0.006 80);
|
||||
--fg-faint: oklch(0.42 0.006 80);
|
||||
|
||||
--accent: oklch(0.74 0.175 35);
|
||||
--accent-soft: oklch(0.74 0.175 35 / 0.18);
|
||||
--accent-glow: oklch(0.74 0.175 35 / 0.35);
|
||||
--accent-fg: #1a0f0a;
|
||||
|
||||
--ok: oklch(0.78 0.16 155);
|
||||
|
||||
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; min-height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Ambient grid */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
mask-image: radial-gradient(ellipse 70% 70% at 50% 40%, #000 30%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 70% 70% at 50% 40%, #000 30%, transparent 80%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Film grain */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.035;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
||||
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
|
||||
p { margin: 0; }
|
||||
::selection { background: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
/* Layout */
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 100dvh;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative; z-index: 5;
|
||||
padding: 22px clamp(20px, 4vw, 48px);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.topbar a:hover { color: var(--fg); }
|
||||
.topbar-back {
|
||||
color: var(--fg-mute);
|
||||
font-size: 14px;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.logo-mark {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
|
||||
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
|
||||
display: grid; place-items: center;
|
||||
color: var(--accent-fg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-mark svg { display: block; }
|
||||
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
|
||||
@keyframes caret-blink { 50% { opacity: 0.25; } }
|
||||
|
||||
/* Main */
|
||||
.auth-main {
|
||||
flex: 1;
|
||||
display: grid; place-items: center;
|
||||
padding: clamp(20px, 4vw, 40px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ambient glows */
|
||||
.auth-glow {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
filter: blur(20px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.auth-card {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%; max-width: 440px;
|
||||
padding: 36px clamp(24px, 4vw, 40px) 32px;
|
||||
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.85), oklch(0.17 0.008 60 / 0.85));
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 22px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow:
|
||||
0 30px 80px -20px oklch(0 0 0 / 0.7),
|
||||
0 0 80px -30px var(--accent-glow);
|
||||
}
|
||||
.auth-card::before {
|
||||
content: "";
|
||||
position: absolute; left: 0; right: 0; top: 0; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.auth-eye {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
.auth-eye::before {
|
||||
content: ""; width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.auth-title {
|
||||
margin-top: 14px;
|
||||
font-size: clamp(26px, 3.4vw, 34px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.022em;
|
||||
line-height: 1.1;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.auth-title em {
|
||||
font-style: normal;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 30px var(--accent-glow);
|
||||
}
|
||||
.auth-sub {
|
||||
margin-top: 10px;
|
||||
color: var(--fg-mute);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.5;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.auth-form {
|
||||
margin-top: 24px;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.auth-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-mute);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding: 13px 16px;
|
||||
background: oklch(0.16 0.008 60 / 0.8);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 12px;
|
||||
color: var(--fg);
|
||||
font: 15px/1.5 var(--font-sans);
|
||||
outline: none;
|
||||
transition: border-color .15s, background .15s, box-shadow .15s;
|
||||
}
|
||||
.auth-input::placeholder { color: var(--fg-faint); }
|
||||
.auth-input:focus {
|
||||
border-color: oklch(0.74 0.175 35 / 0.65);
|
||||
background: oklch(0.18 0.009 60 / 0.95);
|
||||
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12), 0 0 30px -10px var(--accent-glow);
|
||||
}
|
||||
.auth-input.mono { font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.auth-input.mono::placeholder { letter-spacing: 0.08em; }
|
||||
|
||||
/* Buttons */
|
||||
.auth-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
||||
height: 50px; padding: 0 22px;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
transition: transform .12s, box-shadow .2s, background .2s;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.auth-btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
|
||||
0 10px 40px -10px var(--accent-glow),
|
||||
0 0 40px -8px var(--accent-glow);
|
||||
}
|
||||
.auth-btn-primary:hover { transform: translateY(-1px); }
|
||||
.auth-btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
|
||||
.auth-btn-ghost {
|
||||
background: oklch(0.20 0.009 60 / 0.6);
|
||||
border: 1px solid var(--hairline);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.auth-btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
|
||||
.auth-btn-ghost img, .auth-btn-ghost svg { flex-shrink: 0; }
|
||||
|
||||
/* Divider */
|
||||
.auth-divider {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin: 6px 0 2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.auth-divider::before, .auth-divider::after {
|
||||
content: ""; flex: 1; height: 1px; background: var(--hairline);
|
||||
}
|
||||
|
||||
/* OAuth row */
|
||||
.auth-oauth {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.auth-foot {
|
||||
margin-top: 26px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
.auth-foot a {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.auth-foot a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
.auth-fine {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.auth-fine a { color: var(--fg-mute); text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* Spinner */
|
||||
.auth-spinner {
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
border: 2px solid oklch(0 0 0 / 0.2);
|
||||
border-top-color: var(--accent-fg);
|
||||
animation: spin .9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Confirmed state */
|
||||
.auth-success {
|
||||
text-align: center;
|
||||
}
|
||||
.auth-success-badge {
|
||||
display: inline-grid; place-items: center;
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 16px;
|
||||
color: var(--accent);
|
||||
background: oklch(0.74 0.175 35 / 0.12);
|
||||
border: 1px solid oklch(0.74 0.175 35 / 0.4);
|
||||
box-shadow: 0 0 40px var(--accent-glow);
|
||||
}
|
||||
.auth-success .email-chip {
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 6px;
|
||||
background: oklch(0.74 0.175 35 / 0.12);
|
||||
border: 1px solid oklch(0.74 0.175 35 / 0.3);
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-tip {
|
||||
margin-top: 22px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: oklch(0.16 0.008 60 / 0.6);
|
||||
border: 1px solid var(--hairline);
|
||||
display: flex; gap: 12px;
|
||||
text-align: left;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.auth-tip-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px; height: 22px; border-radius: 6px;
|
||||
background: oklch(0.22 0.011 60);
|
||||
color: var(--fg-mute);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.auth-tip a { color: var(--accent); }
|
||||
.auth-tip a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* Resend */
|
||||
.auth-resend {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
margin-top: 20px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
.auth-resend button {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.auth-resend button:hover { color: oklch(0.78 0.16 35); }
|
||||
.auth-resend button[disabled] {
|
||||
color: var(--fg-faint);
|
||||
text-decoration: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Trust strip in footer area */
|
||||
.auth-trust {
|
||||
margin-top: 32px;
|
||||
display: flex; gap: 14px; justify-content: center; align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.auth-trust .sep { color: var(--fg-faint); opacity: 0.5; }
|
||||
@@ -56,12 +56,12 @@ function Closing() {
|
||||
|
||||
<div className="wrap closing-inner">
|
||||
<h2 className="closing-title">
|
||||
If you can <em>describe</em> it,
|
||||
<br/>you can <em>build</em> it.
|
||||
If you can <em>describe your business</em>,
|
||||
<br/>you can <em>build the tool</em> that runs it.
|
||||
</h2>
|
||||
<p className="closing-sub">
|
||||
And you can keep building it — all the way to customers.
|
||||
<br />No new tools. No homework. No going back to the wall.
|
||||
Owned by you. No monthly rent. No homework.
|
||||
<br/>All the way to customers.
|
||||
</p>
|
||||
|
||||
<div className="closing-cta">
|
||||
966
design-templates/VIBN (2)/design-canvas.jsx
Normal file
@@ -0,0 +1,966 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
||||
// inline-editable, and any artboard can be opened in a fullscreen focus
|
||||
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
||||
// via the host bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
// isolation:isolate contains artboard content's z-indexes so a
|
||||
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
|
||||
// the .dc-menu popover that drops into the top of the card.
|
||||
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||
// right. Single flex row; when the artboard's on-screen width is too
|
||||
// narrow for both the label yields (ellipsis, then hidden entirely below
|
||||
// ~4ch via the container query) and the buttons stay on the row.
|
||||
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
||||
' display:flex;align-items:center;container-type:inline-size}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
||||
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
||||
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
||||
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
||||
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
||||
// until the card is moused.
|
||||
'@container (max-width: 110px){',
|
||||
' .dc-labeltext{display:none}',
|
||||
' .dc-grip{opacity:0}',
|
||||
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
||||
'}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
||||
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
|
||||
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
|
||||
' font:inherit;transition:background .12s,color .12s}',
|
||||
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
// Slot hosting an open menu floats above later siblings (which otherwise
|
||||
// paint on top — same z-index:auto, later DOM order) so the popup isn't
|
||||
// clipped by the next card.
|
||||
'[data-dc-slot]:has(.dc-menu){z-index:10}',
|
||||
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
|
||||
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
|
||||
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
|
||||
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
|
||||
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
|
||||
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
|
||||
'.dc-menu .dc-danger{color:#c96442}',
|
||||
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
|
||||
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
||||
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
||||
// DCViewport on every transform update and inherits to all descendants —
|
||||
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
||||
// it the same way.
|
||||
//
|
||||
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
||||
// matter) with its world-space width set to card-width / inv-zoom so that
|
||||
// after counter-scaling its on-screen width exactly matches the card's —
|
||||
// that's what lets the container query + text-overflow behave against the
|
||||
// card's visible edge at every zoom level.
|
||||
//
|
||||
// The section head uses CSS zoom instead of transform so its layout box
|
||||
// grows with the counter-scale, pushing the card row down — otherwise the
|
||||
// constant-screen-size title would overflow into the (shrinking) world-
|
||||
// space gap and overlap the artboard headers at low zoom.
|
||||
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
||||
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
||||
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// Recursively unwrap React.Fragment so <>…</> grouping doesn't hide
|
||||
// DCSection/DCArtboard children from the type-based walks below.
|
||||
function dcFlatten(children) {
|
||||
const out = [];
|
||||
React.Children.forEach(children, (c) => {
|
||||
if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
|
||||
else out.push(c);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
||||
// artboards, focused artboard). Order/titles/labels/hidden persist to a
|
||||
// .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Fragments are flattened; wrapping in other
|
||||
// elements still opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
dcFlatten(children).forEach((sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const abs = [];
|
||||
dcFlatten(sec.props.children).forEach((ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (aid) abs.push([aid, ab]);
|
||||
});
|
||||
// hidden is scoped to one source revision — when the agent regenerates
|
||||
// (artboard-ID set changes), prior deletes don't apply to new content.
|
||||
const srcKey = abs.map(([k]) => k).join('\x1f');
|
||||
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
||||
const srcIds = [];
|
||||
abs.forEach(([aid, ab]) => {
|
||||
if (hidden.includes(aid)) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
// Persist viewport across reloads so the user lands back where they were
|
||||
// after an agent edit or browser refresh. The sandbox origin is already
|
||||
// per-project; pathname keeps multiple canvas files in one project apart.
|
||||
const tfKey = 'dc-viewport:' + location.pathname;
|
||||
const saveT = React.useRef(0);
|
||||
|
||||
const lastPostedScale = React.useRef();
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (!el) return;
|
||||
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
||||
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
||||
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
||||
// ticks leave scale unchanged — skip the cross-frame post for those.
|
||||
if (lastPostedScale.current !== scale) {
|
||||
lastPostedScale.current = scale;
|
||||
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
|
||||
}
|
||||
clearTimeout(saveT.current);
|
||||
saveT.current = setTimeout(() => {
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
}, 200);
|
||||
}, [tfKey]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const flush = () => {
|
||||
clearTimeout(saveT.current);
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
};
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
||||
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
||||
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
||||
apply();
|
||||
}
|
||||
} catch {}
|
||||
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
||||
// window doesn't drop the last pan/zoom.
|
||||
window.addEventListener('pagehide', flush);
|
||||
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
|
||||
// marginBottom) reflow on every scale change, vertically shifting the
|
||||
// world layout — so a world point mathematically pinned under the cursor
|
||||
// drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
|
||||
// Anchor the DOM element under the cursor instead: record its screen Y,
|
||||
// apply the transform + --dc-inv-zoom, then cancel whatever vertical
|
||||
// drift the reflow introduced so it stays put on screen.
|
||||
let marker = null, markerY0 = 0;
|
||||
if (k !== 1) {
|
||||
const hit = document.elementFromPoint(cx, cy);
|
||||
marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
|
||||
if (marker) markerY0 = marker.getBoundingClientRect().top;
|
||||
}
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
if (marker) {
|
||||
// A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
|
||||
// departure after the --dc-inv-zoom reflow is the layout drift.
|
||||
const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
|
||||
if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
|
||||
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
|
||||
// wheels fall through to the fixed-step branch below.
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
||||
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
||||
const onHostMsg = (e) => {
|
||||
const d = e.data;
|
||||
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
|
||||
const r = vp.getBoundingClientRect();
|
||||
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
||||
} else if (d && d.type === '__dc_probe') {
|
||||
// Host's [readyGen] reset asks whether a canvas is present; it
|
||||
// fires on the iframe's native 'load', which for canvases with
|
||||
// images/fonts is after our mount-time announce, so re-announce.
|
||||
// Clear the pan-tick guard so apply() re-posts the current scale
|
||||
// even if it's unchanged — the host just reset dcScale to 1.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onHostMsg);
|
||||
// Announce canvas mode so the host toolbar proxies its % control here
|
||||
// instead of scaling the iframe element (which would just shrink the
|
||||
// viewport window of an infinite canvas). The apply() that follows emits
|
||||
// the initial __dc_zoom so the toolbar % is correct before first pinch.
|
||||
// lastPostedScale reset mirrors the __dc_probe handler: the layout
|
||||
// effect's restore-path apply() may already have posted the restored
|
||||
// scale (before __dc_present), so clear the guard to re-post it in order.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener('message', onHostMsg);
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(dcFlatten(children));
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
||||
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
||||
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
||||
const srcKey = allIds.join('\x1f');
|
||||
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
||||
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
// marginBottom counter-scales so the on-screen gap between sections stays
|
||||
// constant — otherwise at low zoom the (world-space) gap collapses while
|
||||
// the screen-constant sectionhead below it doesn't, and the title reads as
|
||||
// belonging to the section above. paddingBottom below is just enough for
|
||||
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
||||
// the title sits tight against its own row at every zoom.
|
||||
return (
|
||||
<div data-dc-section={sid}
|
||||
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px' }}>
|
||||
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
||||
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
||||
srcKey,
|
||||
}))}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
|
||||
// self-contained clone: computed styles baked in, @font-face / <img> /
|
||||
// inline-style background-image urls inlined as data URIs. PNG wraps the
|
||||
// clone in foreignObject→canvas at 3× the artboard's natural width×height
|
||||
// (same pipeline the host uses for page captures); HTML wraps it in a
|
||||
// minimal standalone document. Both are independent of viewport zoom.
|
||||
async function dcExport(node, w, h, name, kind) {
|
||||
try { await document.fonts.ready; } catch {}
|
||||
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
|
||||
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
|
||||
})).catch(() => url);
|
||||
|
||||
// Collect @font-face rules. ss.cssRules throws SecurityError on
|
||||
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
|
||||
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
|
||||
// the blocks. @import and @media/@supports are walked so nested
|
||||
// @font-face rules aren't missed.
|
||||
const fontRules = [], pending = [], seen = new Set();
|
||||
const scrapeCss = (href) => {
|
||||
if (seen.has(href)) return; seen.add(href);
|
||||
pending.push(fetch(href).then((r) => r.text()).then((css) => {
|
||||
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
|
||||
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
||||
scrapeCss(new URL(m[1], href).href);
|
||||
}).catch(() => {}));
|
||||
};
|
||||
const walk = (rules, base) => {
|
||||
for (const r of rules) {
|
||||
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
||||
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
||||
const ibase = r.styleSheet.href || base;
|
||||
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
|
||||
} else if (r.cssRules) walk(r.cssRules, base);
|
||||
}
|
||||
};
|
||||
for (const ss of document.styleSheets) {
|
||||
const base = ss.href || location.href;
|
||||
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
|
||||
}
|
||||
while (pending.length) await pending.shift();
|
||||
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
|
||||
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
|
||||
while ((m = re.exec(rule.css))) {
|
||||
if (m[2].indexOf('data:') === 0) continue;
|
||||
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
|
||||
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
|
||||
}
|
||||
return out;
|
||||
}))).join('\n');
|
||||
|
||||
const cloneStyled = (src) => {
|
||||
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
|
||||
const dst = src.cloneNode(false);
|
||||
if (src.nodeType === 1) {
|
||||
const cs = getComputedStyle(src); let txt = '';
|
||||
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
||||
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
||||
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
|
||||
}
|
||||
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
||||
return dst;
|
||||
};
|
||||
const clone = cloneStyled(node);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
||||
// Drop the card's own shadow/radius so the export is a flush w×h rect;
|
||||
// the artboard's own background (if any) is already in the computed style.
|
||||
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
|
||||
|
||||
const jobs = [];
|
||||
clone.querySelectorAll('img').forEach((el) => {
|
||||
const s = el.getAttribute('src');
|
||||
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
||||
});
|
||||
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
||||
const bg = el.style.backgroundImage; if (!bg) return;
|
||||
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
|
||||
while ((m = re.exec(bg))) {
|
||||
const tok = m[0], url = m[1];
|
||||
if (url.indexOf('data:') === 0) continue;
|
||||
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
|
||||
}
|
||||
});
|
||||
await Promise.all(jobs);
|
||||
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const save = (blob, ext) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
};
|
||||
|
||||
if (kind === 'html') {
|
||||
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||
}
|
||||
|
||||
// PNG: the SVG's own width/height must be the output resolution — an
|
||||
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
|
||||
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
|
||||
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
|
||||
// the HTML at full resolution.
|
||||
const px = 3;
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||
const img = new Image();
|
||||
await new Promise((res, rej) => {
|
||||
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
});
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w * px; cv.height = h * px;
|
||||
cv.getContext('2d').drawImage(img, 0, 0);
|
||||
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
||||
}
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
const cardRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
|
||||
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
|
||||
// the menu — first click arms the row, second commits; closing disarms.
|
||||
React.useEffect(() => {
|
||||
if (!menuOpen) { setConfirming(false); return; }
|
||||
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
||||
document.addEventListener('pointerdown', off, true);
|
||||
return () => document.removeEventListener('pointerdown', off, true);
|
||||
}, [menuOpen]);
|
||||
|
||||
const doExport = (kind) => {
|
||||
setMenuOpen(false);
|
||||
if (!cardRef.current) return;
|
||||
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
|
||||
dcExport(cardRef.current, width, height, name, kind)
|
||||
.catch((e) => console.error('[design-canvas] export failed:', e));
|
||||
};
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-header" data-noncommentable="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="dc-labelrow">
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dc-btns">
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||
<hr />
|
||||
<button className="dc-danger"
|
||||
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||
{confirming ? 'Click again to delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={cardRef} className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||
const n = sectionOrder.length;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
189
design-templates/VIBN (2)/exports/vibn-onboarding.html
Normal file
@@ -1,21 +1,23 @@
|
||||
// Hero: the Reddit quote headline + prompt input.
|
||||
// Visitors can type into the prompt; cycling placeholders, suggestion chips, submit handler logs to console.
|
||||
|
||||
// SMB-owner placeholders: each one frames "replace the stack" or "fit my biz",
|
||||
// not "build a side project". The chips below match the same voice.
|
||||
const HERO_PLACEHOLDERS = [
|
||||
"A booking site for my dog grooming business…",
|
||||
"An invoice tracker for my freelance clients…",
|
||||
"A members-only recipe site for my supper club…",
|
||||
"A custom CRM for our 3-person real estate team…",
|
||||
"A tip calculator app for our restaurant staff…",
|
||||
"A waitlist site for my new ceramics studio…",
|
||||
"A booking system for my barbershop — the way we actually book",
|
||||
"One tool that replaces my POS, Square Appointments, and that spreadsheet",
|
||||
"A customer portal for my plumbing business — quotes, jobs, invoices, one place",
|
||||
"An inventory tool that fits my vintage shop, not Shopify's idea of one",
|
||||
"A back-office system to replace QuickBooks plus six other things",
|
||||
"A jobs + crews scheduler for my landscaping company, finally in one place",
|
||||
];
|
||||
|
||||
const HERO_CHIPS = [
|
||||
"📋 Client intake form",
|
||||
"📅 Booking site",
|
||||
"🧾 Invoice tracker",
|
||||
"🛒 Online store",
|
||||
"📰 Email newsletter",
|
||||
"📅 Booking system",
|
||||
"🧾 Invoices + quotes",
|
||||
"👥 Customer portal",
|
||||
"📦 Inventory",
|
||||
"🗂️ Back-office",
|
||||
];
|
||||
|
||||
function Hero({ onStart, variant = "quote" }) {
|
||||
@@ -54,7 +56,7 @@ function Hero({ onStart, variant = "quote" }) {
|
||||
|
||||
const useChip = (chip) => {
|
||||
const clean = chip.replace(/^[^\w]+/, "").trim();
|
||||
setText(`Build me ${clean.toLowerCase()} for my business.`);
|
||||
setText(`Build me a ${clean.toLowerCase()} for my business.`);
|
||||
if (taRef.current) taRef.current.focus();
|
||||
};
|
||||
|
||||
@@ -244,6 +246,34 @@ function Hero({ onStart, variant = "quote" }) {
|
||||
box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6);
|
||||
animation: pulse 2s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Rally line — typographic, not a pill. Reads like the opening of
|
||||
a manifesto: small caps, mono, coral hairlines either side. */
|
||||
.rally {
|
||||
display: inline-flex; align-items: center; gap: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
.rally::before, .rally::after {
|
||||
content: "";
|
||||
width: 36px; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
.rally b {
|
||||
color: var(--fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.rally { font-size: 10.5px; letter-spacing: 0.18em; gap: 10px; }
|
||||
.rally::before, .rally::after { width: 18px; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6); }
|
||||
70% { box-shadow: 0 0 0 8px oklch(0.78 0.16 155 / 0); }
|
||||
@@ -269,7 +299,19 @@ function Hero({ onStart, variant = "quote" }) {
|
||||
<div className="wrap hero-inner">
|
||||
<span className="live-pill"><span className="dot" /> Live from minute one</span>
|
||||
|
||||
{variant === "promise" ? (
|
||||
{variant === "owner" ? (
|
||||
<>
|
||||
<span className="rally">To every <b>small business owner</b></span>
|
||||
<h1 className="hero-quote">
|
||||
Stop renting <span className="mark">software</span>
|
||||
<br/>that doesn't fit.
|
||||
</h1>
|
||||
<p className="hero-sub">
|
||||
Look at your subscriptions. Ask if they're doing the job.
|
||||
<br/>Vibn builds the <b>one</b> tool that fits how your business actually runs — <b>built for you, owned by you.</b>
|
||||
</p>
|
||||
</>
|
||||
) : variant === "promise" ? (
|
||||
<>
|
||||
<h1 className="hero-quote">
|
||||
Keep <span className="mark">vibing</span>.
|
||||
@@ -287,7 +329,7 @@ function Hero({ onStart, variant = "quote" }) {
|
||||
<span className="mark" style={{ fontSize: "0.95em" }}>"</span>I built my product,
|
||||
<br/>now what<span className="mark" style={{ fontSize: "0.95em" }}>?"</span>
|
||||
</h1>
|
||||
<div className="hero-attribution mono">posted 2 hours ago · r/SideProject</div>
|
||||
<div className="hero-attribution mono">posted 2 hours ago · r/smallbusiness</div>
|
||||
<p className="hero-sub">
|
||||
<b>Keep vibing.</b> All the way to launch.
|
||||
<br/>Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
|
||||
@@ -211,8 +211,10 @@
|
||||
<script type="text/babel" src="hero.jsx"></script>
|
||||
<script type="text/babel" src="wall.jsx"></script>
|
||||
<script type="text/babel" src="crossed.jsx"></script>
|
||||
<script type="text/babel" src="stack.jsx"></script>
|
||||
<script type="text/babel" src="journey.jsx"></script>
|
||||
<script type="text/babel" src="audience.jsx"></script>
|
||||
<script type="text/babel" src="mission.jsx"></script>
|
||||
<script type="text/babel" src="closing.jsx"></script>
|
||||
<script type="text/babel" src="app.jsx"></script>
|
||||
</body>
|
||||
107
design-templates/VIBN (2)/mission.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
// Mission whisper — three sentences, one CTA. Sits between Audience and
|
||||
// Closing. Intentionally small, intentionally separate from the product pitch.
|
||||
|
||||
function Mission() {
|
||||
return (
|
||||
<section className="section mission">
|
||||
<style>{`
|
||||
.mission {
|
||||
padding-block: clamp(70px, 10vh, 120px);
|
||||
position: relative;
|
||||
}
|
||||
.mission-card {
|
||||
position: relative;
|
||||
max-width: 820px; margin: 0 auto;
|
||||
padding: clamp(40px, 6vw, 64px) clamp(28px, 5vw, 56px);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%, oklch(0.74 0.175 35 / 0.10), transparent 70%),
|
||||
linear-gradient(180deg, oklch(0.19 0.009 60 / 0.65), oklch(0.16 0.008 60 / 0.5));
|
||||
border: 1px solid var(--hairline);
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
.mission-card::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
opacity: .7;
|
||||
}
|
||||
.mission-eye {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.mission-eye::before, .mission-eye::after {
|
||||
content: ""; width: 24px; height: 1px;
|
||||
background: oklch(0.74 0.175 35 / 0.4);
|
||||
}
|
||||
.mission-title {
|
||||
margin-top: 18px;
|
||||
font-size: clamp(28px, 3.6vw, 44px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.08;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.mission-title em {
|
||||
font-style: normal;
|
||||
background: linear-gradient(180deg, var(--accent), oklch(0.62 0.18 18));
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.mission-body {
|
||||
margin-top: 22px;
|
||||
font-size: clamp(15px, 1.55vw, 18px);
|
||||
color: var(--fg-dim);
|
||||
line-height: 1.6;
|
||||
max-width: 580px; margin-inline: auto;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.mission-body b {
|
||||
color: var(--fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.mission-cta {
|
||||
margin-top: 28px;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
border: 1px solid oklch(0.74 0.175 35 / 0.4);
|
||||
background: oklch(0.74 0.175 35 / 0.08);
|
||||
transition: background .15s, transform .12s, border-color .15s;
|
||||
}
|
||||
.mission-cta:hover {
|
||||
background: oklch(0.74 0.175 35 / 0.16);
|
||||
border-color: oklch(0.74 0.175 35 / 0.6);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="wrap">
|
||||
<div className="mission-card">
|
||||
<div className="mission-eye">Why Vibn exists</div>
|
||||
<h2 className="mission-title">
|
||||
This is bigger than software.
|
||||
<br/>It's <em>the golden age of small business.</em>
|
||||
</h2>
|
||||
<p className="mission-body">
|
||||
For twenty years, small business got the leftovers — generic tools, monthly rent,
|
||||
software built for someone else. <b>AI changes the math.</b> The custom system a business
|
||||
needs to actually thrive is finally something they can have, own, and afford.
|
||||
</p>
|
||||
<a href="#" className="mission-cta">
|
||||
Read our mission <Arrow size={12} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Mission });
|
||||
753
design-templates/VIBN (2)/nav-styles.jsx
Normal file
@@ -0,0 +1,753 @@
|
||||
// ============================================================
|
||||
// 4 modern SaaS nav layouts. Each artboard is a full 1440×900
|
||||
// app/marketing chrome with the nav as the focal point and just
|
||||
// enough body content to read context. Original brand "Lattice
|
||||
// Studio" used throughout so the navs feel like one product
|
||||
// family — the variable is the nav pattern itself.
|
||||
// ============================================================
|
||||
|
||||
// Generic placeholder block
|
||||
const NavImgSlot = ({ label, h = 200, tone = "light" }) => {
|
||||
const p = tone === "dark"
|
||||
? { bg: "#1a1a1f", stripe: "#222229", ink: "#7a7a85" }
|
||||
: { bg: "#f3f3f0", stripe: "#e7e7e3", ink: "#7a7a72" };
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: h, position: "relative",
|
||||
backgroundImage: `repeating-linear-gradient(135deg, ${p.bg} 0 14px, ${p.stripe} 14px 15px)`,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: "ui-monospace, 'SF Mono', Menlo, monospace",
|
||||
fontSize: 10, letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
color: p.ink, padding: "3px 8px",
|
||||
border: `1px solid ${p.ink}40`, background: `${p.bg}d0`,
|
||||
}}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Tiny stroke icon helper — single line so it doesn't bloat the file
|
||||
const I = ({ d, size = 16, sw = 1.6 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeLinejoin="round">{d}</svg>
|
||||
);
|
||||
// Common path sets, kept terse
|
||||
const Paths = {
|
||||
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
|
||||
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
|
||||
cmd: <path d="M9 3a3 3 0 0 0-3 3v3H3M15 3a3 3 0 0 1 3 3v3h3M9 21a3 3 0 0 1-3-3v-3H3M15 21a3 3 0 0 0 3-3v-3h3M9 9h6v6H9z"/>,
|
||||
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
|
||||
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
|
||||
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
|
||||
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
|
||||
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
|
||||
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
|
||||
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
|
||||
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/><rect x="19" y="14" width="0" height="4"/></>,
|
||||
hash: <path d="M9 3l-2 18M17 3l-2 18M3 9h18M2 15h18"/>,
|
||||
lock: <><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></>,
|
||||
plus: <path d="M12 5v14M5 12h14"/>,
|
||||
chevron: <path d="m6 9 6 6 6-6"/>,
|
||||
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
|
||||
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
|
||||
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
|
||||
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
|
||||
};
|
||||
|
||||
const sansStack = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif";
|
||||
|
||||
// ============================================================
|
||||
// 1. SIDEBAR — workspace + sections + secondary
|
||||
// (Linear / Notion / Twenty school)
|
||||
// ============================================================
|
||||
const NavSidebar = () => {
|
||||
const ItemRow = ({ icon, label, count, active }) => (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "6px 10px", borderRadius: 6, fontSize: 13,
|
||||
color: active ? "#111" : "#5a5a5e",
|
||||
background: active ? "#ffffff" : "transparent",
|
||||
boxShadow: active ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
|
||||
fontWeight: active ? 500 : 400, cursor: "pointer",
|
||||
}}>
|
||||
<span style={{ color: active ? "#5e5cff" : "#8a8a90", display: "flex" }}>
|
||||
<I d={icon} size={15} />
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
{count && <span style={{
|
||||
fontSize: 11, color: "#8a8a90", fontVariantNumeric: "tabular-nums",
|
||||
}}>{count}</span>}
|
||||
</div>
|
||||
);
|
||||
const SectionHeader = ({ label }) => (
|
||||
<div style={{
|
||||
fontSize: 11, color: "#8a8a90", letterSpacing: "0.04em",
|
||||
padding: "16px 10px 6px", textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
}}>{label}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", display: "grid",
|
||||
gridTemplateColumns: "248px 1fr",
|
||||
background: "#fcfcfb", fontFamily: sansStack, color: "#111",
|
||||
}}>
|
||||
{/* SIDEBAR */}
|
||||
<aside style={{
|
||||
background: "#f5f5f2", borderRight: "1px solid #e8e8e3",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}>
|
||||
{/* Workspace switcher */}
|
||||
<div style={{
|
||||
padding: "12px 12px", display: "flex", alignItems: "center", gap: 10,
|
||||
borderBottom: "1px solid #e8e8e3",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: 6,
|
||||
background: "linear-gradient(135deg, #6e6cff 0%, #b15bff 100%)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 700, fontSize: 13,
|
||||
}}>L</div>
|
||||
<div style={{ flex: 1, lineHeight: 1.2 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Lattice Studio</div>
|
||||
<div style={{ fontSize: 11, color: "#8a8a90" }}>Free · 4 members</div>
|
||||
</div>
|
||||
<span style={{ color: "#8a8a90", display: "flex" }}>
|
||||
<I d={Paths.chevron} size={14} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ padding: "10px 12px" }}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
|
||||
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 6,
|
||||
fontSize: 12, color: "#8a8a90",
|
||||
}}>
|
||||
<I d={Paths.search} size={14} />
|
||||
<span style={{ flex: 1 }}>Search…</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 5px", border: "1px solid #e0e0d8",
|
||||
borderRadius: 3, fontFamily: "monospace",
|
||||
}}>⌘K</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
|
||||
<ItemRow icon={Paths.home} label="Home" />
|
||||
<ItemRow icon={Paths.inbox} label="Inbox" count="12" />
|
||||
<ItemRow icon={Paths.check} label="My tasks" count="3" />
|
||||
|
||||
<SectionHeader label="Workspaces" />
|
||||
<ItemRow icon={Paths.hash} label="Marketing site" active />
|
||||
<ItemRow icon={Paths.hash} label="Q2 launch" />
|
||||
<ItemRow icon={Paths.hash} label="Brand refresh" />
|
||||
<ItemRow icon={Paths.hash} label="Customer interviews" />
|
||||
|
||||
<SectionHeader label="Pinned" />
|
||||
<ItemRow icon={Paths.doc} label="Roadmap · 2026" />
|
||||
<ItemRow icon={Paths.doc} label="Design tokens" />
|
||||
<ItemRow icon={Paths.doc} label="Onboarding flow" />
|
||||
|
||||
<SectionHeader label="Team" />
|
||||
<ItemRow icon={Paths.people} label="People" />
|
||||
<ItemRow icon={Paths.bar} label="Insights" />
|
||||
<ItemRow icon={Paths.workflow} label="Automations" />
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: "10px 12px", borderTop: "1px solid #e8e8e3",
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: "50%", background: "#d4b8a8",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 11, fontWeight: 600, color: "#5a3e34",
|
||||
}}>MR</div>
|
||||
<div style={{ flex: 1, fontSize: 12 }}>
|
||||
<div style={{ fontWeight: 500 }}>Mira Reyes</div>
|
||||
<div style={{ color: "#8a8a90", fontSize: 11 }}>mira@lattice.co</div>
|
||||
</div>
|
||||
<span style={{ color: "#8a8a90", display: "flex" }}>
|
||||
<I d={Paths.chevron} size={14} />
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* CONTENT */}
|
||||
<main style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div style={{
|
||||
padding: "14px 28px", borderBottom: "1px solid #e8e8e3",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div style={{ fontSize: 13, color: "#8a8a90" }}>
|
||||
Workspaces / <span style={{ color: "#111" }}>Marketing site</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={{
|
||||
padding: "6px 12px", border: "1px solid #e0e0d8",
|
||||
background: "#fff", borderRadius: 6, fontSize: 12, fontFamily: sansStack,
|
||||
color: "#5a5a5e", cursor: "pointer",
|
||||
}}>Share</button>
|
||||
<button style={{
|
||||
padding: "6px 12px", border: "none",
|
||||
background: "#111", color: "#fff", borderRadius: 6, fontSize: 12,
|
||||
fontFamily: sansStack, cursor: "pointer", display: "flex", alignItems: "center", gap: 6,
|
||||
}}><I d={Paths.plus} size={12}/> New page</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 36, flex: 1, overflow: "hidden" }}>
|
||||
<h1 style={{
|
||||
fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em", margin: 0,
|
||||
}}>Marketing site</h1>
|
||||
<p style={{ color: "#5a5a5e", fontSize: 13, marginTop: 6 }}>
|
||||
14 pages · last edited 4 minutes ago by Mira
|
||||
</p>
|
||||
<div style={{ marginTop: 28, display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
|
||||
{["Homepage", "Pricing", "Changelog"].map(t => (
|
||||
<div key={t} style={{
|
||||
background: "#fff", border: "1px solid #e8e8e3", borderRadius: 10,
|
||||
padding: 16,
|
||||
}}>
|
||||
<NavImgSlot label={`${t} · preview`} h={110} />
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginTop: 12 }}>{t}</div>
|
||||
<div style={{ fontSize: 11, color: "#8a8a90", marginTop: 2 }}>
|
||||
Edited 2h ago · Mira
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 2. ICON RAIL + CONTEXT PANEL — Slack / Discord / Mail school
|
||||
// ============================================================
|
||||
const NavIconRail = () => {
|
||||
const RailIcon = ({ icon, active, badge, color }) => (
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: active ? color : "transparent",
|
||||
color: active ? "#fff" : "#9a9aa6",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
cursor: "pointer", position: "relative",
|
||||
border: active ? "none" : "1px solid transparent",
|
||||
}}>
|
||||
<I d={icon} size={18} sw={2} />
|
||||
{badge && (
|
||||
<span style={{
|
||||
position: "absolute", top: -2, right: -2, minWidth: 16, height: 16,
|
||||
padding: "0 4px", background: "#ff4d5e", color: "#fff",
|
||||
borderRadius: 8, fontSize: 10, fontWeight: 600,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
border: "2px solid #0f0f14",
|
||||
}}>{badge}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChannelItem = ({ name, active, unread, mention }) => (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "6px 10px", borderRadius: 6, fontSize: 13,
|
||||
color: active ? "#fff" : (unread ? "#dcdce4" : "#9a9aa6"),
|
||||
background: active ? "#ffffff14" : "transparent",
|
||||
fontWeight: unread ? 500 : 400, cursor: "pointer",
|
||||
}}>
|
||||
<span style={{ opacity: 0.7 }}>#</span>
|
||||
<span style={{ flex: 1 }}>{name}</span>
|
||||
{mention && <span style={{
|
||||
background: "#ff4d5e", color: "#fff", fontSize: 10, fontWeight: 600,
|
||||
padding: "1px 6px", borderRadius: 8,
|
||||
}}>{mention}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", display: "grid",
|
||||
gridTemplateColumns: "72px 260px 1fr",
|
||||
background: "#0f0f14", color: "#e8e8ee", fontFamily: sansStack,
|
||||
}}>
|
||||
{/* RAIL */}
|
||||
<div style={{
|
||||
background: "#08080c", borderRight: "1px solid #ffffff08",
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
padding: "12px 0", gap: 6,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: "linear-gradient(135deg, #5e5cff 0%, #b15bff 100%)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 800, fontSize: 16, marginBottom: 6,
|
||||
}}>L</div>
|
||||
<div style={{ width: 24, height: 1, background: "#ffffff10", margin: "4px 0" }}></div>
|
||||
|
||||
<RailIcon icon={Paths.home} active color="#5e5cff" />
|
||||
<RailIcon icon={Paths.inbox} badge="9" />
|
||||
<RailIcon icon={Paths.people} />
|
||||
<RailIcon icon={Paths.target} badge="2" />
|
||||
<RailIcon icon={Paths.bar} />
|
||||
<RailIcon icon={Paths.doc} />
|
||||
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<RailIcon icon={Paths.plus} />
|
||||
<RailIcon icon={Paths.spark} />
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: "50%", marginTop: 4,
|
||||
background: "#d4b8a8", display: "flex", alignItems: "center",
|
||||
justifyContent: "center", fontSize: 12, fontWeight: 600, color: "#5a3e34",
|
||||
border: "2px solid #08080c", boxShadow: "0 0 0 2px #5e5cff",
|
||||
position: "relative",
|
||||
}}>MR
|
||||
<span style={{
|
||||
position: "absolute", bottom: -2, right: -2, width: 12, height: 12,
|
||||
background: "#22c55e", borderRadius: "50%", border: "2px solid #08080c",
|
||||
}}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECONDARY PANEL */}
|
||||
<div style={{
|
||||
background: "#13131a", borderRight: "1px solid #ffffff08",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}>
|
||||
<div style={{
|
||||
padding: "16px 16px 12px",
|
||||
borderBottom: "1px solid #ffffff08",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<span style={{ fontSize: 15, fontWeight: 600 }}>Lattice HQ</span>
|
||||
<span style={{ color: "#9a9aa6", display: "flex" }}>
|
||||
<I d={Paths.chevron} size={16} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "7px 10px", background: "#08080c",
|
||||
borderRadius: 7, fontSize: 12, color: "#9a9aa6",
|
||||
}}>
|
||||
<I d={Paths.search} size={13} />
|
||||
<span style={{ flex: 1 }}>Jump to…</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 5px",
|
||||
background: "#ffffff08", borderRadius: 3, fontFamily: "monospace",
|
||||
}}>⌘K</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "12px 8px", flex: 1, overflowY: "auto" }}>
|
||||
<div style={{
|
||||
fontSize: 11, color: "#6a6a78", padding: "8px 10px 4px",
|
||||
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
|
||||
display: "flex", justifyContent: "space-between",
|
||||
}}>
|
||||
<span>Channels</span>
|
||||
<I d={Paths.plus} size={12} />
|
||||
</div>
|
||||
<ChannelItem name="general" />
|
||||
<ChannelItem name="design-crits" unread mention="3" />
|
||||
<ChannelItem name="launch-2026" active />
|
||||
<ChannelItem name="random" />
|
||||
<ChannelItem name="bugs" unread />
|
||||
|
||||
<div style={{
|
||||
fontSize: 11, color: "#6a6a78", padding: "16px 10px 4px",
|
||||
textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
|
||||
}}>Direct messages</div>
|
||||
{[
|
||||
["Sun Kim", "#e8a87c", true],
|
||||
["Devi Patel", "#a8c8e8", false],
|
||||
["Theo Roux", "#c8e8a8", false],
|
||||
].map(([n, c, online], i) => (
|
||||
<div key={i} style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "6px 10px", borderRadius: 6, fontSize: 13, color: "#dcdce4",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<div style={{ width: 22, height: 22, borderRadius: "50%", background: c }}></div>
|
||||
{online && <span style={{
|
||||
position: "absolute", bottom: -1, right: -1, width: 8, height: 8,
|
||||
background: "#22c55e", borderRadius: "50%", border: "2px solid #13131a",
|
||||
}}></span>}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{n}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CONTENT */}
|
||||
<main style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{
|
||||
padding: "14px 24px", borderBottom: "1px solid #ffffff08",
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
}}>
|
||||
<span style={{ color: "#9a9aa6" }}>#</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 600 }}>launch-2026</span>
|
||||
<span style={{ color: "#6a6a78", fontSize: 12 }}>· 18 members</span>
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<span style={{ color: "#9a9aa6", display: "flex" }}><I d={Paths.bell} size={16}/></span>
|
||||
<span style={{ color: "#9a9aa6", display: "flex" }}><I d={Paths.star} size={16}/></span>
|
||||
</div>
|
||||
<div style={{ padding: 28, flex: 1, color: "#9a9aa6", fontSize: 13 }}>
|
||||
<NavImgSlot label="conversation thread" h={420} tone="dark" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 3. TOP HORIZONTAL + COMMAND BAR — Vercel / Stripe / Linear web
|
||||
// ============================================================
|
||||
const NavTopHorizontal = () => {
|
||||
const TabItem = ({ label, active }) => (
|
||||
<div style={{
|
||||
padding: "16px 2px", margin: "0 12px", fontSize: 13, fontWeight: 500,
|
||||
color: active ? "#fff" : "#9a9aa6",
|
||||
borderBottom: active ? "2px solid #fff" : "2px solid transparent",
|
||||
cursor: "pointer", position: "relative", top: 1,
|
||||
}}>{label}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", background: "#fafaf9",
|
||||
color: "#111", fontFamily: sansStack, display: "flex", flexDirection: "column",
|
||||
}}>
|
||||
{/* DARK TOP BAR */}
|
||||
<header style={{ background: "#0a0a0a", color: "#fff" }}>
|
||||
{/* Row 1: brand + workspace + global */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 14,
|
||||
padding: "12px 24px",
|
||||
}}>
|
||||
{/* Brand */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8, fontWeight: 600, fontSize: 14,
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 20 L12 4 L21 20 Z" fill="#fff"/>
|
||||
</svg>
|
||||
Lattice
|
||||
</div>
|
||||
<span style={{ color: "#3a3a3a" }}>/</span>
|
||||
{/* Workspace */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8, fontSize: 13,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: "50%", background: "#e8a87c",
|
||||
fontSize: 9, fontWeight: 700, color: "#5a3e34",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>MR</div>
|
||||
<span>mira-reyes</span>
|
||||
<span style={{ color: "#5a5a5e", display: "flex" }}><I d={Paths.chevron} size={12}/></span>
|
||||
</div>
|
||||
<span style={{ color: "#3a3a3a" }}>/</span>
|
||||
{/* Project */}
|
||||
<div style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span>marketing-site</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 7px", borderRadius: 999,
|
||||
background: "#1f1f1f", color: "#9a9aa6", border: "1px solid #2a2a2a",
|
||||
}}>Hobby</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}></div>
|
||||
|
||||
{/* Command bar — focal point */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "6px 12px", borderRadius: 8,
|
||||
background: "#1a1a1a", border: "1px solid #2a2a2a",
|
||||
color: "#9a9aa6", fontSize: 12, minWidth: 320,
|
||||
}}>
|
||||
<I d={Paths.search} size={13} />
|
||||
<span style={{ flex: 1 }}>Find or jump to anything…</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 5px", background: "#2a2a2a",
|
||||
borderRadius: 3, fontFamily: "monospace",
|
||||
}}>⌘K</span>
|
||||
</div>
|
||||
|
||||
{/* Right icons */}
|
||||
<button style={{
|
||||
background: "transparent", border: "1px solid #2a2a2a",
|
||||
color: "#fff", padding: "5px 12px", borderRadius: 6,
|
||||
fontSize: 12, fontFamily: sansStack, cursor: "pointer",
|
||||
}}>Feedback</button>
|
||||
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer" }}>
|
||||
<I d={Paths.doc} size={16}/>
|
||||
</span>
|
||||
<span style={{ color: "#9a9aa6", display: "flex", cursor: "pointer", position: "relative" }}>
|
||||
<I d={Paths.bell} size={16}/>
|
||||
<span style={{
|
||||
position: "absolute", top: -2, right: -2, width: 7, height: 7,
|
||||
background: "#5e5cff", borderRadius: "50%",
|
||||
}}></span>
|
||||
</span>
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: "50%", background: "#d4b8a8",
|
||||
fontSize: 11, fontWeight: 600, color: "#5a3e34",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
}}>MR</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: tabs */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center",
|
||||
padding: "0 16px", borderBottom: "1px solid #1a1a1a",
|
||||
}}>
|
||||
<TabItem label="Overview" active />
|
||||
<TabItem label="Deployments" />
|
||||
<TabItem label="Analytics" />
|
||||
<TabItem label="Logs" />
|
||||
<TabItem label="Storage" />
|
||||
<TabItem label="Domains" />
|
||||
<TabItem label="Settings" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* CONTENT */}
|
||||
<main style={{ flex: 1, padding: "32px 48px" }}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontSize: 32, fontWeight: 600, margin: 0, letterSpacing: "-0.02em",
|
||||
}}>Overview</h1>
|
||||
<p style={{ color: "#6a6a72", margin: "4px 0 0", fontSize: 13 }}>
|
||||
Last deployment 14 minutes ago to <code style={{
|
||||
background: "#f0efea", padding: "1px 5px", borderRadius: 3,
|
||||
fontSize: 12,
|
||||
}}>main</code>
|
||||
</p>
|
||||
</div>
|
||||
<button style={{
|
||||
background: "#111", color: "#fff", border: "none",
|
||||
padding: "8px 16px", borderRadius: 6, fontSize: 13, fontWeight: 500,
|
||||
fontFamily: sansStack, cursor: "pointer", display: "flex",
|
||||
alignItems: "center", gap: 6,
|
||||
}}><I d={Paths.plus} size={13}/> Deploy</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 20 }}>
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #ebebe6", borderRadius: 10,
|
||||
padding: 20,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: "#6a6a72", marginBottom: 12 }}>
|
||||
Production · Last 24h
|
||||
</div>
|
||||
<NavImgSlot label="requests · time series" h={190} />
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #ebebe6", borderRadius: 10,
|
||||
padding: 20, display: "grid", gridTemplateRows: "1fr 1fr", gap: 14,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: "#6a6a72" }}>Requests</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 600, marginTop: 4 }}>
|
||||
284,012 <span style={{ color: "#22c55e", fontSize: 12 }}>+12%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: "#6a6a72" }}>Edge p99</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 600, marginTop: 4 }}>
|
||||
47ms <span style={{ color: "#22c55e", fontSize: 12 }}>−3ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 4. FLOATING GLASS NAV — marketing site / homepage pattern
|
||||
// ============================================================
|
||||
const NavFloatingGlass = () => (
|
||||
<div style={{
|
||||
width: "100%", height: "100%", color: "#fff",
|
||||
background: "#08081a", fontFamily: sansStack,
|
||||
position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
{/* Soft aurora background */}
|
||||
<div style={{
|
||||
position: "absolute", top: -250, left: -150, width: 700, height: 700,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, #5e5cff 0%, transparent 60%)",
|
||||
filter: "blur(100px)", opacity: 0.5,
|
||||
}}></div>
|
||||
<div style={{
|
||||
position: "absolute", top: 100, right: -200, width: 600, height: 600,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, #b15bff 0%, transparent 60%)",
|
||||
filter: "blur(100px)", opacity: 0.4,
|
||||
}}></div>
|
||||
<div style={{
|
||||
position: "absolute", bottom: -200, left: "30%", width: 500, height: 500,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, #00e5b3 0%, transparent 60%)",
|
||||
filter: "blur(100px)", opacity: 0.3,
|
||||
}}></div>
|
||||
|
||||
{/* Floating pill nav — the focal point */}
|
||||
<header style={{
|
||||
position: "absolute", top: 24, left: "50%",
|
||||
transform: "translateX(-50%)", zIndex: 10,
|
||||
width: "max-content", whiteSpace: "nowrap",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
padding: "8px 8px 8px 18px",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
backdropFilter: "blur(24px)",
|
||||
WebkitBackdropFilter: "blur(24px)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
borderRadius: 999,
|
||||
boxShadow: "0 20px 50px -20px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.1)",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
marginRight: 18, fontWeight: 600, fontSize: 14,
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 20 L12 4 L21 20 Z" fill="url(#g)" />
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#5e5cff"/>
|
||||
<stop offset="100%" stopColor="#b15bff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
Lattice
|
||||
</div>
|
||||
|
||||
{["Product", "Solutions", "Customers", "Pricing", "Docs"].map((l, i) => (
|
||||
<button key={l} style={{
|
||||
background: i === 0 ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
border: "none", color: "#fff", whiteSpace: "nowrap",
|
||||
padding: "8px 14px", borderRadius: 999,
|
||||
fontSize: 13, fontFamily: sansStack, cursor: "pointer",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
}}>
|
||||
{l}
|
||||
{(i === 0 || i === 1) && (
|
||||
<span style={{ opacity: 0.6, display: "flex" }}>
|
||||
<I d={Paths.chevron} size={11} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div style={{ width: 1, height: 22, background: "rgba(255,255,255,0.12)", margin: "0 6px" }}></div>
|
||||
|
||||
<button style={{
|
||||
background: "transparent", border: "none", color: "#fff",
|
||||
padding: "8px 14px", borderRadius: 999, fontSize: 13,
|
||||
fontFamily: sansStack, cursor: "pointer", whiteSpace: "nowrap",
|
||||
}}>Sign in</button>
|
||||
<button style={{
|
||||
background: "#fff", color: "#08081a", border: "none",
|
||||
padding: "8px 16px", borderRadius: 999, fontSize: 13, fontWeight: 600,
|
||||
fontFamily: sansStack, cursor: "pointer", whiteSpace: "nowrap",
|
||||
}}>Get started →</button>
|
||||
</header>
|
||||
|
||||
{/* Tiny status pill above nav */}
|
||||
<div style={{
|
||||
position: "absolute", top: -2, left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
padding: "4px 10px 4px 26px",
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
borderRadius: "0 0 10px 10px",
|
||||
fontSize: 11, color: "#a8a8c0",
|
||||
borderTop: "none",
|
||||
}}>
|
||||
All systems normal ·{" "}
|
||||
<span style={{ color: "#7aff66" }}>● 99.99% uptime</span>
|
||||
</div>
|
||||
|
||||
{/* HERO */}
|
||||
<main style={{
|
||||
position: "relative", paddingTop: 180,
|
||||
textAlign: "center", maxWidth: 880, margin: "0 auto",
|
||||
padding: "180px 40px 0",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 8,
|
||||
padding: "5px 14px 5px 5px", borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
fontSize: 12, marginBottom: 28,
|
||||
}}>
|
||||
<span style={{
|
||||
padding: "2px 8px", background: "#5e5cff", borderRadius: 999,
|
||||
fontWeight: 600, fontSize: 10,
|
||||
}}>NEW</span>
|
||||
Lattice 4.0 — agents that draft for you ·{" "}
|
||||
<span style={{ opacity: 0.7 }}>read more →</span>
|
||||
</div>
|
||||
<h1 style={{
|
||||
fontSize: 76, lineHeight: 1, margin: 0, fontWeight: 500,
|
||||
letterSpacing: "-0.04em", textWrap: "balance",
|
||||
}}>
|
||||
The workspace where{" "}
|
||||
<span style={{
|
||||
background: "linear-gradient(90deg, #b15bff, #5e5cff, #00e5b3)",
|
||||
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
|
||||
fontStyle: "italic", fontWeight: 400,
|
||||
}}>
|
||||
good ideas
|
||||
</span>{" "}
|
||||
compound.
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: 17, lineHeight: 1.5, marginTop: 22, opacity: 0.7,
|
||||
maxWidth: 540, marginLeft: "auto", marginRight: "auto",
|
||||
}}>
|
||||
Docs, canvases, and agents in one luminous surface.
|
||||
Built by people who got tired of switching tabs.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 32 }}>
|
||||
<button style={{
|
||||
background: "#fff", color: "#08081a", border: "none",
|
||||
padding: "14px 28px", borderRadius: 999, fontWeight: 600,
|
||||
fontSize: 14, cursor: "pointer", fontFamily: sansStack,
|
||||
whiteSpace: "nowrap",
|
||||
}}>Start for free</button>
|
||||
<button style={{
|
||||
background: "rgba(255,255,255,0.08)", color: "#fff",
|
||||
border: "1px solid rgba(255,255,255,0.16)",
|
||||
backdropFilter: "blur(12px)",
|
||||
padding: "14px 28px", borderRadius: 999, fontSize: 14,
|
||||
cursor: "pointer", fontFamily: sansStack, whiteSpace: "nowrap",
|
||||
}}>▶ Watch the film · 2 min</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Export to window
|
||||
Object.assign(window, {
|
||||
NavSidebar, NavIconRail, NavTopHorizontal, NavFloatingGlass,
|
||||
});
|
||||
300
design-templates/VIBN (2)/page-admin.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
// ============================================================
|
||||
// page-admin.jsx — Workspace settings, Members tab.
|
||||
// Sub-nav (Workspace / Members / Roles / Integrations / Billing
|
||||
// / API) + searchable member table + bulk actions + invite row.
|
||||
// ============================================================
|
||||
|
||||
const AdminBody = ({ theme = "light", hideSubnav = false }) => {
|
||||
const dark = theme === "dark";
|
||||
const c = dark ? {
|
||||
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
|
||||
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
|
||||
rowAlt: "#ffffff04", input: "#08080c", accent: "#7a78ff",
|
||||
chipBg: "#ffffff08", chipText: "#dcdce4", danger: "#ff4d5e",
|
||||
} : {
|
||||
bg: "#fafaf9", panel: "#ffffff", border: "#ebebe6",
|
||||
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
|
||||
rowAlt: "#fafaf6", input: "#fff", accent: "#5e5cff",
|
||||
chipBg: "#f1f0eb", chipText: "#3a3a3e", danger: "#dc2626",
|
||||
};
|
||||
|
||||
const subnav = [
|
||||
"General", "Members", "Roles", "Integrations", "Billing", "API & Webhooks", "Audit log",
|
||||
];
|
||||
|
||||
const roleColors = {
|
||||
Owner: "#b15bff",
|
||||
Admin: "#5e5cff",
|
||||
Member: "#22c55e",
|
||||
Guest: "#9a9aa6",
|
||||
};
|
||||
|
||||
const members = [
|
||||
{ i: "MR", c: "#d4b8a8", n: "Mira Reyes", e: "mira@lattice.co", r: "Owner", s: "Active", last: "now", teams: ["Founding"] },
|
||||
{ i: "TR", c: "#c8e8a8", n: "Theo Roux", e: "theo@lattice.co", r: "Admin", s: "Active", last: "12 min", teams: ["Engineering"] },
|
||||
{ i: "DP", c: "#a8c8e8", n: "Devi Patel", e: "devi@lattice.co", r: "Admin", s: "Active", last: "1 hour", teams: ["Revenue"] },
|
||||
{ i: "SK", c: "#e8a87c", n: "Sun Kim", e: "sun@lattice.co", r: "Member", s: "Active", last: "today", teams: ["Revenue", "Design"] },
|
||||
{ i: "AN", c: "#e8c8a8", n: "Ade Nwosu", e: "ade@lattice.co", r: "Member", s: "Active", last: "yesterday", teams: ["Engineering"] },
|
||||
{ i: "LB", c: "#c8a8e8", n: "Linnea Berg", e: "linnea@lattice.co", r: "Member", s: "Invited", last: "—", teams: [] },
|
||||
{ i: "JF", c: "#a8e8c8", n: "Jamal Frost", e: "jamal@partner.co", r: "Guest", s: "Active", last: "3 days", teams: ["Revenue"] },
|
||||
{ i: "ER", c: "#e8a8c8", n: "Elin Roos", e: "elin@lattice.co", r: "Member", s: "Suspended", last: "14 days", teams: ["Design"] },
|
||||
];
|
||||
|
||||
const Badge = ({ color, children, dot }) => (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
padding: "2px 8px", borderRadius: 999,
|
||||
background: color ? `${color}1f` : c.chipBg,
|
||||
color: color || c.chipText,
|
||||
fontSize: 11, fontWeight: 500, whiteSpace: "nowrap",
|
||||
}}>
|
||||
{dot && <span style={{
|
||||
width: 6, height: 6, borderRadius: "50%", background: color,
|
||||
}}></span>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const Avatar = ({ name, color, size = 28 }) => (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: "50%", background: color,
|
||||
fontSize: size * 0.4, fontWeight: 600, color: "#3a2820",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}>{name}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: "100%", background: c.bg, color: c.text, fontFamily: SANS,
|
||||
display: "grid",
|
||||
gridTemplateColumns: hideSubnav ? "1fr" : "220px 1fr",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{/* Settings sub-nav */}
|
||||
{!hideSubnav && <aside style={{
|
||||
borderRight: `1px solid ${c.border}`, padding: "20px 12px",
|
||||
background: dark ? "#0a0a10" : "#f5f5f2",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 11, color: c.muted, padding: "0 10px 8px",
|
||||
letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 500,
|
||||
}}>Settings</div>
|
||||
{subnav.map((s, i) => (
|
||||
<div key={s} style={{
|
||||
padding: "7px 10px", borderRadius: 6, fontSize: 13, cursor: "pointer",
|
||||
color: i === 1 ? c.text : c.subtext,
|
||||
background: i === 1 ? (dark ? "#ffffff10" : "#fff") : "transparent",
|
||||
boxShadow: i === 1 && !dark ? "0 1px 0 #00000008, 0 0 0 1px #00000008" : "none",
|
||||
fontWeight: i === 1 ? 500 : 400,
|
||||
marginBottom: 2,
|
||||
}}>{s}</div>
|
||||
))}
|
||||
<div style={{
|
||||
fontSize: 11, color: c.muted, padding: "16px 10px 8px",
|
||||
letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 500,
|
||||
}}>Personal</div>
|
||||
{["Profile", "Notifications", "Sessions"].map(s => (
|
||||
<div key={s} style={{
|
||||
padding: "7px 10px", borderRadius: 6, fontSize: 13, cursor: "pointer",
|
||||
color: c.subtext, marginBottom: 2,
|
||||
}}>{s}</div>
|
||||
))}
|
||||
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<div style={{
|
||||
padding: "12px 12px", borderRadius: 8,
|
||||
background: dark ? "#ffffff06" : "#fff",
|
||||
border: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, marginBottom: 4 }}>
|
||||
Free workspace
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: c.muted, lineHeight: 1.4, marginBottom: 10 }}>
|
||||
6 of 10 seats used. Upgrade for SSO, audit log retention, and SCIM.
|
||||
</div>
|
||||
<button style={{
|
||||
width: "100%", padding: "7px 12px", borderRadius: 6,
|
||||
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
|
||||
border: "none", fontSize: 12, fontFamily: SANS, fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}>Upgrade to Pro →</button>
|
||||
</div>
|
||||
</aside>}
|
||||
|
||||
{/* Main */}
|
||||
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Page header */}
|
||||
<div style={{
|
||||
padding: "20px 28px 14px", borderBottom: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: c.muted, marginBottom: 6 }}>
|
||||
Settings / Members
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 600, margin: 0, letterSpacing: "-0.02em" }}>
|
||||
Members
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: 13, color: c.subtext, margin: "6px 0 0", maxWidth: 540,
|
||||
}}>
|
||||
Manage who has access to <b>Lattice Studio</b>. Roles control
|
||||
what each person can see and edit across the workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={{
|
||||
padding: "8px 14px", borderRadius: 6, fontSize: 13, fontFamily: SANS,
|
||||
background: c.panel, border: `1px solid ${c.border}`, color: c.text,
|
||||
cursor: "pointer",
|
||||
}}>Export CSV</button>
|
||||
<button style={{
|
||||
padding: "8px 14px", borderRadius: 6, fontSize: 13, fontFamily: SANS,
|
||||
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
|
||||
border: "none", cursor: "pointer", fontWeight: 500,
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
}}><Icon d={P.plus} size={13}/> Invite people</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter / search row */}
|
||||
<div style={{
|
||||
padding: "12px 28px", borderBottom: `1px solid ${c.border}`,
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8, padding: "6px 10px",
|
||||
background: c.input, border: `1px solid ${c.border}`, borderRadius: 6,
|
||||
fontSize: 12, color: c.muted, width: 280,
|
||||
}}>
|
||||
<Icon d={P.search} size={13} />
|
||||
<span style={{ flex: 1 }}>Search by name, email…</span>
|
||||
</div>
|
||||
{[
|
||||
{ l: "Role", v: "All" },
|
||||
{ l: "Status", v: "Active + Invited" },
|
||||
{ l: "Team", v: "Any" },
|
||||
].map(f => (
|
||||
<div key={f.l} style={{
|
||||
display: "flex", alignItems: "center", gap: 6, padding: "6px 10px",
|
||||
border: `1px dashed ${c.border}`, borderRadius: 6, fontSize: 12,
|
||||
color: c.subtext, cursor: "pointer",
|
||||
}}>
|
||||
<span style={{ color: c.muted }}>{f.l}:</span>
|
||||
<span style={{ color: c.text, fontWeight: 500 }}>{f.v}</span>
|
||||
<Icon d={P.chevron} size={11} />
|
||||
</div>
|
||||
))}
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<span style={{ fontSize: 12, color: c.muted }}>
|
||||
<b style={{ color: c.text }}>8</b> members · 1 invited · 1 suspended
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "28px 2fr 1fr 1fr 1.4fr 1fr 32px",
|
||||
padding: "10px 28px", fontSize: 11, color: c.muted,
|
||||
letterSpacing: "0.04em", textTransform: "uppercase", fontWeight: 500,
|
||||
borderBottom: `1px solid ${c.border}`,
|
||||
alignItems: "center", gap: 12,
|
||||
}}>
|
||||
<input type="checkbox" style={{ accentColor: c.accent }} readOnly />
|
||||
<span>Name</span>
|
||||
<span>Role</span>
|
||||
<span>Status</span>
|
||||
<span>Teams</span>
|
||||
<span>Last active</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{members.map((m, i) => (
|
||||
<div key={m.e} style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "28px 2fr 1fr 1fr 1.4fr 1fr 32px",
|
||||
padding: "10px 28px", fontSize: 13,
|
||||
alignItems: "center", gap: 12,
|
||||
borderBottom: `1px solid ${c.border}`,
|
||||
background: i % 2 === 1 ? c.rowAlt : "transparent",
|
||||
}}>
|
||||
<input type="checkbox" style={{ accentColor: c.accent }} readOnly />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||
<Avatar name={m.i} color={m.c} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.n}</div>
|
||||
<div style={{ fontSize: 11, color: c.muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.e}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "3px 9px", borderRadius: 5,
|
||||
background: `${roleColors[m.r]}18`,
|
||||
color: roleColors[m.r], fontSize: 12, fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
{m.r}
|
||||
<Icon d={P.chevron} size={11} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{m.s === "Active" && <Badge color="#22c55e" dot>Active</Badge>}
|
||||
{m.s === "Invited" && <Badge color="#f6c560" dot>Invited</Badge>}
|
||||
{m.s === "Suspended" && <Badge color={c.danger} dot>Suspended</Badge>}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{m.teams.length === 0
|
||||
? <span style={{ fontSize: 12, color: c.muted }}>—</span>
|
||||
: m.teams.map(t => <Badge key={t}>{t}</Badge>)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: c.subtext }}>{m.last}</div>
|
||||
<div style={{ color: c.muted, display: "flex", justifyContent: "flex-end", cursor: "pointer" }}>
|
||||
<Icon d={P.more} size={16} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pending-invite footer band */}
|
||||
<div style={{
|
||||
margin: "18px 28px 28px", padding: "14px 16px", borderRadius: 10,
|
||||
background: dark ? "#ffffff06" : "#fff8e6",
|
||||
border: `1px solid ${dark ? "#ffffff14" : "#f3e0a4"}`,
|
||||
display: "flex", alignItems: "center", gap: 14,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: dark ? "#ffffff10" : "#f6c56020",
|
||||
color: dark ? "#f6c560" : "#a87b1a",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}><Icon d={P.bell} size={16} /></div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
1 invitation is still pending
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: c.subtext, marginTop: 2 }}>
|
||||
<b>linnea@lattice.co</b> hasn't accepted yet — sent 3 days ago.
|
||||
</div>
|
||||
</div>
|
||||
<button style={{
|
||||
padding: "6px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
|
||||
background: dark ? "#ffffff10" : "#fff",
|
||||
border: `1px solid ${c.border}`, color: c.text, cursor: "pointer",
|
||||
}}>Resend</button>
|
||||
<button style={{
|
||||
padding: "6px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
|
||||
background: "transparent", border: "none", color: c.muted, cursor: "pointer",
|
||||
}}>Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.AdminBody = AdminBody;
|
||||
318
design-templates/VIBN (2)/page-customer.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
// ============================================================
|
||||
// page-customer.jsx — CRM company record page.
|
||||
// Header (logo + name + status + actions), 2-col layout:
|
||||
// left — details panel (industry, owner, links, deals)
|
||||
// right — tabbed work area (Overview / Activity / People / Notes)
|
||||
// Pure content. Wrap in any *Chrome to compose.
|
||||
// ============================================================
|
||||
|
||||
const CustomerBody = ({ theme = "light" }) => {
|
||||
const dark = theme === "dark";
|
||||
const c = dark ? {
|
||||
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
|
||||
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
|
||||
rowAlt: "#ffffff04", accent: "#7a78ff", ring: "#5e5cff",
|
||||
chipBg: "#ffffff08", chipText: "#dcdce4",
|
||||
inputBg: "#0a0a10",
|
||||
} : {
|
||||
bg: "#fcfcfb", panel: "#ffffff", border: "#e8e8e3",
|
||||
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
|
||||
rowAlt: "#f9f9f6", accent: "#5e5cff", ring: "#5e5cff",
|
||||
chipBg: "#f1f0eb", chipText: "#3a3a3e",
|
||||
inputBg: "#fff",
|
||||
};
|
||||
|
||||
const KV = ({ k, v }) => (
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "110px 1fr", gap: 10,
|
||||
padding: "8px 0", fontSize: 13, alignItems: "baseline",
|
||||
}}>
|
||||
<span style={{ color: c.muted }}>{k}</span>
|
||||
<span style={{ color: c.text }}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Tag = ({ children, color }) => (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
padding: "2px 8px", borderRadius: 999,
|
||||
background: color ? `${color}1f` : c.chipBg,
|
||||
color: color || c.chipText,
|
||||
fontSize: 11, fontWeight: 500, whiteSpace: "nowrap",
|
||||
}}>
|
||||
{color && <span style={{
|
||||
width: 6, height: 6, borderRadius: "50%", background: color,
|
||||
}}></span>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const Avatar = ({ name, color = "#d4b8a8", size = 24, ring }) => (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: "50%", background: color,
|
||||
fontSize: size * 0.4, fontWeight: 600, color: "#3a2820",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0, boxShadow: ring ? `0 0 0 2px ${c.panel}, 0 0 0 3px ${ring}` : "none",
|
||||
}}>{name}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "grid", gridTemplateRows: "auto 1fr", height: "100%",
|
||||
background: c.bg, color: c.text, fontFamily: SANS, overflow: "hidden",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: "16px 28px", borderBottom: `1px solid ${c.border}`,
|
||||
display: "flex", alignItems: "center", gap: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: "linear-gradient(135deg, #f6c560 0%, #e08c4a 100%)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontWeight: 700, fontSize: 18, color: "#3a2210",
|
||||
}}>NS</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 3 }}>
|
||||
<h1 style={{
|
||||
fontSize: 22, fontWeight: 600, margin: 0, letterSpacing: "-0.01em",
|
||||
}}>Northstar Logistics</h1>
|
||||
<Tag color="#22c55e">Customer</Tag>
|
||||
<Tag color="#5e5cff">Tier 1</Tag>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12, color: c.muted, display: "flex", gap: 14, alignItems: "center",
|
||||
}}>
|
||||
<span>northstarlogistics.com</span>
|
||||
<span>·</span>
|
||||
<span>Created Aug 2024</span>
|
||||
<span>·</span>
|
||||
<span>Last touched 2h ago</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={{
|
||||
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
|
||||
background: dark ? "#ffffff08" : "#fff",
|
||||
border: `1px solid ${c.border}`,
|
||||
color: c.text, cursor: "pointer", display: "flex", alignItems: "center", gap: 6,
|
||||
}}><Icon d={P.star} size={13}/> Star</button>
|
||||
<button style={{
|
||||
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
|
||||
background: dark ? "#ffffff08" : "#fff",
|
||||
border: `1px solid ${c.border}`,
|
||||
color: c.text, cursor: "pointer",
|
||||
}}>Share</button>
|
||||
<button style={{
|
||||
padding: "7px 14px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
|
||||
background: dark ? "#fff" : "#111",
|
||||
color: dark ? "#111" : "#fff",
|
||||
border: "none", cursor: "pointer", fontWeight: 500,
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
}}><Icon d={P.plus} size={12}/> Log activity</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "320px 1fr", gap: 0,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{/* Details rail */}
|
||||
<div style={{
|
||||
padding: "20px 24px", borderRight: `1px solid ${c.border}`,
|
||||
overflowY: "auto",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", fontWeight: 500, marginBottom: 6,
|
||||
}}>About</div>
|
||||
<KV k="Industry" v="Freight & Logistics" />
|
||||
<KV k="Employees" v="240 — 500" />
|
||||
<KV k="HQ" v="Rotterdam, NL" />
|
||||
<KV k="Founded" v="2011" />
|
||||
<KV k="Owner" v={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<Avatar name="MR" size={18} /> Mira Reyes
|
||||
</span>
|
||||
} />
|
||||
<KV k="Source" v="Referral · DH" />
|
||||
|
||||
<div style={{
|
||||
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", fontWeight: 500, marginTop: 22, marginBottom: 8,
|
||||
}}>Tags</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<Tag color="#e08c4a">Enterprise</Tag>
|
||||
<Tag color="#22c55e">Renewal Q3</Tag>
|
||||
<Tag color="#5e5cff">EMEA</Tag>
|
||||
<Tag>Logistics</Tag>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", fontWeight: 500, marginTop: 22, marginBottom: 8,
|
||||
}}>Open opportunities</div>
|
||||
{[
|
||||
{ name: "Q3 — Carrier API", v: "€84,000", stage: "Negotiation", p: 70 },
|
||||
{ name: "EU expansion", v: "€38,500", stage: "Proposal", p: 40 },
|
||||
{ name: "Renewal · Pro", v: "€24,000", stage: "Discovery", p: 15 },
|
||||
].map(d => (
|
||||
<div key={d.name} style={{
|
||||
padding: "10px 12px", borderRadius: 8,
|
||||
background: c.panel, border: `1px solid ${c.border}`,
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between",
|
||||
alignItems: "baseline", marginBottom: 6,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{d.name}</span>
|
||||
<span style={{
|
||||
fontSize: 12, color: c.subtext, fontVariantNumeric: "tabular-nums",
|
||||
}}>{d.v}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: c.muted, display: "flex",
|
||||
justifyContent: "space-between", marginBottom: 4,
|
||||
}}>
|
||||
<span>{d.stage}</span><span>{d.p}%</span>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 3, borderRadius: 2,
|
||||
background: dark ? "#ffffff10" : "#eeeee9",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${d.p}%`, height: "100%",
|
||||
background: d.p > 60 ? "#22c55e" : d.p > 30 ? "#f6c560" : "#9a9aa6",
|
||||
}}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs + work area */}
|
||||
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div style={{
|
||||
padding: "0 28px", borderBottom: `1px solid ${c.border}`,
|
||||
display: "flex", gap: 0,
|
||||
}}>
|
||||
{["Overview", "Activity", "People", "Notes", "Files"].map((t, i) => (
|
||||
<div key={t} style={{
|
||||
padding: "14px 14px", fontSize: 13, fontWeight: 500,
|
||||
color: i === 1 ? c.text : c.muted,
|
||||
borderBottom: i === 1 ? `2px solid ${c.accent}` : "2px solid transparent",
|
||||
cursor: "pointer", position: "relative", top: 1,
|
||||
}}>{t}{t === "Activity" && " · 28"}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "24px 28px" }}>
|
||||
{/* KPI row */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12,
|
||||
marginBottom: 22,
|
||||
}}>
|
||||
{[
|
||||
{ l: "Pipeline", v: "€146.5k", s: "+€12.4k 30d", up: true },
|
||||
{ l: "Closed-won", v: "€220k", s: "lifetime" },
|
||||
{ l: "Open deals", v: "3", s: "1 stalled", warn: true },
|
||||
{ l: "Health", v: "82 / 100", s: "stable", up: true },
|
||||
].map(k => (
|
||||
<div key={k.l} style={{
|
||||
padding: "14px 16px", borderRadius: 10,
|
||||
background: c.panel, border: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: c.muted, marginBottom: 6 }}>{k.l}</div>
|
||||
<div style={{
|
||||
fontSize: 22, fontWeight: 600, letterSpacing: "-0.01em",
|
||||
}}>{k.v}</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: k.up ? "#22c55e" : k.warn ? "#f6c560" : c.muted,
|
||||
marginTop: 2,
|
||||
}}>{k.s}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Activity timeline */}
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12 }}>Activity</div>
|
||||
<div style={{ position: "relative", paddingLeft: 22 }}>
|
||||
<div style={{
|
||||
position: "absolute", left: 9, top: 6, bottom: 6,
|
||||
width: 1, background: c.border,
|
||||
}}></div>
|
||||
{[
|
||||
{ dot: "#22c55e", t: "Deal moved to Negotiation",
|
||||
who: "Mira Reyes", w: "Q3 — Carrier API · €84,000",
|
||||
when: "2 hours ago" },
|
||||
{ dot: "#5e5cff", t: "Email sent · proposal v4",
|
||||
who: "Mira Reyes", w: "To: Sun Kim, Devi Patel — opened 6 times",
|
||||
when: "Yesterday" },
|
||||
{ dot: "#f6c560", t: "Call logged · 32 min",
|
||||
who: "Theo Roux", w: "Walkthrough with their ops lead — promising",
|
||||
when: "2 days ago" },
|
||||
{ dot: "#9a9aa6", t: "Note added",
|
||||
who: "Mira Reyes", w: "They want SSO and SCIM by Sept. — gating item.",
|
||||
when: "4 days ago" },
|
||||
].map((a, i) => (
|
||||
<div key={i} style={{ marginBottom: 16, position: "relative" }}>
|
||||
<span style={{
|
||||
position: "absolute", left: -19, top: 4, width: 11, height: 11,
|
||||
background: a.dot, borderRadius: "50%",
|
||||
boxShadow: `0 0 0 3px ${c.bg}`,
|
||||
}}></span>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
}}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{a.t}</span>
|
||||
<span style={{ fontSize: 11, color: c.muted }}>{a.when}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: c.subtext, marginTop: 2 }}>
|
||||
{a.who} · {a.w}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* People row */}
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 600, marginTop: 12, marginBottom: 12,
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
}}>
|
||||
<span>People at Northstar · 6</span>
|
||||
<button style={{
|
||||
background: "transparent", border: "none", color: c.accent,
|
||||
fontSize: 12, fontFamily: SANS, cursor: "pointer",
|
||||
}}>View all →</button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10,
|
||||
}}>
|
||||
{[
|
||||
{ i: "SK", n: "Sun Kim", r: "VP Operations", c: "#e8a87c" },
|
||||
{ i: "DP", n: "Devi Patel", r: "Procurement", c: "#a8c8e8" },
|
||||
{ i: "TR", n: "Theo Roux", r: "CFO", c: "#c8e8a8" },
|
||||
].map(p => (
|
||||
<div key={p.i} style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "10px 12px", borderRadius: 8,
|
||||
background: c.panel, border: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<Avatar name={p.i} color={p.c} size={32} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{p.n}</div>
|
||||
<div style={{ fontSize: 11, color: c.muted }}>{p.r}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.CustomerBody = CustomerBody;
|
||||
355
design-templates/VIBN (2)/page-dashboard.jsx
Normal file
@@ -0,0 +1,355 @@
|
||||
// ============================================================
|
||||
// page-dashboard.jsx — KPI strip + time-series chart +
|
||||
// pipeline funnel + recent activity + team leaderboard.
|
||||
// Theme-aware so it adapts to dark rail chrome.
|
||||
// ============================================================
|
||||
|
||||
const DashboardBody = ({ theme = "light" }) => {
|
||||
const dark = theme === "dark";
|
||||
const c = dark ? {
|
||||
bg: "#0f0f14", panel: "#13131a", border: "#ffffff10",
|
||||
text: "#e8e8ee", subtext: "#9a9aa6", muted: "#6a6a78",
|
||||
grid: "#ffffff08", accent: "#7a78ff", up: "#22c55e", down: "#ff4d5e",
|
||||
} : {
|
||||
bg: "#fafaf9", panel: "#ffffff", border: "#ebebe6",
|
||||
text: "#111", subtext: "#5a5a5e", muted: "#8a8a90",
|
||||
grid: "#eeeee9", accent: "#5e5cff", up: "#22c55e", down: "#ff4d5e",
|
||||
};
|
||||
|
||||
// Synthetic but consistent daily series, weekday-shaped
|
||||
const days = ["M","T","W","T","F","S","S","M","T","W","T","F","S","S"];
|
||||
const series = [42,58,71,64,79,32,28, 51,68,82,75,90,38,33];
|
||||
const max = Math.max(...series);
|
||||
|
||||
// Funnel data
|
||||
const funnel = [
|
||||
{ stage: "New", n: 184, v: "€2.1m" },
|
||||
{ stage: "Qualified", n: 96, v: "€1.4m" },
|
||||
{ stage: "Proposal", n: 42, v: "€780k" },
|
||||
{ stage: "Negotiation", n: 19, v: "€420k" },
|
||||
{ stage: "Closed-won", n: 11, v: "€286k" },
|
||||
];
|
||||
const fmax = funnel[0].n;
|
||||
|
||||
const Avatar = ({ name, color = "#d4b8a8", size = 22 }) => (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: "50%", background: color,
|
||||
fontSize: size * 0.42, fontWeight: 600, color: "#3a2820",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}>{name}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: "100%", background: c.bg, color: c.text, fontFamily: SANS,
|
||||
display: "flex", flexDirection: "column", overflow: "hidden",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: "20px 28px 16px", borderBottom: `1px solid ${c.border}`,
|
||||
display: "flex", alignItems: "flex-end", justifyContent: "space-between",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 11, color: c.muted, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", marginBottom: 4, fontWeight: 500,
|
||||
}}>Workspace dashboard</div>
|
||||
<h1 style={{
|
||||
fontSize: 26, fontWeight: 600, margin: 0, letterSpacing: "-0.02em",
|
||||
}}>Good afternoon, Mira</h1>
|
||||
<div style={{ fontSize: 13, color: c.subtext, marginTop: 4 }}>
|
||||
3 deals moved stage today · 12 unread in Inbox · 1 task overdue
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", padding: "6px 10px",
|
||||
borderRadius: 6, background: c.panel, border: `1px solid ${c.border}`,
|
||||
fontSize: 12, color: c.subtext, gap: 8,
|
||||
}}>
|
||||
<span style={{ fontWeight: 500, color: c.text }}>Last 14 days</span>
|
||||
<Icon d={P.chevron} size={12} />
|
||||
</div>
|
||||
<button style={{
|
||||
padding: "7px 12px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
|
||||
background: c.panel, border: `1px solid ${c.border}`, color: c.text,
|
||||
cursor: "pointer",
|
||||
}}>Export</button>
|
||||
<button style={{
|
||||
padding: "7px 14px", borderRadius: 6, fontSize: 12, fontFamily: SANS,
|
||||
background: dark ? "#fff" : "#111", color: dark ? "#111" : "#fff",
|
||||
border: "none", cursor: "pointer", fontWeight: 500,
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
}}><Icon d={P.plus} size={12}/> New report</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: 1, overflowY: "auto", padding: "20px 28px 28px",
|
||||
display: "flex", flexDirection: "column", gap: 20,
|
||||
}}>
|
||||
{/* KPI strip */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12,
|
||||
}}>
|
||||
{[
|
||||
{ l: "Revenue · MTD", v: "€286,420", d: "+18.4%", up: true,
|
||||
spark: [20,28,24,36,30,42,52,48,58,62,70,82] },
|
||||
{ l: "Active deals", v: "168", d: "+12", up: true,
|
||||
spark: [40,42,45,46,49,52,54,56,58,60,62,65] },
|
||||
{ l: "Win rate · 30d", v: "34.2%", d: "−1.1%", up: false,
|
||||
spark: [60,58,55,52,54,50,48,45,46,42,38,36] },
|
||||
{ l: "Pipeline ratio", v: "4.8×", d: "healthy", up: true,
|
||||
spark: [50,48,52,55,53,58,56,60,62,65,63,68] },
|
||||
].map(k => {
|
||||
const sm = Math.max(...k.spark), sn = Math.min(...k.spark);
|
||||
const pts = k.spark.map((v, i) => {
|
||||
const x = (i / (k.spark.length - 1)) * 100;
|
||||
const y = 30 - ((v - sn) / (sm - sn || 1)) * 26 - 2;
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
return (
|
||||
<div key={k.l} style={{
|
||||
padding: "16px 18px", borderRadius: 10,
|
||||
background: c.panel, border: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: c.muted }}>{k.l}</span>
|
||||
<span style={{
|
||||
fontSize: 11, color: k.up ? c.up : c.down, fontWeight: 500,
|
||||
}}>{k.d}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em",
|
||||
marginBottom: 6, fontVariantNumeric: "tabular-nums",
|
||||
}}>{k.v}</div>
|
||||
<svg viewBox="0 0 100 30" style={{
|
||||
width: "100%", height: 26, display: "block",
|
||||
}} preserveAspectRatio="none">
|
||||
<polyline points={pts} fill="none"
|
||||
stroke={k.up ? c.up : c.down} strokeWidth="1.5"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Chart + funnel */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16,
|
||||
}}>
|
||||
{/* Time-series */}
|
||||
<div style={{
|
||||
padding: "18px 20px", borderRadius: 12,
|
||||
background: c.panel, border: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Revenue, daily</div>
|
||||
<div style={{ fontSize: 11, color: c.muted, marginTop: 2 }}>
|
||||
Bookings · GBP closed-won
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{["Day", "Week", "Month"].map((t, i) => (
|
||||
<span key={t} style={{
|
||||
padding: "4px 10px", borderRadius: 5, fontSize: 11, fontWeight: 500,
|
||||
background: i === 0 ? (dark ? "#ffffff10" : "#f1f0eb") : "transparent",
|
||||
color: i === 0 ? c.text : c.muted, cursor: "pointer",
|
||||
}}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height: 180 }}>
|
||||
{/* Gridlines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(p => (
|
||||
<div key={p} style={{
|
||||
position: "absolute", left: 0, right: 0,
|
||||
bottom: `${p * 100}%`, height: 1, background: c.grid,
|
||||
}}></div>
|
||||
))}
|
||||
{/* Bars */}
|
||||
<div style={{
|
||||
position: "absolute", inset: 0, display: "flex",
|
||||
alignItems: "flex-end", gap: 6, paddingRight: 6,
|
||||
}}>
|
||||
{series.map((v, i) => (
|
||||
<div key={i} style={{ flex: 1, position: "relative",
|
||||
display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "flex-end",
|
||||
height: "100%",
|
||||
}}>
|
||||
<div style={{
|
||||
width: "100%", height: `${(v / max) * 100}%`,
|
||||
background: i === 11
|
||||
? `linear-gradient(180deg, ${c.accent}, ${dark ? "#3a38c0" : "#bfbeff"})`
|
||||
: (dark ? "#ffffff14" : "#e8e7e0"),
|
||||
borderRadius: 3,
|
||||
}}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Annotation */}
|
||||
<div style={{
|
||||
position: "absolute", right: 6, top: -6,
|
||||
background: c.text, color: c.bg,
|
||||
padding: "3px 8px", borderRadius: 4, fontSize: 11, fontWeight: 500,
|
||||
}}>€42k · today</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", marginTop: 6,
|
||||
fontSize: 10, color: c.muted, fontFamily: "ui-monospace, monospace",
|
||||
}}>
|
||||
{days.map((d, i) => (
|
||||
<span key={i} style={{ flex: 1, textAlign: "center" }}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Funnel */}
|
||||
<div style={{
|
||||
padding: "18px 20px", borderRadius: 12,
|
||||
background: c.panel, border: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Pipeline funnel</div>
|
||||
<span style={{ fontSize: 11, color: c.muted }}>Q2 · 168 deals</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{funnel.map((f, i) => {
|
||||
const w = (f.n / fmax) * 100;
|
||||
const colors = ["#5e5cff", "#7a78ff", "#9b99ff", "#bcb9ff", "#22c55e"];
|
||||
return (
|
||||
<div key={f.stage} style={{ position: "relative" }}>
|
||||
<div style={{
|
||||
width: `${w}%`, height: 30, borderRadius: 5,
|
||||
background: colors[i], display: "flex",
|
||||
alignItems: "center", paddingLeft: 12, color: "#fff",
|
||||
fontSize: 12, fontWeight: 500,
|
||||
}}>{f.stage}</div>
|
||||
<div style={{
|
||||
position: "absolute", right: 0, top: 0, height: 30,
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
fontSize: 12, color: c.muted,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: "ui-monospace, monospace", color: c.text,
|
||||
}}>{f.n}</span>
|
||||
<span style={{ fontSize: 11 }}>{f.v}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity + leaderboard */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: "18px 20px", borderRadius: 12,
|
||||
background: c.panel, border: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Recent activity</div>
|
||||
<span style={{ fontSize: 11, color: c.accent, cursor: "pointer" }}>View all →</span>
|
||||
</div>
|
||||
{[
|
||||
{ who: "MR", c: "#d4b8a8", n: "Mira Reyes", v: "moved",
|
||||
w: <><b>Q3 — Carrier API</b> to <span style={{ color: "#22c55e" }}>Negotiation</span></>,
|
||||
t: "2m ago" },
|
||||
{ who: "TR", c: "#c8e8a8", n: "Theo Roux", v: "logged a call with",
|
||||
w: <><b>Sun Kim · Northstar</b></>, t: "14m" },
|
||||
{ who: "DP", c: "#a8c8e8", n: "Devi Patel", v: "closed",
|
||||
w: <><b>Halcyon · Pro renewal</b> · €24,000</>, t: "1h" },
|
||||
{ who: "MR", c: "#d4b8a8", n: "Mira Reyes", v: "created a deal",
|
||||
w: <><b>Brooke Foods — Q3 pilot</b></>, t: "2h" },
|
||||
{ who: "SK", c: "#e8a87c", n: "Sun Kim", v: "added 4 contacts to",
|
||||
w: <><b>Kestrel</b></>, t: "3h" },
|
||||
].map((a, i) => (
|
||||
<div key={i} style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "8px 0",
|
||||
borderTop: i === 0 ? "none" : `1px solid ${c.border}`,
|
||||
}}>
|
||||
<Avatar name={a.who} color={a.c} size={26} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>
|
||||
<span style={{ fontWeight: 500 }}>{a.n}</span>
|
||||
<span style={{ color: c.muted }}> {a.v} </span>
|
||||
<span>{a.w}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: c.muted, whiteSpace: "nowrap" }}>{a.t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: "18px 20px", borderRadius: 12,
|
||||
background: c.panel, border: `1px solid ${c.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Team · this month</div>
|
||||
<span style={{ fontSize: 11, color: c.muted }}>By bookings</span>
|
||||
</div>
|
||||
{[
|
||||
{ i: "MR", c: "#d4b8a8", n: "Mira Reyes", v: 124, d: "€124k", p: 100 },
|
||||
{ i: "DP", c: "#a8c8e8", n: "Devi Patel", v: 86, d: "€86k", p: 70 },
|
||||
{ i: "TR", c: "#c8e8a8", n: "Theo Roux", v: 62, d: "€62k", p: 50 },
|
||||
{ i: "SK", c: "#e8a87c", n: "Sun Kim", v: 48, d: "€48k", p: 39 },
|
||||
].map(t => (
|
||||
<div key={t.i} style={{
|
||||
display: "grid", gridTemplateColumns: "26px 1fr auto", gap: 10,
|
||||
alignItems: "center", padding: "8px 0",
|
||||
}}>
|
||||
<Avatar name={t.i} color={t.c} size={26} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between",
|
||||
fontSize: 12, marginBottom: 4,
|
||||
}}>
|
||||
<span style={{ fontWeight: 500 }}>{t.n}</span>
|
||||
<span style={{
|
||||
color: c.subtext, fontVariantNumeric: "tabular-nums",
|
||||
}}>{t.d}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 3, borderRadius: 2,
|
||||
background: dark ? "#ffffff10" : "#eeeee9", overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${t.p}%`, height: "100%",
|
||||
background: `linear-gradient(90deg, ${c.accent}, ${dark ? "#9b99ff" : "#b15bff"})`,
|
||||
}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.DashboardBody = DashboardBody;
|
||||
BIN
design-templates/VIBN (2)/screenshots/hero-current.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
design-templates/VIBN (2)/screenshots/hero-v2.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
design-templates/VIBN (2)/screenshots/hero-v2b.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
design-templates/VIBN (2)/screenshots/logo-check.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
design-templates/VIBN (2)/screenshots/logo-nav.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
design-templates/VIBN (2)/screenshots/signin-bundled.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
139
design-templates/VIBN (2)/signin.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
// Sign In — magic-link primary, OAuth alternatives. Default action is
|
||||
// "Send me a magic link" (no passwords — fits the "no homework" brand).
|
||||
// On submit, transitions to a "Check your inbox" confirmation state.
|
||||
|
||||
function SignIn() {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [sent, setSent] = React.useState(false);
|
||||
|
||||
const valid = /\S+@\S+\.\S+/.test(email);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!valid || submitting) return;
|
||||
setSubmitting(true);
|
||||
setTimeout(() => {
|
||||
setSubmitting(false);
|
||||
setSent(true);
|
||||
}, 700);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<TopBar rightLink={{ href: "index.html", label: "Back to home" }} />
|
||||
|
||||
<main className="auth-main">
|
||||
<Glows />
|
||||
|
||||
<div className="auth-card">
|
||||
{sent ? (
|
||||
<SentConfirmation email={email} onChangeEmail={() => setSent(false)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="auth-eye">Welcome back</div>
|
||||
<h1 className="auth-title">
|
||||
Sign in and <em>keep building</em>.
|
||||
</h1>
|
||||
<p className="auth-sub">
|
||||
We'll email you a one-tap link. No passwords to remember, no homework.
|
||||
</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit} noValidate>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email" type="email" autoComplete="email" required autoFocus
|
||||
className="auth-input"
|
||||
placeholder="you@somewhere.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={!valid || submitting}
|
||||
className="auth-btn auth-btn-primary">
|
||||
{submitting ? (
|
||||
<><span className="auth-spinner" /> Sending…</>
|
||||
) : (
|
||||
<><MailIcon size={17} /> Send me a magic link</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-divider">or continue with</div>
|
||||
|
||||
<div className="auth-oauth">
|
||||
<button type="button" className="auth-btn auth-btn-ghost">
|
||||
<GoogleIcon /> Continue with Google
|
||||
</button>
|
||||
<button type="button" className="auth-btn auth-btn-ghost">
|
||||
<AppleIcon /> Continue with Apple
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="auth-foot">
|
||||
Don't have an invite yet? <a href="Beta Signup.html">Request one →</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TrustStrip items={["No passwords", "No homework", "🇨🇦 Built in Canada"]} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Confirmation: "Check your inbox at you@x.com" with a resend timer + the
|
||||
// option to change email and try again.
|
||||
function SentConfirmation({ email, onChangeEmail }) {
|
||||
const [left, restart] = useResendTimer(30);
|
||||
|
||||
return (
|
||||
<div className="auth-success">
|
||||
<div className="auth-success-badge">
|
||||
<MailIcon size={26} />
|
||||
</div>
|
||||
<div className="auth-eye">Check your inbox</div>
|
||||
<h1 className="auth-title" style={{ marginTop: 10 }}>
|
||||
Magic link <em>sent</em>.
|
||||
</h1>
|
||||
<p className="auth-sub">
|
||||
We just sent a one-tap sign-in link to
|
||||
<span className="email-chip">{email}</span>.
|
||||
Tap it on this device to keep building.
|
||||
</p>
|
||||
|
||||
<div className="auth-tip">
|
||||
<span className="auth-tip-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 5v4M8 11v.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
Can't find it? Check your <b style={{ color: "var(--fg)", fontWeight: 500 }}>spam folder</b> or wait a few seconds —
|
||||
email is slower than Vibn.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="auth-resend">
|
||||
Didn't get it?{" "}
|
||||
{left > 0 ? (
|
||||
<button type="button" disabled>Resend in {left}s</button>
|
||||
) : (
|
||||
<button type="button" onClick={restart}>Send again</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="auth-foot" style={{ marginTop: 22 }}>
|
||||
Wrong email? <button type="button" onClick={onChangeEmail}
|
||||
style={{ color: "var(--accent)", fontWeight: 500 }}>
|
||||
Use a different one
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<SignIn />);
|
||||
213
design-templates/VIBN (2)/signup.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
// Sign Up — invite-code gated. User pastes/types their invite, gives email +
|
||||
// optional name, hits "Create my workspace." Magic-link delivery on submit.
|
||||
// OAuth is offered too but the invite is still required.
|
||||
|
||||
function SignUp() {
|
||||
const [code, setCode] = React.useState("");
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [name, setName] = React.useState("");
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [created, setCreated] = React.useState(false);
|
||||
const [codeState, setCodeState] = React.useState("idle"); // idle | checking | ok | bad
|
||||
|
||||
// Validate the invite code shape (V-XXXXXX, case-insensitive) and pretend to
|
||||
// verify with a debounce so the UI feels alive even with no backend.
|
||||
React.useEffect(() => {
|
||||
const c = code.trim().toLowerCase();
|
||||
if (!c) { setCodeState("idle"); return undefined; }
|
||||
const looksValid = /^v-?[a-z0-9]{4,8}$/.test(c);
|
||||
if (!looksValid) { setCodeState("bad"); return undefined; }
|
||||
setCodeState("checking");
|
||||
const t = setTimeout(() => setCodeState("ok"), 600);
|
||||
return () => clearTimeout(t);
|
||||
}, [code]);
|
||||
|
||||
const emailValid = /\S+@\S+\.\S+/.test(email);
|
||||
const valid = emailValid && codeState === "ok";
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!valid || submitting) return;
|
||||
setSubmitting(true);
|
||||
setTimeout(() => {
|
||||
setSubmitting(false);
|
||||
setCreated(true);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<TopBar rightLink={{ href: "index.html", label: "Back to home" }} />
|
||||
|
||||
<main className="auth-main">
|
||||
<Glows />
|
||||
|
||||
<div className="auth-card">
|
||||
{created ? (
|
||||
<CreatedConfirmation email={email} name={name} />
|
||||
) : (
|
||||
<>
|
||||
<div className="auth-eye">You're invited</div>
|
||||
<h1 className="auth-title">
|
||||
Create your <em>workspace</em>.
|
||||
</h1>
|
||||
<p className="auth-sub">
|
||||
Paste your invite code and the email it came to. We'll have you building in seconds.
|
||||
</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit} noValidate>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="code">Invite code</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
id="code" type="text" autoComplete="off"
|
||||
required autoFocus
|
||||
className="auth-input mono"
|
||||
placeholder="V-XXXXXX"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
maxLength={12}
|
||||
style={{ paddingRight: 44 }}
|
||||
/>
|
||||
<CodeStatus state={codeState} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email" type="email" autoComplete="email" required
|
||||
className="auth-input"
|
||||
placeholder="you@somewhere.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="name">
|
||||
What should we call you? <span style={{ color: "var(--fg-faint)", letterSpacing: 0, textTransform: "none" }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="name" type="text" autoComplete="given-name"
|
||||
className="auth-input"
|
||||
placeholder="First name or handle"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={!valid || submitting}
|
||||
className="auth-btn auth-btn-primary"
|
||||
style={{ marginTop: 4 }}>
|
||||
{submitting ? (
|
||||
<><span className="auth-spinner" /> Creating your workspace…</>
|
||||
) : (
|
||||
<>Create my workspace <Arrow size={13} /></>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-divider">or continue with</div>
|
||||
|
||||
<div className="auth-oauth">
|
||||
<button type="button" className="auth-btn auth-btn-ghost">
|
||||
<GoogleIcon /> Continue with Google
|
||||
</button>
|
||||
<button type="button" className="auth-btn auth-btn-ghost">
|
||||
<AppleIcon /> Continue with Apple
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="auth-fine">
|
||||
By creating a workspace you agree to our <a href="#">Terms</a> and <a href="#">Privacy Policy</a>.
|
||||
</p>
|
||||
|
||||
<div className="auth-foot">
|
||||
Already have an account? <a href="Sign In.html">Sign in →</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TrustStrip items={["No credit card", "No homework", "🇨🇦 Built in Canada"]} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeStatus({ state }) {
|
||||
const wrap = {
|
||||
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
fontFamily: "var(--font-mono)", fontSize: 11, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
if (state === "idle") return null;
|
||||
if (state === "checking") return (
|
||||
<span style={{ ...wrap, color: "var(--fg-mute)" }}>
|
||||
<span className="auth-spinner" style={{ width: 12, height: 12, borderTopColor: "var(--fg-mute)" }} />
|
||||
</span>
|
||||
);
|
||||
if (state === "bad") return (
|
||||
<span style={{ ...wrap, color: "oklch(0.65 0.18 25)" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.5"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
if (state === "ok") return (
|
||||
<span style={{ ...wrap, color: "var(--ok)" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m3 8.5 3.2 3.2L13 5"/>
|
||||
</svg>
|
||||
Valid
|
||||
</span>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Confirmation — we've sent the magic link AND provisioned a workspace.
|
||||
// Small celebratory beat: "Welcome, <name>" if given, else "You're in."
|
||||
function CreatedConfirmation({ email, name }) {
|
||||
return (
|
||||
<div className="auth-success">
|
||||
<div className="auth-success-badge">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="14" cy="14" r="13" opacity="0.25"/>
|
||||
<path d="M8 14.5 12.5 19 21 10"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="auth-eye">Workspace ready</div>
|
||||
<h1 className="auth-title" style={{ marginTop: 10 }}>
|
||||
{name ? <>Welcome, <em>{name}</em>.</> : <>You're <em>in</em>.</>}
|
||||
</h1>
|
||||
<p className="auth-sub">
|
||||
We sent a sign-in link to <span className="email-chip">{email}</span>.
|
||||
Tap it on this device to step inside your workspace.
|
||||
</p>
|
||||
|
||||
<div className="auth-tip">
|
||||
<span className="auth-tip-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path d="M3.5 12 8 3l4.5 9"/><path d="M5 9h6"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
While you're waiting on the email — your workspace lives at{" "}
|
||||
<b style={{ color: "var(--fg)", fontWeight: 500, fontFamily: "var(--font-mono)" }}>
|
||||
{(name || email.split("@")[0] || "you").toLowerCase().replace(/[^a-z0-9-]/g, "")}.vibn.app
|
||||
</b>
|
||||
. We'll send you the keys.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="auth-foot" style={{ marginTop: 24 }}>
|
||||
Already opened the email? <a href="Sign In.html">Continue here →</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<SignUp />);
|
||||
343
design-templates/VIBN (2)/stack.jsx
Normal file
@@ -0,0 +1,343 @@
|
||||
// Replace your stack — visualizes the SMB's fractured subscription landscape
|
||||
// collapsing into one tool. Eight grayscale "rented" tiles + spaghetti
|
||||
// connections, an arrow, then one bright "owned" tile.
|
||||
|
||||
const STACK_TOOLS = [
|
||||
{ name: "Booking", price: "$29/mo", glyph: "B" },
|
||||
{ name: "POS", price: "$79/mo", glyph: "P" },
|
||||
{ name: "CRM", price: "$45/mo", glyph: "C" },
|
||||
{ name: "Accounting", price: "$30/mo", glyph: "A" },
|
||||
{ name: "Inventory", price: "$59/mo", glyph: "I" },
|
||||
{ name: "Email", price: "$19/mo", glyph: "E" },
|
||||
{ name: "Loyalty", price: "$25/mo", glyph: "L" },
|
||||
{ name: "+ spreadsheet", price: "the one you trust", glyph: "+" },
|
||||
];
|
||||
|
||||
function Stack() {
|
||||
return (
|
||||
<section className="section stack">
|
||||
<style>{`
|
||||
.stack { padding-block: clamp(80px, 11vh, 130px); }
|
||||
.stack-head { text-align: center; max-width: 820px; margin: 0 auto 56px; }
|
||||
.stack-title {
|
||||
font-size: clamp(36px, 4.8vw, 64px);
|
||||
font-weight: 500; letter-spacing: -0.025em; line-height: 1.02;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.stack-title .accent { color: var(--accent); }
|
||||
.stack-sub {
|
||||
margin-top: 20px;
|
||||
color: var(--fg-mute); font-size: 17px;
|
||||
line-height: 1.5;
|
||||
text-wrap: balance;
|
||||
max-width: 640px; margin-inline: auto;
|
||||
}
|
||||
|
||||
.stack-stage {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: clamp(20px, 4vw, 56px);
|
||||
align-items: center;
|
||||
max-width: 1080px; margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.stack-stage { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Left side: 8 rented tiles */
|
||||
.rented-wrap { position: relative; }
|
||||
.rented-label, .owned-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-faint);
|
||||
margin-bottom: 16px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.owned-label { color: var(--accent); }
|
||||
.rented-label::after, .owned-label::after {
|
||||
content: ""; flex: 1; height: 1px;
|
||||
background: var(--hairline);
|
||||
}
|
||||
|
||||
.rented-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.rented-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.tool-tile {
|
||||
position: relative;
|
||||
padding: 14px 12px 12px;
|
||||
background: oklch(0.18 0.006 60 / 0.7);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 10px;
|
||||
filter: grayscale(1);
|
||||
opacity: 0.85;
|
||||
transition: opacity .3s;
|
||||
}
|
||||
.tool-tile:hover { opacity: 1; }
|
||||
.tool-glyph {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 6px;
|
||||
background: oklch(0.30 0.008 60);
|
||||
color: var(--fg-mute);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
display: grid; place-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tool-name {
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.tool-price {
|
||||
margin-top: 2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--fg-faint);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* The chaos: faint connecting lines between tiles */
|
||||
.rented-grid::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
background:
|
||||
radial-gradient(circle at 22% 28%, oklch(0.50 0.05 35 / 0.18), transparent 24%),
|
||||
radial-gradient(circle at 78% 72%, oklch(0.50 0.05 60 / 0.15), transparent 26%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.rented-grid > .tool-tile { z-index: 1; }
|
||||
|
||||
/* Spaghetti lines SVG overlay */
|
||||
.rented-spaghetti {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.rented-spaghetti path {
|
||||
fill: none;
|
||||
stroke: oklch(0.45 0.04 35 / 0.4);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 4;
|
||||
}
|
||||
|
||||
/* Rented total at bottom */
|
||||
.rented-total {
|
||||
margin-top: 14px;
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 10px 14px;
|
||||
background: oklch(0.16 0.008 60 / 0.5);
|
||||
border: 1px dashed var(--hairline);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-mute);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.rented-total b {
|
||||
color: var(--fg-dim);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Middle arrow */
|
||||
.stack-arrow {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
color: var(--accent);
|
||||
gap: 6px;
|
||||
}
|
||||
.stack-arrow svg {
|
||||
filter: drop-shadow(0 0 12px var(--accent-glow));
|
||||
}
|
||||
.stack-arrow-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.stack-arrow { padding: 16px 0; transform: rotate(90deg); }
|
||||
}
|
||||
|
||||
/* Right side: one tile */
|
||||
.owned-tile {
|
||||
position: relative;
|
||||
padding: 28px 26px 26px;
|
||||
background: linear-gradient(180deg, oklch(0.22 0.012 60 / 0.95), oklch(0.18 0.008 60 / 0.95));
|
||||
border: 1px solid oklch(0.74 0.175 35 / 0.55);
|
||||
border-radius: 18px;
|
||||
box-shadow:
|
||||
0 0 60px -10px var(--accent-glow),
|
||||
0 30px 80px -20px oklch(0 0 0 / 0.6),
|
||||
inset 0 1px 0 oklch(0.84 0.16 35 / 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.owned-tile::before {
|
||||
/* Top accent hairline */
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
opacity: .9;
|
||||
}
|
||||
.owned-glyph {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), oklch(0.65 0.20 18));
|
||||
color: var(--accent-fg);
|
||||
display: grid; place-items: center;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
|
||||
}
|
||||
.owned-glyph svg { display: block; }
|
||||
.owned-title {
|
||||
font-size: 22px; font-weight: 500;
|
||||
letter-spacing: -0.018em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.owned-sub {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.owned-features {
|
||||
list-style: none; padding: 0; margin: 18px 0 0;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
font-size: 13.5px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.owned-features li {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.owned-features li::before {
|
||||
content: "✓";
|
||||
color: var(--ok);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
width: 16px; flex-shrink: 0;
|
||||
}
|
||||
.owned-foot {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-mute);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.owned-foot b {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stack-foot {
|
||||
margin-top: 48px;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
color: var(--fg-dim);
|
||||
text-wrap: balance;
|
||||
max-width: 720px; margin-inline: auto;
|
||||
}
|
||||
.stack-foot b { color: var(--fg); font-weight: 500; }
|
||||
`}</style>
|
||||
|
||||
<div className="wrap">
|
||||
<div className="stack-head">
|
||||
<Eyebrow>Replace your stack</Eyebrow>
|
||||
<h2 className="stack-title" style={{ marginTop: 18 }}>
|
||||
You're running your business on <span className="accent">eight tools</span>
|
||||
<br/>that don't fit.
|
||||
</h2>
|
||||
<p className="stack-sub">
|
||||
A booking app over here. Invoicing over there. A separate CRM.
|
||||
A POS that doesn't quite know about either. An accounting add-on, a scheduler,
|
||||
a loyalty platform, plus the spreadsheet you actually trust.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="stack-stage">
|
||||
{/* Left: rented mess */}
|
||||
<div className="rented-wrap">
|
||||
<div className="rented-label">What you rent today</div>
|
||||
<div className="rented-grid">
|
||||
<svg className="rented-spaghetti" viewBox="0 0 200 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d="M20 25 C 60 60, 120 10, 180 50" />
|
||||
<path d="M50 15 C 90 70, 130 80, 170 25" />
|
||||
<path d="M30 75 C 80 30, 140 60, 180 80" />
|
||||
<path d="M15 50 C 80 80, 120 20, 185 70" />
|
||||
</svg>
|
||||
{STACK_TOOLS.map((tool) => (
|
||||
<div className="tool-tile" key={tool.name}>
|
||||
<div className="tool-glyph">{tool.glyph}</div>
|
||||
<div className="tool-name">{tool.name}</div>
|
||||
<div className="tool-price">{tool.price}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rented-total">
|
||||
<span>Monthly rent</span>
|
||||
<b>$286+ / mo</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: arrow */}
|
||||
<div className="stack-arrow">
|
||||
<span className="stack-arrow-label">Replaced by</span>
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" aria-hidden="true">
|
||||
<path d="M8 22h26M24 12l10 10-10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Right: owned tile */}
|
||||
<div className="owned-tile">
|
||||
<div className="owned-label">What you own with Vibn</div>
|
||||
<div className="owned-glyph">
|
||||
<svg viewBox="0 0 36 32" width="60%" height="60%" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
|
||||
<rect x="22.5" y="23" width="9.5" height="3.8" rx="0.7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="owned-title">Your business, one tool.</div>
|
||||
<div className="owned-sub">built for you · owned by you</div>
|
||||
<ul className="owned-features">
|
||||
<li>Bookings, customers, invoicing — one place</li>
|
||||
<li>Fits how your business actually runs</li>
|
||||
<li>No new tools to learn. No homework.</li>
|
||||
<li>You own the code. You own the data.</li>
|
||||
</ul>
|
||||
<div className="owned-foot">
|
||||
<span>One tool</span>
|
||||
<b>One price · No rent</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="stack-foot">
|
||||
Eight tools, none of them built for you, none of them talking to each other.
|
||||
<br/><b>Vibn replaces the whole stack with one tool — built for your business, owned by you.</b>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Stack });
|
||||
1104
design-templates/VIBN (2)/styles.jsx
Normal file
BIN
design-templates/VIBN (2)/uploads/vibn-black-circle-logo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
120
design-templates/VIBN (2)/vibn-ai-templates/README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Vibn AI Templates
|
||||
|
||||
A small, themable starter kit for building modern SaaS UIs. Pure React + CSS variables — no build step, no dependencies. Designed to be copy-pasted into any project.
|
||||
|
||||
## What's in it
|
||||
|
||||
- **`tokens.css`** — Every color, radius, shadow, and type token, exposed as CSS custom properties. Four themes ship out of the box:
|
||||
- `.theme-minimal` — soft warm light (Linear / Notion school)
|
||||
- `.theme-dark` — black-and-white surface (Vercel / Stripe school)
|
||||
- `.theme-glass` — aurora gradient + frosted glass
|
||||
- `.theme-editorial` — paper, serif display, hairline rules
|
||||
- **`icons.jsx`** — A small Tabler-style stroke icon set (`<Icon name="search"/>`) plus a `<VibnMark/>` brand glyph.
|
||||
- **`components.jsx`** — Atoms + composites. Every visual property reads from a CSS variable:
|
||||
- **Forms** · `Button`, `IconButton`, `Field`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`, `FieldGroup`
|
||||
- **Containers** · `Card`, `CardHeader`, `Divider`, `Modal`, `Banner`
|
||||
- **Display** · `Badge` (tones: neutral / accent / success / warn / danger / info), `Avatar`, `AvatarStack`, `Tabs`, `Table`, `Spinner`, `KBD`
|
||||
- **`shells.jsx`** — Page-level layouts:
|
||||
- **In-product** · `SidebarShell`, `TopbarShell`, `RailShell`
|
||||
- **Auth** · `AuthCenteredShell`, `AuthSplitShell`, `AuthGlassShell`
|
||||
|
||||
## How theming works
|
||||
|
||||
Tokens are CSS custom properties on `:root` (the default minimal theme). Each `.theme-*` class overrides a subset. Apply a theme by adding the class anywhere — usually on `<html>` or a top-level wrapper.
|
||||
|
||||
```html
|
||||
<html class="theme-glass">
|
||||
<!-- the whole page uses the glass theme -->
|
||||
</html>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- or scope a theme to one region -->
|
||||
<div class="theme-editorial">
|
||||
<Card>… this card is editorial …</Card>
|
||||
</div>
|
||||
```
|
||||
|
||||
Themes can nest. Setting `theme-*` on a child element overrides only the tokens that theme defines; the rest inherit from the parent.
|
||||
|
||||
### Adding a fifth theme
|
||||
|
||||
Add a new class to `tokens.css` that overrides whichever tokens differ from `:root`:
|
||||
|
||||
```css
|
||||
.theme-sunset {
|
||||
--bg: #2b0d0e;
|
||||
--surface: #3a1316;
|
||||
--accent: #ff8a3a;
|
||||
--accent-2: #f43f5e;
|
||||
--text: #fef7ee;
|
||||
--text-2: #f0c8b0;
|
||||
--border: #4a1f23;
|
||||
--button-bg: #ff8a3a;
|
||||
--button-fg: #2b0d0e;
|
||||
}
|
||||
```
|
||||
|
||||
You don't need to redefine the whole token set — just the differences. Components don't change.
|
||||
|
||||
## Usage in plain HTML (no bundler)
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
|
||||
|
||||
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
|
||||
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
|
||||
|
||||
<div id="root" class="theme-dark"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
function App() {
|
||||
return (
|
||||
<AuthCenteredShell brand={{ name: "Acme" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 600 }}>Welcome back</h1>
|
||||
<p style={{ color: "var(--text-2)", marginTop: 6 }}>Sign in to continue.</p>
|
||||
<Field label="Email"><Input value="mira@acme.io"/></Field>
|
||||
<Field label="Password"><Input type="password" value="••••••••••"/></Field>
|
||||
<Button full>Sign in</Button>
|
||||
</AuthCenteredShell>
|
||||
);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
```
|
||||
|
||||
## Usage in a React codebase
|
||||
|
||||
Convert the three `.jsx` files from `Object.assign(window, …)` to named `export` statements. The components have no runtime dependencies beyond React.
|
||||
|
||||
```jsx
|
||||
// In your app
|
||||
import "vibn-ai-templates/tokens.css";
|
||||
import { Button, Card, Input, Field, Tabs } from "vibn-ai-templates/components";
|
||||
import { SidebarShell } from "vibn-ai-templates/shells";
|
||||
|
||||
// Pick a theme on your root
|
||||
<html className="theme-dark">…
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Inline styles read from CSS vars** — `style={{ background: "var(--surface)" }}`. This is intentional: it lets the entire library reskin with one class swap, and avoids a CSS-in-JS dependency.
|
||||
- **Components are presentational.** State (open/closed modals, active tabs, form values) lives in your app. Pass `active` + `onChange` to controlled components.
|
||||
- **No external icon dependency.** `icons.jsx` ships a curated set. Add to it freely.
|
||||
- **Avatars hash a color from the name** unless you pass `color="#…"`.
|
||||
- **Tables and tabs are uncontrolled-friendly** — pass `rows`/`items`, omit selection props if you don't need them.
|
||||
|
||||
## Showcase
|
||||
|
||||
`Vibn UI Showcase.html` at the project root renders every component across every theme. Use it as the visual reference and as a starting point for new screens.
|
||||
|
||||
## Versioning
|
||||
|
||||
This is a starter — fork it. There's no semver, no changelog. Edit `tokens.css` to match your brand, prune what you don't use, extend what you do.
|
||||
737
design-templates/VIBN (2)/vibn-ai-templates/components.jsx
Normal file
@@ -0,0 +1,737 @@
|
||||
// ============================================================
|
||||
// vibn-ai-templates/components.jsx
|
||||
// ------------------------------------------------------------
|
||||
// The core component set. Every visual property is wired to a
|
||||
// CSS variable from tokens.css — flipping `class="theme-glass"`
|
||||
// (or any other theme class) reskins the whole library.
|
||||
//
|
||||
// Components export to `window` for use in script-tag HTML
|
||||
// projects. In a real codebase, swap the bottom-of-file
|
||||
// assignment for `export { … }`.
|
||||
//
|
||||
// Components included:
|
||||
// Button, IconButton, Field, Input, Textarea, Select,
|
||||
// Checkbox, Radio, Switch, Card, Badge, Tag, Avatar,
|
||||
// AvatarStack, Tabs, Table, Modal, Banner, Divider,
|
||||
// FieldGroup, KBD, Spinner.
|
||||
// ============================================================
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
const cx = (...names) => names.filter(Boolean).join(" ");
|
||||
const noop = () => {};
|
||||
|
||||
// ─── Button ──────────────────────────────────────────────────
|
||||
// variant: primary (default), secondary, ghost, destructive
|
||||
// size: sm | md (default) | lg
|
||||
// leadingIcon / trailingIcon: <Icon name="…"/>
|
||||
// loading: disables and shows a spinner
|
||||
const Button = ({
|
||||
children, variant = "primary", size = "md", full = false,
|
||||
leadingIcon, trailingIcon, loading, disabled, onClick = noop, style, type = "button",
|
||||
...rest
|
||||
}) => {
|
||||
const sizing = {
|
||||
sm: { padY: 6, padX: 12, font: "var(--text-sm)", iconSize: 13 },
|
||||
md: { padY: 9, padX: 16, font: "var(--text-md)", iconSize: 15 },
|
||||
lg: { padY: 12, padX: 22, font: "var(--text-lg)", iconSize: 16 },
|
||||
}[size];
|
||||
|
||||
const variants = {
|
||||
primary: {
|
||||
background: "var(--button-bg)",
|
||||
color: "var(--button-fg)",
|
||||
border: "1px solid var(--button-border)",
|
||||
},
|
||||
secondary: {
|
||||
background: "var(--button-secondary-bg)",
|
||||
color: "var(--button-secondary-fg)",
|
||||
border: "1px solid var(--button-secondary-border)",
|
||||
},
|
||||
ghost: {
|
||||
background: "transparent",
|
||||
color: "var(--button-ghost-fg)",
|
||||
border: "1px solid transparent",
|
||||
},
|
||||
destructive: {
|
||||
background: "var(--danger)",
|
||||
color: "#ffffff",
|
||||
border: "1px solid var(--danger)",
|
||||
},
|
||||
}[variant];
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
...variants,
|
||||
padding: `${sizing.padY}px ${sizing.padX}px`,
|
||||
borderRadius: "var(--button-radius)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: sizing.font,
|
||||
fontWeight: "var(--weight-medium)",
|
||||
lineHeight: 1.2,
|
||||
cursor: disabled || loading ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
width: full ? "100%" : "auto",
|
||||
whiteSpace: "nowrap",
|
||||
transition: "background var(--duration) var(--ease), transform var(--duration-fast) var(--ease)",
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Spinner size={sizing.iconSize}/> : leadingIcon}
|
||||
<span>{children}</span>
|
||||
{!loading && trailingIcon}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── IconButton ──────────────────────────────────────────────
|
||||
const IconButton = ({ icon, name, size = "md", variant = "ghost", onClick = noop, label, style }) => {
|
||||
const dims = { sm: 28, md: 32, lg: 38 }[size];
|
||||
const iconSize = { sm: 14, md: 16, lg: 18 }[size];
|
||||
const variants = {
|
||||
ghost: { background: "transparent", color: "var(--text-2)", border: "1px solid transparent" },
|
||||
secondary: { background: "var(--button-secondary-bg)", color: "var(--button-secondary-fg)",
|
||||
border: "1px solid var(--button-secondary-border)" },
|
||||
}[variant];
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
...variants,
|
||||
width: dims, height: dims, borderRadius: "var(--radius-sm)",
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
cursor: "pointer", padding: 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{icon ?? <Icon name={name} size={iconSize} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Spinner ─────────────────────────────────────────────────
|
||||
const Spinner = ({ size = 14, stroke = 2 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.2" strokeWidth={stroke} />
|
||||
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round">
|
||||
<animateTransform attributeName="transform" type="rotate"
|
||||
from="0 12 12" to="360 12 12" dur="0.9s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─── Field (wraps a labelled input with hint / error) ────────
|
||||
const Field = ({ label, hint, error, optional, htmlFor, children, style }) => (
|
||||
<div style={{ marginBottom: "var(--space-4)", ...style }}>
|
||||
{label && (
|
||||
<label htmlFor={htmlFor} style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)",
|
||||
color: "var(--text)", marginBottom: 6,
|
||||
}}>
|
||||
<span>{label}</span>
|
||||
{optional && <span style={{ color: "var(--text-3)", fontWeight: 400 }}>optional</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{(hint || error) && (
|
||||
<div style={{
|
||||
fontSize: "var(--text-xs)", marginTop: 5,
|
||||
color: error ? "var(--danger)" : "var(--text-3)",
|
||||
}}>{error || hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Input ───────────────────────────────────────────────────
|
||||
// Bare input (use inside <Field>). leadingIcon / trailingIcon
|
||||
// add an inner ornament. invalid red-rings the border.
|
||||
const Input = ({
|
||||
value, placeholder, type = "text", leadingIcon, trailingIcon,
|
||||
invalid, disabled, autofocus, onChange = noop, id, style, ...rest
|
||||
}) => (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "10px 12px",
|
||||
borderRadius: "var(--field-radius)",
|
||||
background: "var(--field-bg)",
|
||||
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
|
||||
boxShadow: autofocus ? "var(--shadow-focus)" : "var(--shadow-sm)",
|
||||
fontSize: "var(--text-md)",
|
||||
color: "var(--text)",
|
||||
backdropFilter: "blur(var(--surface-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
transition: "border-color var(--duration), box-shadow var(--duration)",
|
||||
...style,
|
||||
}}>
|
||||
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value, e)}
|
||||
style={{
|
||||
flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent",
|
||||
fontFamily: "inherit", fontSize: "inherit", color: "inherit",
|
||||
padding: 0,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
{trailingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{trailingIcon}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Textarea ────────────────────────────────────────────────
|
||||
const Textarea = ({ value, placeholder, rows = 4, onChange = noop, invalid, id, style, ...rest }) => (
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
onChange={(e) => onChange(e.target.value, e)}
|
||||
style={{
|
||||
width: "100%", display: "block", padding: "10px 12px",
|
||||
borderRadius: "var(--field-radius)",
|
||||
background: "var(--field-bg)",
|
||||
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
|
||||
fontSize: "var(--text-md)", color: "var(--text)",
|
||||
fontFamily: "var(--font-sans)", resize: "vertical",
|
||||
outline: "none", boxShadow: "var(--shadow-sm)",
|
||||
backdropFilter: "blur(var(--surface-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
// ─── Select (presentation only — clicks the menu open visually) ─
|
||||
const Select = ({ value, placeholder, options = [], leadingIcon, style, ...rest }) => (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "10px 12px", borderRadius: "var(--field-radius)",
|
||||
background: "var(--field-bg)", border: "1px solid var(--field-border)",
|
||||
fontSize: "var(--text-md)", color: value ? "var(--text)" : "var(--text-3)",
|
||||
cursor: "pointer", boxShadow: "var(--shadow-sm)",
|
||||
backdropFilter: "blur(var(--surface-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
||||
...style,
|
||||
}} {...rest}>
|
||||
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
|
||||
<span style={{ flex: 1 }}>{value || placeholder}</span>
|
||||
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Checkbox ────────────────────────────────────────────────
|
||||
const Checkbox = ({ checked, indeterminate, disabled, label, hint, onChange = noop, style }) => (
|
||||
<label style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1, ...style,
|
||||
}}>
|
||||
<span
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? "mixed" : !!checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
style={{
|
||||
width: 16, height: 16, borderRadius: 4, marginTop: 1, flexShrink: 0,
|
||||
border: `1px solid ${checked || indeterminate ? "var(--accent)" : "var(--border-strong)"}`,
|
||||
background: checked || indeterminate ? "var(--accent)" : "var(--surface)",
|
||||
color: "var(--text-on-accent)",
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
transition: "background var(--duration), border-color var(--duration)",
|
||||
}}
|
||||
>
|
||||
{checked && !indeterminate && <Icon name="checkOnly" size={11} stroke={2.6}/>}
|
||||
{indeterminate && <div style={{ width: 8, height: 2, background: "currentColor", borderRadius: 1 }}/>}
|
||||
</span>
|
||||
{(label || hint) && (
|
||||
<span style={{ minWidth: 0 }}>
|
||||
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
|
||||
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
|
||||
// ─── Radio ───────────────────────────────────────────────────
|
||||
const Radio = ({ checked, disabled, label, hint, onChange = noop, style }) => (
|
||||
<label style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1, ...style,
|
||||
}}>
|
||||
<span
|
||||
role="radio"
|
||||
aria-checked={!!checked}
|
||||
onClick={() => !disabled && onChange(true)}
|
||||
style={{
|
||||
width: 16, height: 16, borderRadius: "50%", marginTop: 1, flexShrink: 0,
|
||||
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
|
||||
background: "var(--surface)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{checked && <span style={{
|
||||
position: "absolute", top: 3, left: 3, right: 3, bottom: 3,
|
||||
background: "var(--accent)", borderRadius: "50%",
|
||||
}}/>}
|
||||
</span>
|
||||
{(label || hint) && (
|
||||
<span style={{ minWidth: 0 }}>
|
||||
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
|
||||
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
|
||||
// ─── Switch ──────────────────────────────────────────────────
|
||||
const Switch = ({ checked, disabled, onChange = noop, label, hint, style }) => (
|
||||
<label style={{
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style,
|
||||
}}>
|
||||
<span
|
||||
role="switch"
|
||||
aria-checked={!!checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
style={{
|
||||
width: 34, height: 20, borderRadius: 999,
|
||||
background: checked ? "var(--accent)" : "var(--surface-alt)",
|
||||
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
|
||||
position: "relative", flexShrink: 0,
|
||||
transition: "background var(--duration), border-color var(--duration)",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: "absolute", top: 1, left: checked ? 15 : 1,
|
||||
width: 16, height: 16, borderRadius: "50%",
|
||||
background: checked ? "var(--text-on-accent)" : "var(--surface)",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
||||
transition: "left var(--duration) var(--ease)",
|
||||
}}/>
|
||||
</span>
|
||||
{(label || hint) && (
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
{label && <div style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</div>}
|
||||
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
|
||||
// ─── Card / Surface ──────────────────────────────────────────
|
||||
// Card paints a `surface` background with border + shadow.
|
||||
// Use `variant="raised"` for shadow-lg, "flat" for no shadow.
|
||||
const Card = ({ children, variant = "default", padding = 20, style, ...rest }) => {
|
||||
const shadows = {
|
||||
default: "var(--shadow-sm)",
|
||||
raised: "var(--shadow)",
|
||||
floating:"var(--shadow-lg)",
|
||||
flat: "none",
|
||||
};
|
||||
return (
|
||||
<div style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--card-radius)",
|
||||
padding,
|
||||
boxShadow: shadows[variant] || shadows.default,
|
||||
backdropFilter: "blur(var(--surface-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
||||
color: "var(--text)",
|
||||
...style,
|
||||
}} {...rest}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CardHeader = ({ title, subtitle, action, style }) => (
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "flex-start",
|
||||
marginBottom: "var(--space-4)", gap: 16, ...style,
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{title && <div style={{
|
||||
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
|
||||
color: "var(--text)", letterSpacing: "-0.01em",
|
||||
}}>{title}</div>}
|
||||
{subtitle && <div style={{
|
||||
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: 2,
|
||||
}}>{subtitle}</div>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Badge / Tag ─────────────────────────────────────────────
|
||||
// tone: neutral | accent | success | warn | danger | info
|
||||
const Badge = ({ children, tone = "neutral", dot, leadingIcon, style }) => {
|
||||
const palette = {
|
||||
neutral: { bg: "var(--surface-alt)", fg: "var(--text-2)", dotColor: "var(--text-3)" },
|
||||
accent: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
|
||||
success: { bg: "var(--success-soft)", fg: "var(--success)", dotColor: "var(--success)" },
|
||||
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", dotColor: "var(--warn)" },
|
||||
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", dotColor: "var(--danger)" },
|
||||
info: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
|
||||
}[tone] || {};
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
padding: "2px 8px", borderRadius: "var(--radius-pill)",
|
||||
background: palette.bg, color: palette.fg,
|
||||
fontSize: "var(--text-xs)", fontWeight: "var(--weight-medium)",
|
||||
whiteSpace: "nowrap", lineHeight: 1.4,
|
||||
...style,
|
||||
}}>
|
||||
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: palette.dotColor }}/>}
|
||||
{leadingIcon}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const Tag = Badge; // alias
|
||||
|
||||
// ─── Avatar ──────────────────────────────────────────────────
|
||||
const avatarPalette = ["#d4b8a8", "#e8a87c", "#c8e8a8", "#a8c8e8", "#c8a8e8", "#e8c8a8", "#a8e8c8", "#e8a8c8"];
|
||||
const hashName = (s = "") => {
|
||||
let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return avatarPalette[Math.abs(h) % avatarPalette.length];
|
||||
};
|
||||
const Avatar = ({ name = "?", src, size = 32, status, color, ring, style }) => {
|
||||
const initials = name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
||||
return (
|
||||
<span style={{
|
||||
position: "relative", display: "inline-flex", flexShrink: 0,
|
||||
width: size, height: size, borderRadius: "50%",
|
||||
background: color || hashName(name), color: "#3a2820",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
fontSize: Math.round(size * 0.4), fontWeight: 600,
|
||||
boxShadow: ring ? `0 0 0 2px var(--surface), 0 0 0 ${2 + ring}px var(--accent)` : "none",
|
||||
overflow: "hidden", ...style,
|
||||
}}>
|
||||
{src
|
||||
? <img src={src} alt={name} style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
|
||||
: initials}
|
||||
{status && <span style={{
|
||||
position: "absolute", bottom: 0, right: 0,
|
||||
width: Math.max(8, size * 0.28), height: Math.max(8, size * 0.28),
|
||||
borderRadius: "50%", border: "2px solid var(--surface)",
|
||||
background: status === "online" ? "var(--success)" :
|
||||
status === "busy" ? "var(--danger)" : "var(--text-3)",
|
||||
}}/>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarStack = ({ items = [], size = 28, max = 4 }) => {
|
||||
const shown = items.slice(0, max);
|
||||
const remaining = items.length - shown.length;
|
||||
return (
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
{shown.map((p, i) => (
|
||||
<Avatar key={i} name={p.name} src={p.src} color={p.color} size={size}
|
||||
style={{ marginLeft: i ? -size * 0.32 : 0, boxShadow: "0 0 0 2px var(--surface)" }}/>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<span style={{
|
||||
width: size, height: size, borderRadius: "50%",
|
||||
background: "var(--surface-alt)", color: "var(--text-2)",
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: Math.round(size * 0.4), fontWeight: 600,
|
||||
marginLeft: -size * 0.32, boxShadow: "0 0 0 2px var(--surface)",
|
||||
}}>+{remaining}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Tabs ────────────────────────────────────────────────────
|
||||
// Controlled: pass `active` (label of active tab) + `onChange`.
|
||||
const Tabs = ({ items = [], active, onChange = noop, style, variant = "underline" }) => {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", gap: 4, borderBottom: variant === "underline" ? "1px solid var(--border)" : "none",
|
||||
...style,
|
||||
}}>
|
||||
{items.map(t => {
|
||||
const isActive = t.label === active || t.id === active;
|
||||
if (variant === "pill") {
|
||||
return (
|
||||
<button key={t.id || t.label}
|
||||
onClick={() => onChange(t.id || t.label)}
|
||||
style={{
|
||||
padding: "6px 12px", borderRadius: "var(--radius-pill)",
|
||||
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)",
|
||||
background: isActive ? "var(--accent)" : "transparent",
|
||||
color: isActive ? "var(--text-on-accent)" : "var(--text-2)",
|
||||
border: "none", cursor: "pointer", fontWeight: 500,
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
}}>
|
||||
{t.icon}{t.label}
|
||||
{t.count != null && (
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 6px", borderRadius: 999,
|
||||
background: isActive ? "rgba(255,255,255,0.18)" : "var(--surface-alt)",
|
||||
color: "inherit",
|
||||
}}>{t.count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button key={t.id || t.label}
|
||||
onClick={() => onChange(t.id || t.label)}
|
||||
style={{
|
||||
padding: "10px 4px", margin: "0 12px 0 0",
|
||||
fontFamily: "var(--font-sans)", fontSize: "var(--text-md)",
|
||||
fontWeight: "var(--weight-medium)",
|
||||
background: "transparent", border: "none", cursor: "pointer",
|
||||
color: isActive ? "var(--text)" : "var(--text-2)",
|
||||
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
position: "relative", top: 1,
|
||||
display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
|
||||
}}>
|
||||
{t.icon}{t.label}
|
||||
{t.count != null && (
|
||||
<span style={{
|
||||
fontSize: 10, padding: "1px 6px", borderRadius: 999,
|
||||
background: isActive ? "var(--accent-soft)" : "var(--surface-alt)",
|
||||
color: isActive ? "var(--accent)" : "var(--text-3)",
|
||||
}}>{t.count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Table ───────────────────────────────────────────────────
|
||||
// columns: [{ key, label, width?, align?, render? }]
|
||||
// rows: [{ id, [key]: value, … }]
|
||||
const Table = ({ columns = [], rows = [], selectable, selected = [], onSelectionChange = noop, density = "comfortable" }) => {
|
||||
const padY = density === "compact" ? 8 : 12;
|
||||
const allChecked = rows.length > 0 && selected.length === rows.length;
|
||||
const someChecked = selected.length > 0 && !allChecked;
|
||||
const toggleAll = () => onSelectionChange(allChecked ? [] : rows.map(r => r.id));
|
||||
const toggleOne = (id) => onSelectionChange(
|
||||
selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]
|
||||
);
|
||||
|
||||
const headerCell = {
|
||||
padding: `10px 12px`, fontSize: "var(--text-xs)",
|
||||
color: "var(--text-3)", fontWeight: "var(--weight-medium)",
|
||||
textTransform: "uppercase", letterSpacing: "0.04em", textAlign: "left",
|
||||
borderBottom: "1px solid var(--border)", background: "var(--surface)",
|
||||
};
|
||||
const bodyCell = {
|
||||
padding: `${padY}px 12px`, fontSize: "var(--text-md)",
|
||||
color: "var(--text)", borderBottom: "1px solid var(--divider)", verticalAlign: "middle",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: "var(--surface)", border: "1px solid var(--border)",
|
||||
borderRadius: "var(--card-radius)", overflow: "hidden",
|
||||
backdropFilter: "blur(var(--surface-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
||||
}}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "var(--font-sans)" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{selectable && (
|
||||
<th style={{ ...headerCell, width: 36, paddingRight: 0 }}>
|
||||
<Checkbox checked={allChecked} indeterminate={someChecked} onChange={toggleAll}/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map(c => (
|
||||
<th key={c.key} style={{ ...headerCell, width: c.width, textAlign: c.align || "left" }}>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={r.id ?? i}>
|
||||
{selectable && (
|
||||
<td style={{ ...bodyCell, paddingRight: 0 }}>
|
||||
<Checkbox checked={selected.includes(r.id)} onChange={() => toggleOne(r.id)}/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map(c => (
|
||||
<td key={c.key} style={{ ...bodyCell, textAlign: c.align || "left" }}>
|
||||
{c.render ? c.render(r) : r[c.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Modal (presentational — wrap your own state) ────────────
|
||||
const Modal = ({ open, onClose = noop, title, description, footer, children, width = 480 }) => {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 100,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width, maxWidth: "100%", maxHeight: "85vh", overflow: "auto",
|
||||
background: "var(--surface)", color: "var(--text)",
|
||||
border: "1px solid var(--border)", borderRadius: "var(--modal-radius)",
|
||||
boxShadow: "var(--shadow-modal)",
|
||||
backdropFilter: "blur(var(--surface-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: "var(--space-6)", display: "flex",
|
||||
justifyContent: "space-between", alignItems: "flex-start", gap: 16,
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{title && <h2 style={{
|
||||
margin: 0, fontSize: "var(--text-xl)", fontWeight: "var(--weight-semibold)",
|
||||
letterSpacing: "-0.01em", fontFamily: "var(--font-display)",
|
||||
}}>{title}</h2>}
|
||||
{description && <p style={{
|
||||
margin: "6px 0 0", fontSize: "var(--text-md)", color: "var(--text-2)",
|
||||
}}>{description}</p>}
|
||||
</div>
|
||||
<IconButton name="x" size="sm" onClick={onClose} label="Close"/>
|
||||
</div>
|
||||
{children && <div style={{ padding: "0 var(--space-6) var(--space-6)" }}>{children}</div>}
|
||||
{footer && (
|
||||
<div style={{
|
||||
padding: "var(--space-4) var(--space-6)",
|
||||
borderTop: "1px solid var(--divider)",
|
||||
display: "flex", justifyContent: "flex-end", gap: 8,
|
||||
background: "var(--surface-2)",
|
||||
}}>{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Banner / Alert ──────────────────────────────────────────
|
||||
const Banner = ({ tone = "info", title, children, action, onDismiss, icon, style }) => {
|
||||
const palette = {
|
||||
info: { bg: "var(--accent-soft)", fg: "var(--accent)", iconName: "info" },
|
||||
success: { bg: "var(--success-soft)", fg: "var(--success)", iconName: "checkOnly" },
|
||||
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", iconName: "alert" },
|
||||
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", iconName: "alert" },
|
||||
}[tone];
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", gap: 12, padding: "12px 16px",
|
||||
borderRadius: "var(--radius)",
|
||||
background: palette.bg, color: "var(--text)",
|
||||
border: `1px solid ${palette.fg}33`,
|
||||
alignItems: "flex-start",
|
||||
...style,
|
||||
}}>
|
||||
<span style={{ color: palette.fg, display: "flex", marginTop: 1 }}>
|
||||
{icon ?? <Icon name={palette.iconName} size={16} stroke={2}/>}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && <div style={{
|
||||
fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)",
|
||||
color: "var(--text)",
|
||||
}}>{title}</div>}
|
||||
{children && <div style={{
|
||||
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: title ? 2 : 0,
|
||||
}}>{children}</div>}
|
||||
</div>
|
||||
{action}
|
||||
{onDismiss && <IconButton name="x" size="sm" onClick={onDismiss} label="Dismiss"/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Divider ─────────────────────────────────────────────────
|
||||
const Divider = ({ label, vertical, style }) => {
|
||||
if (vertical) return <span style={{ width: 1, alignSelf: "stretch", background: "var(--border)", ...style }}/>;
|
||||
if (!label) return <hr style={{ border: "none", borderTop: "1px solid var(--border)", margin: "var(--space-4) 0", ...style }}/>;
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
fontSize: "var(--text-xs)", color: "var(--text-3)",
|
||||
letterSpacing: "0.08em", textTransform: "uppercase",
|
||||
margin: "var(--space-4) 0", ...style,
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
|
||||
<span>{label}</span>
|
||||
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── FieldGroup — horizontal segmented control ───────────────
|
||||
const FieldGroup = ({ options = [], value, onChange = noop, style }) => (
|
||||
<div style={{
|
||||
display: "inline-flex", padding: 3, gap: 2,
|
||||
background: "var(--surface-alt)", border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)", ...style,
|
||||
}}>
|
||||
{options.map(o => {
|
||||
const v = typeof o === "string" ? o : o.value;
|
||||
const label = typeof o === "string" ? o : o.label;
|
||||
const sel = v === value;
|
||||
return (
|
||||
<button key={v} onClick={() => onChange(v)} style={{
|
||||
padding: "5px 12px", borderRadius: "calc(var(--radius) - 3px)",
|
||||
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)", whiteSpace: "nowrap",
|
||||
background: sel ? "var(--surface)" : "transparent",
|
||||
color: sel ? "var(--text)" : "var(--text-2)",
|
||||
border: "none", cursor: "pointer",
|
||||
boxShadow: sel ? "var(--shadow-sm)" : "none",
|
||||
fontWeight: sel ? 500 : 400,
|
||||
}}>{label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── KBD ─────────────────────────────────────────────────────
|
||||
const KBD = ({ children, style }) => (
|
||||
<kbd style={{
|
||||
fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
|
||||
padding: "1px 6px", borderRadius: 4,
|
||||
background: "var(--surface-alt)", color: "var(--text-2)",
|
||||
border: "1px solid var(--border)",
|
||||
...style,
|
||||
}}>{children}</kbd>
|
||||
);
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────
|
||||
Object.assign(window, {
|
||||
Button, IconButton, Spinner,
|
||||
Field, Input, Textarea, Select, FieldGroup,
|
||||
Checkbox, Radio, Switch,
|
||||
Card, CardHeader, Divider,
|
||||
Badge, Tag, Avatar, AvatarStack,
|
||||
Tabs, Table, Modal, Banner, KBD,
|
||||
});
|
||||
89
design-templates/VIBN (2)/vibn-ai-templates/icons.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// ============================================================
|
||||
// vibn-ai-templates/icons.jsx
|
||||
// ------------------------------------------------------------
|
||||
// A tiny Tabler-style stroke-icon helper + a curated set of
|
||||
// paths used by the components. All inherit `currentColor` so
|
||||
// they re-tint to whatever the parent's CSS color is.
|
||||
//
|
||||
// Usage:
|
||||
// <Icon name="search" />
|
||||
// <Icon path={icons.bell} size={20} stroke={2} />
|
||||
// ============================================================
|
||||
|
||||
const icons = {
|
||||
// Navigation / surface
|
||||
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
|
||||
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
|
||||
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
|
||||
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
|
||||
settings:<><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8L4.2 7a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></>,
|
||||
|
||||
// Objects
|
||||
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
|
||||
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
|
||||
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
|
||||
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
|
||||
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
|
||||
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/></>,
|
||||
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
|
||||
|
||||
// Actions
|
||||
plus: <path d="M12 5v14M5 12h14"/>,
|
||||
x: <path d="M6 6l12 12M18 6L6 18"/>,
|
||||
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
|
||||
chevDown:<path d="m6 9 6 6 6-6"/>,
|
||||
chevUp: <path d="m6 15 6-6 6 6"/>,
|
||||
chevLeft:<path d="m15 6-6 6 6 6"/>,
|
||||
chevRight:<path d="m9 6 6 6-6 6"/>,
|
||||
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
|
||||
arrowUp: <path d="M12 19V5M5 12l7-7 7 7"/>,
|
||||
arrowDown:<path d="M12 5v14M5 12l7 7 7-7"/>,
|
||||
|
||||
// Status
|
||||
checkOnly: <path d="M5 12l5 5L20 7"/>,
|
||||
alert: <><path d="M12 9v4M12 17v.01"/><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.7 3.86a2 2 0 0 0-3.4 0z"/></>,
|
||||
info: <><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></>,
|
||||
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
|
||||
eyeOff: <><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 7 11 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 7 11 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="M2 2l20 20"/></>,
|
||||
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
|
||||
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
|
||||
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
|
||||
shield: <path d="M12 2 4 5v7c0 5 3.5 9 8 10 4.5-1 8-5 8-10V5z"/>,
|
||||
|
||||
// Misc
|
||||
briefcase: <><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/></>,
|
||||
link: <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></>,
|
||||
copy: <><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>,
|
||||
};
|
||||
|
||||
const Icon = ({ name, path, size = 16, stroke = 1.6, style, className }) => (
|
||||
<svg
|
||||
width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth={stroke}
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0, ...style }}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{path ?? icons[name]}
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Tiny brand mark — a gradient triangle, the same as we've been
|
||||
// using everywhere. Exported here so consumers don't redraw it.
|
||||
const VibnMark = ({ size = 22 }) => {
|
||||
const id = `vmk_${size}`;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#6e6cff"/>
|
||||
<stop offset="100%" stopColor="#b15bff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#${id})`}/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { Icon, icons, VibnMark });
|
||||
399
design-templates/VIBN (2)/vibn-ai-templates/shells.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
// ============================================================
|
||||
// vibn-ai-templates/shells.jsx
|
||||
// ------------------------------------------------------------
|
||||
// Layout shells — both in-product navs (Sidebar / Rail /
|
||||
// Topbar) and auth scaffolds (CenteredCard / SplitHero / Glass).
|
||||
//
|
||||
// These are containers. Wrap your page in any shell and the
|
||||
// shell handles brand, search, nav, footer. Compose with the
|
||||
// components from components.jsx.
|
||||
// ============================================================
|
||||
|
||||
// ── SidebarShell ─────────────────────────────────────────────
|
||||
// Props:
|
||||
// brand: { name, mark? }
|
||||
// sections: [{ title?, items: [{ id, label, icon, count, active }] }]
|
||||
// user: { name, email, color? }
|
||||
// children: main pane
|
||||
const SidebarShell = ({ brand = { name: "Vibn" }, sections = [], user, search = "Search…", children, width = 248 }) => {
|
||||
return (
|
||||
<div className="vibn-app" style={{
|
||||
width: "100%", height: "100%",
|
||||
display: "grid", gridTemplateColumns: `${width}px 1fr`,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<aside style={{
|
||||
background: "var(--surface-alt)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
display: "flex", flexDirection: "column",
|
||||
}}>
|
||||
{/* Brand row */}
|
||||
<div style={{
|
||||
padding: "12px 14px", display: "flex", alignItems: "center", gap: 10,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}>
|
||||
{brand.mark || <VibnMark size={22}/>}
|
||||
<div style={{ flex: 1, fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)" }}>
|
||||
{brand.name}
|
||||
</div>
|
||||
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }}/>
|
||||
</div>
|
||||
{/* Search */}
|
||||
<div style={{ padding: 12 }}>
|
||||
<Input
|
||||
placeholder={search}
|
||||
leadingIcon={<Icon name="search" size={14}/>}
|
||||
trailingIcon={<KBD>⌘K</KBD>}
|
||||
style={{ padding: "6px 10px" }}
|
||||
/>
|
||||
</div>
|
||||
{/* Nav */}
|
||||
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
|
||||
{sections.map((s, i) => (
|
||||
<div key={s.title || `s${i}`}>
|
||||
{s.title && <div style={{
|
||||
fontSize: "var(--text-xs)", color: "var(--text-3)",
|
||||
padding: "14px 10px 6px", textTransform: "uppercase",
|
||||
letterSpacing: "0.04em", fontWeight: 500,
|
||||
}}>{s.title}</div>}
|
||||
{s.items.map(it => (
|
||||
<div key={it.id || it.label} style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "6px 10px", borderRadius: "var(--radius-sm)",
|
||||
fontSize: "var(--text-md)", cursor: "pointer", marginBottom: 1,
|
||||
color: it.active ? "var(--text)" : "var(--text-2)",
|
||||
fontWeight: it.active ? 500 : 400,
|
||||
background: it.active ? "var(--surface)" : "transparent",
|
||||
boxShadow: it.active ? "var(--shadow-sm)" : "none",
|
||||
}}>
|
||||
<span style={{ color: it.active ? "var(--accent)" : "var(--text-3)", display: "flex" }}>
|
||||
{typeof it.icon === "string"
|
||||
? <Icon name={it.icon} size={15}/>
|
||||
: it.icon}
|
||||
</span>
|
||||
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.label}</span>
|
||||
{it.count != null && <span style={{
|
||||
fontSize: "var(--text-xs)", color: "var(--text-3)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}>{it.count}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
{/* User */}
|
||||
{user && (
|
||||
<div style={{
|
||||
padding: 12, borderTop: "1px solid var(--border)",
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
}}>
|
||||
<Avatar name={user.name} color={user.color} size={26}/>
|
||||
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.2 }}>
|
||||
<div style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>{user.name}</div>
|
||||
{user.email && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)" }}>{user.email}</div>}
|
||||
</div>
|
||||
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }}/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main style={{ display: "flex", flexDirection: "column", overflow: "hidden", background: "var(--bg)" }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── TopbarShell ──────────────────────────────────────────────
|
||||
// Dark top with breadcrumb + ⌘K + avatar; tabs strip below.
|
||||
const TopbarShell = ({ brand = { name: "Vibn" }, breadcrumb, tabs = [], activeTab,
|
||||
onTabChange = () => {}, user, children, search = "Find or jump to anything…" }) => {
|
||||
return (
|
||||
<div className="vibn-app" style={{
|
||||
width: "100%", height: "100%", display: "flex", flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<header style={{
|
||||
background: "var(--surface-alt)", color: "var(--text)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 14, padding: "12px 24px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: "var(--weight-semibold)", fontSize: "var(--text-lg)" }}>
|
||||
{brand.mark || <VibnMark size={20}/>}
|
||||
{brand.name}
|
||||
</div>
|
||||
{breadcrumb && (
|
||||
<>
|
||||
<span style={{ color: "var(--text-3)" }}>/</span>
|
||||
{breadcrumb.map((b, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span style={{ color: "var(--text-3)" }}>/</span>}
|
||||
<span style={{ fontSize: "var(--text-md)", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{b.avatar && <Avatar name={b.avatar} size={18}/>}
|
||||
<span>{b.label}</span>
|
||||
{b.badge && <Badge tone="neutral">{b.badge}</Badge>}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div style={{ flex: 1 }}/>
|
||||
<div style={{ minWidth: 280 }}>
|
||||
<Input placeholder={search}
|
||||
leadingIcon={<Icon name="search" size={13}/>}
|
||||
trailingIcon={<KBD>⌘K</KBD>}
|
||||
style={{ padding: "6px 12px" }} />
|
||||
</div>
|
||||
<Button variant="secondary" size="sm">Feedback</Button>
|
||||
<IconButton name="bell" size="md"/>
|
||||
{user && <Avatar name={user.name} color={user.color} size={28}/>}
|
||||
</div>
|
||||
{tabs.length > 0 && (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<Tabs items={tabs} active={activeTab} onChange={onTabChange}/>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main style={{ flex: 1, overflow: "hidden", background: "var(--bg)" }}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── RailShell ────────────────────────────────────────────────
|
||||
// Icon rail + secondary panel + content.
|
||||
const RailShell = ({ brand, items = [], activeRail, onRailChange = () => {},
|
||||
secondary, secondaryTitle, user, children }) => {
|
||||
return (
|
||||
<div className="vibn-app" style={{
|
||||
width: "100%", height: "100%",
|
||||
display: "grid", gridTemplateColumns: "64px 240px 1fr", overflow: "hidden",
|
||||
}}>
|
||||
{/* Rail */}
|
||||
<div style={{
|
||||
background: "var(--surface-alt)", borderRight: "1px solid var(--border)",
|
||||
padding: "10px 0", display: "flex", flexDirection: "column",
|
||||
alignItems: "center", gap: 4,
|
||||
}}>
|
||||
<div style={{ padding: "0 10px 6px" }}>
|
||||
{brand?.mark || <VibnMark size={22}/>}
|
||||
</div>
|
||||
<Divider />
|
||||
{items.map(it => {
|
||||
const sel = (it.id || it.label) === activeRail;
|
||||
return (
|
||||
<button key={it.id || it.label}
|
||||
onClick={() => onRailChange(it.id || it.label)}
|
||||
aria-label={it.label}
|
||||
style={{
|
||||
width: 40, height: 40, borderRadius: "var(--radius)",
|
||||
background: sel ? "var(--accent)" : "transparent",
|
||||
color: sel ? "var(--text-on-accent)" : "var(--text-2)",
|
||||
border: "none", cursor: "pointer", position: "relative",
|
||||
}}>
|
||||
{typeof it.icon === "string" ? <Icon name={it.icon} size={18} stroke={2}/> : it.icon}
|
||||
{it.badge && <span style={{
|
||||
position: "absolute", top: 2, right: 2, minWidth: 16, height: 16,
|
||||
padding: "0 4px", borderRadius: 8,
|
||||
background: "var(--danger)", color: "#fff",
|
||||
fontSize: 10, fontWeight: 600,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
border: "2px solid var(--surface-alt)",
|
||||
}}>{it.badge}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div style={{ flex: 1 }}/>
|
||||
{user && <Avatar name={user.name} color={user.color} size={30} ring={1}/>}
|
||||
</div>
|
||||
|
||||
{/* Secondary */}
|
||||
<div style={{
|
||||
background: "var(--surface-2)", borderRight: "1px solid var(--border)",
|
||||
display: "flex", flexDirection: "column", overflow: "hidden",
|
||||
}}>
|
||||
{secondaryTitle && (
|
||||
<div style={{
|
||||
padding: "16px 14px 10px", borderBottom: "1px solid var(--border)",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
|
||||
marginBottom: 10,
|
||||
}}>{secondaryTitle}</div>
|
||||
<Input
|
||||
placeholder="Jump to…"
|
||||
leadingIcon={<Icon name="search" size={13}/>}
|
||||
trailingIcon={<KBD>⌘K</KBD>}
|
||||
style={{ padding: "6px 10px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: 10, flex: 1, overflowY: "auto" }}>{secondary}</div>
|
||||
</div>
|
||||
|
||||
<main style={{ overflow: "hidden", background: "var(--bg)" }}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── AuthCenteredShell ────────────────────────────────────────
|
||||
// A single centered Card on a soft background, with brand top
|
||||
// and small footer links. Good for sign-in / sign-up.
|
||||
const AuthCenteredShell = ({ brand = { name: "Vibn" }, footerLinks = ["Privacy", "Terms", "Security"], cardWidth = 420, children }) => (
|
||||
<div className="vibn-app" style={{
|
||||
width: "100%", height: "100%", display: "grid",
|
||||
gridTemplateRows: "auto 1fr auto", overflow: "hidden",
|
||||
}}>
|
||||
<header style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "20px 28px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
|
||||
{brand.mark || <VibnMark size={20}/>}
|
||||
{brand.name}
|
||||
</div>
|
||||
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)", display: "flex", gap: 18 }}>
|
||||
<span>Status</span><span>Docs</span><span style={{ color: "var(--text)", fontWeight: 500 }}>Sign in ↗</span>
|
||||
</div>
|
||||
</header>
|
||||
<main style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
|
||||
<Card variant="raised" padding={32} style={{ width: cardWidth }}>{children}</Card>
|
||||
</main>
|
||||
<footer style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "16px 28px", fontSize: "var(--text-xs)", color: "var(--text-3)",
|
||||
}}>
|
||||
<span>© 2026 {brand.name}</span>
|
||||
<div style={{ display: "flex", gap: 16 }}>{footerLinks.map(l => <span key={l}>{l}</span>)}</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── AuthSplitShell ───────────────────────────────────────────
|
||||
// Left storytelling panel, right form. Big SaaS / Vercel feel.
|
||||
const AuthSplitShell = ({ brand = { name: "Vibn" }, hero = {}, children }) => (
|
||||
<div className="vibn-app" style={{
|
||||
width: "100%", height: "100%", display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr", overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
padding: "32px 44px", borderRight: "1px solid var(--border)",
|
||||
display: "flex", flexDirection: "column",
|
||||
background: "var(--surface-alt)", position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
{/* Decorative wash, picks up theme accent */}
|
||||
<div style={{
|
||||
position: "absolute", top: -140, left: -120, width: 540, height: 540,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, color-mix(in srgb, var(--accent-2) 40%, transparent), transparent 60%)",
|
||||
filter: "blur(60px)", pointerEvents: "none",
|
||||
}}/>
|
||||
<div style={{
|
||||
position: "absolute", bottom: -180, right: -120, width: 480, height: 480,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent), transparent 60%)",
|
||||
filter: "blur(60px)", pointerEvents: "none",
|
||||
}}/>
|
||||
|
||||
<div style={{ position: "relative", display: "flex", alignItems: "center", gap: 10, fontWeight: 600 }}>
|
||||
{brand.mark || <VibnMark size={22}/>}
|
||||
{brand.name}
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", marginTop: "auto" }}>
|
||||
{hero.badge && (
|
||||
<Badge tone="accent" style={{ marginBottom: 22 }}>{hero.badge}</Badge>
|
||||
)}
|
||||
{hero.headline && <h2 style={{
|
||||
fontFamily: "var(--font-display)", fontSize: "var(--text-3xl)",
|
||||
lineHeight: 1.05, margin: 0, letterSpacing: "-0.02em",
|
||||
fontWeight: 500, textWrap: "balance", maxWidth: 360,
|
||||
}}>{hero.headline}</h2>}
|
||||
{hero.sub && <p style={{
|
||||
fontSize: "var(--text-md)", color: "var(--text-2)",
|
||||
marginTop: 14, lineHeight: 1.5, maxWidth: 340,
|
||||
}}>{hero.sub}</p>}
|
||||
|
||||
{hero.quote && (
|
||||
<div style={{
|
||||
position: "relative", marginTop: 28, padding: 18,
|
||||
borderRadius: "var(--card-radius)", background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}>
|
||||
<p style={{ fontSize: "var(--text-md)", margin: 0, lineHeight: 1.5, color: "var(--text)" }}>
|
||||
"{hero.quote.body}"
|
||||
</p>
|
||||
<div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Avatar name={hero.quote.author} size={26}/>
|
||||
<div style={{ fontSize: "var(--text-xs)" }}>
|
||||
<div style={{ fontWeight: 500 }}>{hero.quote.author}</div>
|
||||
<div style={{ color: "var(--text-3)" }}>{hero.quote.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", padding: "32px 56px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", fontSize: "var(--text-sm)", color: "var(--text-2)" }}>
|
||||
Need help? <span style={{ color: "var(--text)", fontWeight: 500, marginLeft: 4 }}>support</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 380 }}>{children}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 18, fontSize: "var(--text-xs)", color: "var(--text-3)", justifyContent: "flex-end" }}>
|
||||
<span>Privacy</span><span>Terms</span><span>Security</span><span>v4.2.1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── AuthGlassShell ───────────────────────────────────────────
|
||||
// Aurora background + frosted card. Marketing-leaning.
|
||||
const AuthGlassShell = ({ brand = { name: "Vibn" }, eyebrow, cardWidth = 460, children }) => (
|
||||
<div className="vibn-app" style={{
|
||||
width: "100%", height: "100%", position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
{/* Top bar (a thin frosted pill — works in any theme thanks to surface vars) */}
|
||||
<header style={{
|
||||
position: "absolute", top: 22, left: "50%", transform: "translateX(-50%)",
|
||||
zIndex: 10, width: "max-content",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
padding: "8px 8px 8px 18px", borderRadius: "var(--radius-pill)",
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
backdropFilter: "blur(var(--surface-blur))",
|
||||
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginRight: 16, fontWeight: 600 }}>
|
||||
{brand.mark || <VibnMark size={18}/>}
|
||||
{brand.name}
|
||||
</div>
|
||||
{["Product", "Pricing", "Docs"].map(l => (
|
||||
<Button key={l} variant="ghost" size="sm">{l}</Button>
|
||||
))}
|
||||
<Divider vertical style={{ margin: "0 6px" }}/>
|
||||
<Button variant="ghost" size="sm">Sign in</Button>
|
||||
<Button size="sm">Get started →</Button>
|
||||
</header>
|
||||
|
||||
<main style={{
|
||||
position: "relative", height: "100%",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
|
||||
}}>
|
||||
<Card variant="floating" padding={36} style={{ width: cardWidth, borderRadius: "var(--radius-xl)" }}>
|
||||
{eyebrow && <Badge tone="accent" style={{ marginBottom: 16 }}>{eyebrow}</Badge>}
|
||||
{children}
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────
|
||||
Object.assign(window, {
|
||||
SidebarShell, TopbarShell, RailShell,
|
||||
AuthCenteredShell, AuthSplitShell, AuthGlassShell,
|
||||
});
|
||||
325
design-templates/VIBN (2)/vibn-ai-templates/tokens.css
Normal file
@@ -0,0 +1,325 @@
|
||||
/* ============================================================
|
||||
vibn-ai-templates · tokens.css
|
||||
------------------------------------------------------------
|
||||
The whole library is themed through CSS custom properties.
|
||||
The :root block holds the DEFAULT theme (minimal). Each
|
||||
.theme-* class below overrides a subset to flip aesthetics.
|
||||
------------------------------------------------------------
|
||||
To use:
|
||||
<html class="theme-glass"> → glass theme app-wide
|
||||
<div class="theme-editorial">…</div> → scope to one block
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* ── Typography ─────────────────────────────────────────── */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-display: var(--font-sans); /* themes may override */
|
||||
--font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace;
|
||||
|
||||
--text-xs: 11px;
|
||||
--text-sm: 12px;
|
||||
--text-md: 13px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 28px;
|
||||
--text-3xl: 38px;
|
||||
|
||||
--weight-regular: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--weight-bold: 700;
|
||||
|
||||
/* ── Spacing (4 px base) ────────────────────────────────── */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
|
||||
/* ── Radii ──────────────────────────────────────────────── */
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 5px;
|
||||
--radius: 8px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 22px;
|
||||
--radius-pill: 999px;
|
||||
--button-radius: var(--radius);
|
||||
--field-radius: 7px;
|
||||
--card-radius: 12px;
|
||||
--modal-radius: 16px;
|
||||
|
||||
/* ── Colors · MINIMAL (default light theme) ────────────── */
|
||||
--bg: #f5f5f2;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fafaf8;
|
||||
--surface-alt: #f1f0eb; /* sidebar / muted regions */
|
||||
--border: #e8e8e3;
|
||||
--border-strong:#d8d8d2;
|
||||
--divider: #ededea;
|
||||
|
||||
--text: #111111;
|
||||
--text-2: #5a5a5e;
|
||||
--text-3: #8a8a90;
|
||||
--text-on-accent: #ffffff;
|
||||
|
||||
--accent: #5e5cff;
|
||||
--accent-2: #b15bff;
|
||||
--accent-soft: #eeedff;
|
||||
--accent-ring: rgba(94,92,255,0.22);
|
||||
|
||||
--success: #16a34a;
|
||||
--success-soft: #dcfce7;
|
||||
--warn: #d97706;
|
||||
--warn-soft: #fef3c7;
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fee2e2;
|
||||
|
||||
/* ── Surfaces & effects ────────────────────────────────── */
|
||||
--surface-blur: 0px; /* glass theme overrides */
|
||||
--backdrop: transparent; /* glass theme overrides */
|
||||
--grain: none; /* maximalist themes can use */
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow: 0 4px 12px -4px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-lg: 0 24px 48px -20px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.05);
|
||||
--shadow-modal: 0 40px 80px -20px rgba(0,0,0,0.28), 0 2px 8px rgba(0,0,0,0.08);
|
||||
--shadow-focus: 0 0 0 3px var(--accent-ring);
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────── */
|
||||
--button-bg: #111111;
|
||||
--button-fg: #ffffff;
|
||||
--button-border: #111111;
|
||||
--button-hover: #2a2a2a;
|
||||
--button-press: #000000;
|
||||
|
||||
--button-secondary-bg: #ffffff;
|
||||
--button-secondary-fg: #111111;
|
||||
--button-secondary-border: var(--border);
|
||||
--button-secondary-hover: #f6f5f0;
|
||||
|
||||
--button-ghost-fg: var(--text);
|
||||
--button-ghost-hover: #00000008;
|
||||
|
||||
/* ── Inputs ────────────────────────────────────────────── */
|
||||
--field-bg: #ffffff;
|
||||
--field-border: var(--border);
|
||||
--field-text: var(--text);
|
||||
--field-placeholder: var(--text-3);
|
||||
--field-focus-ring: var(--shadow-focus);
|
||||
|
||||
/* ── Animation ─────────────────────────────────────────── */
|
||||
--duration-fast: 120ms;
|
||||
--duration: 180ms;
|
||||
--duration-slow: 260ms;
|
||||
--ease: cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THEME: minimal (default, same as :root)
|
||||
The class exists so consumers can name-toggle.
|
||||
============================================================ */
|
||||
.theme-minimal {}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
THEME: dark — Vercel / Stripe / Linear web school
|
||||
============================================================ */
|
||||
.theme-dark {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #101015;
|
||||
--surface-2: #16161d;
|
||||
--surface-alt: #0a0a0d;
|
||||
--border: #1f1f25;
|
||||
--border-strong:#2a2a32;
|
||||
--divider: #1a1a20;
|
||||
|
||||
--text: #fafafa;
|
||||
--text-2: #a8a8b0;
|
||||
--text-3: #6a6a72;
|
||||
--text-on-accent: #0a0a0a;
|
||||
|
||||
--accent: #ffffff;
|
||||
--accent-2: #b15bff;
|
||||
--accent-soft: rgba(255,255,255,0.08);
|
||||
--accent-ring: rgba(255,255,255,0.24);
|
||||
|
||||
--success: #4ade80;
|
||||
--success-soft: rgba(74,222,128,0.14);
|
||||
--warn: #f59e0b;
|
||||
--warn-soft: rgba(245,158,11,0.14);
|
||||
--danger: #ff4d5e;
|
||||
--danger-soft: rgba(255,77,94,0.16);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.5);
|
||||
--shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
--shadow-lg: 0 24px 60px -20px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
|
||||
--shadow-modal: 0 40px 100px -20px rgba(0,0,0,0.8);
|
||||
|
||||
--button-bg: #ffffff;
|
||||
--button-fg: #0a0a0a;
|
||||
--button-border: #ffffff;
|
||||
--button-hover: #e8e8e8;
|
||||
--button-press: #d4d4d4;
|
||||
|
||||
--button-secondary-bg: #16161d;
|
||||
--button-secondary-fg: #fafafa;
|
||||
--button-secondary-border: var(--border);
|
||||
--button-secondary-hover: #1f1f28;
|
||||
|
||||
--button-ghost-fg: var(--text);
|
||||
--button-ghost-hover: rgba(255,255,255,0.05);
|
||||
|
||||
--field-bg: #16161d;
|
||||
--field-border: var(--border);
|
||||
--field-placeholder: var(--text-3);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
THEME: glass — vibrant aurora bg + frosted surfaces
|
||||
============================================================ */
|
||||
.theme-glass {
|
||||
--bg: #08081a;
|
||||
--surface: rgba(255,255,255,0.06);
|
||||
--surface-2: rgba(255,255,255,0.10);
|
||||
--surface-alt: rgba(255,255,255,0.04);
|
||||
--border: rgba(255,255,255,0.14);
|
||||
--border-strong:rgba(255,255,255,0.22);
|
||||
--divider: rgba(255,255,255,0.08);
|
||||
|
||||
--text: #ffffff;
|
||||
--text-2: rgba(255,255,255,0.70);
|
||||
--text-3: rgba(255,255,255,0.50);
|
||||
--text-on-accent: #08081a;
|
||||
|
||||
--accent: #ffffff;
|
||||
--accent-2: #b15bff;
|
||||
--accent-soft: rgba(255,255,255,0.12);
|
||||
--accent-ring: rgba(122,120,255,0.40);
|
||||
|
||||
--success: #7aff66;
|
||||
--success-soft: rgba(122,255,102,0.14);
|
||||
--warn: #ffce5b;
|
||||
--warn-soft: rgba(255,206,91,0.14);
|
||||
--danger: #ff5b6b;
|
||||
--danger-soft: rgba(255,91,107,0.14);
|
||||
|
||||
--button-radius: var(--radius-pill);
|
||||
--field-radius: 10px;
|
||||
--card-radius: 22px;
|
||||
--modal-radius: 22px;
|
||||
|
||||
--surface-blur: 20px;
|
||||
--backdrop: radial-gradient(60% 50% at 20% 20%, rgba(122,120,255,0.55), transparent 60%),
|
||||
radial-gradient(50% 50% at 80% 30%, rgba(177,91,255,0.50), transparent 60%),
|
||||
radial-gradient(70% 60% at 50% 100%, rgba(0,229,179,0.35), transparent 60%),
|
||||
#08081a;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
|
||||
--shadow: 0 10px 40px -10px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.10);
|
||||
--shadow-lg: 0 30px 80px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12);
|
||||
--shadow-modal: 0 40px 100px -30px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.14);
|
||||
|
||||
--button-bg: #ffffff;
|
||||
--button-fg: #08081a;
|
||||
--button-border: transparent;
|
||||
--button-hover: rgba(255,255,255,0.92);
|
||||
--button-press: rgba(255,255,255,0.84);
|
||||
|
||||
--button-secondary-bg: rgba(255,255,255,0.08);
|
||||
--button-secondary-fg: #ffffff;
|
||||
--button-secondary-border: var(--border);
|
||||
--button-secondary-hover: rgba(255,255,255,0.14);
|
||||
|
||||
--button-ghost-fg: #ffffff;
|
||||
--button-ghost-hover: rgba(255,255,255,0.06);
|
||||
|
||||
--field-bg: rgba(255,255,255,0.06);
|
||||
--field-border: var(--border);
|
||||
--field-placeholder: var(--text-3);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
THEME: editorial — warm paper, serif display, hairline rules
|
||||
============================================================ */
|
||||
.theme-editorial {
|
||||
--font-display: 'DM Serif Display', 'Times New Roman', Times, serif;
|
||||
|
||||
--bg: #f3eee2;
|
||||
--surface: #fbf8f0;
|
||||
--surface-2: #f7f2e6;
|
||||
--surface-alt: #ece6d6;
|
||||
--border: #d8d0bc;
|
||||
--border-strong:#b8a988;
|
||||
--divider: #e2d9c4;
|
||||
|
||||
--text: #1c1a14;
|
||||
--text-2: #5a5044;
|
||||
--text-3: #8a7d6a;
|
||||
--text-on-accent: #fbf8f0;
|
||||
|
||||
--accent: #1c1a14;
|
||||
--accent-2: #b85c28; /* terracotta */
|
||||
--accent-soft: #e8e1cd;
|
||||
--accent-ring: rgba(28,26,20,0.18);
|
||||
|
||||
--success: #3f7a3a;
|
||||
--success-soft: #dde9d4;
|
||||
--warn: #a86b14;
|
||||
--warn-soft: #f3e7c4;
|
||||
--danger: #a32a1e;
|
||||
--danger-soft: #f1d6cf;
|
||||
|
||||
--button-radius: 3px;
|
||||
--field-radius: 3px;
|
||||
--card-radius: 4px;
|
||||
--modal-radius: 4px;
|
||||
|
||||
--shadow-sm: 0 1px 0 rgba(28,26,20,0.06);
|
||||
--shadow: 0 1px 0 rgba(28,26,20,0.06), 0 6px 24px -12px rgba(28,26,20,0.12);
|
||||
--shadow-lg: 0 14px 36px -16px rgba(28,26,20,0.18), 0 1px 0 rgba(28,26,20,0.06);
|
||||
--shadow-modal: 0 30px 60px -20px rgba(28,26,20,0.28);
|
||||
|
||||
--button-bg: #1c1a14;
|
||||
--button-fg: #fbf8f0;
|
||||
--button-border: #1c1a14;
|
||||
--button-hover: #2f2a20;
|
||||
--button-press: #000000;
|
||||
|
||||
--button-secondary-bg: transparent;
|
||||
--button-secondary-fg: #1c1a14;
|
||||
--button-secondary-border: #1c1a14; /* thick rule */
|
||||
--button-secondary-hover: rgba(28,26,20,0.06);
|
||||
|
||||
--button-ghost-fg: var(--text);
|
||||
--button-ghost-hover: rgba(28,26,20,0.06);
|
||||
|
||||
--field-bg: #fbf8f0;
|
||||
--field-border: #1c1a14; /* hairline rule */
|
||||
--field-placeholder: var(--text-3);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Body backdrop helper — paint --backdrop on the page root.
|
||||
Glass theme uses this to show the aurora wash.
|
||||
============================================================ */
|
||||
.vibn-app {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.vibn-app::before {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background: var(--backdrop);
|
||||
z-index: 0; pointer-events: none;
|
||||
}
|
||||
.vibn-app > * { position: relative; z-index: 1; }
|
||||
5
design-templates/VIBN (2)/vibn-app/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.env*.local
|
||||
*.log
|
||||
123
design-templates/VIBN (2)/vibn-app/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Vibn — Marketing Site
|
||||
|
||||
Production Vite + React 18 + Tailwind CSS implementation of the Vibn marketing
|
||||
homepage and beta-invite signup page.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Vite 6** — dev server, HMR, multi-page build
|
||||
- **React 18** — function components + hooks, no router (two static entries)
|
||||
- **Tailwind CSS 3** — utility classes + a small `@layer components` block for
|
||||
things that don't compress cleanly to utilities (gradients, pseudo-elements,
|
||||
custom shadows)
|
||||
- **No CSS-in-JS, no UI library** — design tokens live as CSS custom properties
|
||||
so a tweak panel or theme switcher can runtime-swap the accent palette
|
||||
- **Geist + Geist Mono** — loaded from Google Fonts in each entry HTML
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173 (homepage)
|
||||
# http://localhost:5173/beta.html (signup)
|
||||
npm run build # production build → dist/
|
||||
npm run preview # serve the built bundle
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
vibn-app/
|
||||
├── index.html ← homepage entry
|
||||
├── beta.html ← beta-signup entry
|
||||
├── vite.config.js ← multi-page input config
|
||||
├── tailwind.config.js ← design tokens + named animations
|
||||
├── postcss.config.js
|
||||
├── public/
|
||||
│ └── logo-black.png ← favicon (V_ mark on black)
|
||||
└── src/
|
||||
├── main.jsx ← mounts <App />
|
||||
├── beta-main.jsx ← mounts <BetaApp />
|
||||
├── styles.css ← Tailwind + tokens + keyframes + .btn / .card / .logo-mark
|
||||
├── App.jsx ← homepage composition
|
||||
├── BetaApp.jsx ← signup-page composition
|
||||
├── lib/
|
||||
│ └── primitives.jsx ← Logo, LogoMark, Arrow, Eyebrow, Glow, TrustStrip
|
||||
└── components/
|
||||
├── Nav.jsx
|
||||
├── Hero.jsx ← Reddit-quote / promise variants + prompt input
|
||||
├── Wall.jsx ← faux chat-window "homework wall" scene
|
||||
├── CrossedOut.jsx ← animated strike-through term wall
|
||||
├── Journey.jsx ← 4-step path + "where others stop" marker
|
||||
├── Audience.jsx ← 3 audience cards w/ Reddit-style quotes
|
||||
├── Closing.jsx
|
||||
├── Footer.jsx
|
||||
├── LaunchModal.jsx ← hero prompt-submit modal
|
||||
└── beta/
|
||||
├── BetaForm.jsx ← 5-step form
|
||||
├── Confirmed.jsx ← submitted state w/ queue card + referral
|
||||
└── Benefits.jsx ← "what you get inside" trio
|
||||
```
|
||||
|
||||
## Design tokens
|
||||
|
||||
All colors are exposed as CSS custom properties (see `src/styles.css`,
|
||||
`:root` block) and aliased in `tailwind.config.js → theme.extend.colors`:
|
||||
|
||||
| Tailwind class | CSS var | Default |
|
||||
|---|---|---|
|
||||
| `bg-bg` | `--c-bg` | `oklch(0.155 0.008 60)` |
|
||||
| `bg-bg-1` | `--c-bg-1` | `oklch(0.185 0.009 60)` |
|
||||
| `text-fg` | `--c-fg` | `oklch(0.97 0.005 80)` |
|
||||
| `text-fg-dim` | `--c-fg-dim` | `oklch(0.78 0.006 80)` |
|
||||
| `text-fg-mute` | `--c-fg-mute` | `oklch(0.58 0.006 80)` |
|
||||
| `text-fg-faint` | `--c-fg-faint` | `oklch(0.42 0.006 80)` |
|
||||
| `text-accent` | `--c-accent` | `oklch(0.74 0.175 35)` (coral) |
|
||||
| `bg-accent` | `--c-accent` | (same) |
|
||||
| `border-hairline` | `--c-hairline` | `oklch(0.32 0.010 60 / 0.55)` |
|
||||
| `text-ok` | `--c-ok` | `oklch(0.78 0.16 155)` |
|
||||
|
||||
To re-theme at runtime, set the variables on `:root` from JS:
|
||||
|
||||
```js
|
||||
document.documentElement.style.setProperty("--c-accent", "#9ee649");
|
||||
document.documentElement.style.setProperty("--c-accent-glow", "#9ee64959");
|
||||
document.documentElement.style.setProperty("--c-accent-fg", "#0f1408");
|
||||
```
|
||||
|
||||
## Type scale
|
||||
|
||||
Fluid `clamp()` sizes are used inline:
|
||||
|
||||
- Hero headline: `clamp(44px, 7.4vw, 104px)`
|
||||
- Section H2: `clamp(36px, 4.8vw, 64px)`
|
||||
- Body / sub: `clamp(16–17px, 1.6–2.2vw, 19–28px)`
|
||||
|
||||
Geist is the body face; Geist Mono is reserved for tags, eyebrows, code, and
|
||||
"trust strip" details.
|
||||
|
||||
## Animations
|
||||
|
||||
Keyframes are defined once in `styles.css`; some are also registered as named
|
||||
Tailwind utilities (`animate-caret-blink`, `animate-pulse-ok`, etc.) for
|
||||
ergonomics. Anything not in the config uses an inline `style={{ animation: ... }}`
|
||||
with the keyframe name.
|
||||
|
||||
## Notes & next steps
|
||||
|
||||
- **No router.** Homepage links to `/beta.html` directly. Drop in
|
||||
`react-router-dom` if you need client-side navigation between more pages.
|
||||
- **Form submission is a stub.** `BetaForm` just waits 700 ms and toggles to
|
||||
the confirmed state. Wire to your real endpoint (e.g. `fetch("/api/invite", …)`).
|
||||
- **Queue position is deterministic on email.** Replace with the real one from
|
||||
your backend.
|
||||
- **The hero "Live from minute one" pill is currently not rendered** in this
|
||||
port (the marketing prototype defaults it off). Re-add by uncommenting the
|
||||
pill in `Hero.jsx` if you want it back.
|
||||
- **The Tweaks panel from the prototype is not ported.** Tweaks are a design-time
|
||||
tool; runtime theming is enough via the CSS-var token system.
|
||||
|
||||
## Browser support
|
||||
|
||||
Uses `oklch()`, `text-wrap: balance`, and `backdrop-filter`. Safari 15.4+,
|
||||
Chrome 111+, Firefox 113+.
|
||||
19
design-templates/VIBN (2)/vibn-app/beta.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo-black.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Vibn — Request an invite</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/beta-main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
design-templates/VIBN (2)/vibn-app/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo-black.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Vibn — Keep vibing. All the way to launch.</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
design-templates/VIBN (2)/vibn-app/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "vibn-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
|
||||
}
|
||||
1635
design-templates/VIBN (2)/vibn-app/pnpm-lock.yaml
generated
Normal file
6
design-templates/VIBN (2)/vibn-app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
design-templates/VIBN (2)/vibn-app/public/logo-black.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
40
design-templates/VIBN (2)/vibn-app/src/App.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Nav from "./components/Nav.jsx";
|
||||
import Hero from "./components/Hero.jsx";
|
||||
import Wall from "./components/Wall.jsx";
|
||||
import CrossedOut from "./components/CrossedOut.jsx";
|
||||
import Journey from "./components/Journey.jsx";
|
||||
import Audience from "./components/Audience.jsx";
|
||||
import Closing from "./components/Closing.jsx";
|
||||
import Footer from "./components/Footer.jsx";
|
||||
import LaunchModal from "./components/LaunchModal.jsx";
|
||||
|
||||
export default function App() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [launchPrompt, setLaunchPrompt] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav scrolled={scrolled} />
|
||||
<main>
|
||||
<Hero onStart={(p) => setLaunchPrompt(p || "Build me a tool for my business.")} />
|
||||
<Wall />
|
||||
<CrossedOut />
|
||||
<Journey />
|
||||
<Audience />
|
||||
<Closing />
|
||||
</main>
|
||||
<Footer />
|
||||
{launchPrompt !== null && (
|
||||
<LaunchModal prompt={launchPrompt} onClose={() => setLaunchPrompt(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
91
design-templates/VIBN (2)/vibn-app/src/BetaApp.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Logo, Arrow, Eyebrow, Glow } from "./lib/primitives.jsx";
|
||||
import BetaForm from "./components/beta/BetaForm.jsx";
|
||||
import Confirmed from "./components/beta/Confirmed.jsx";
|
||||
import Benefits from "./components/beta/Benefits.jsx";
|
||||
|
||||
export default function BetaApp() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
email: "", name: "", build: "", role: "smb", source: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Stable queue position — deterministic on email so the number doesn't jump
|
||||
const queuePos = useMemo(() => {
|
||||
let h = 7;
|
||||
for (const c of form.email) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
return 2100 + (h % 900);
|
||||
}, [form.email]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
setSubmitting(true);
|
||||
setTimeout(() => {
|
||||
setSubmitting(false);
|
||||
setSubmitted(true);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, 700);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className={`sticky top-0 z-50 backdrop-blur-md bg-[oklch(0.155_0.008_60/0.55)] transition-colors ${scrolled ? "border-b border-hairline" : "border-b border-transparent"}`}>
|
||||
<div className="wrap flex items-center justify-between h-16">
|
||||
<Logo href="/" />
|
||||
<a href="/" className="text-fg-mute text-[14px] inline-flex items-center gap-1.5 hover:text-fg">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M13 8H3M7 4 3 8l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="relative overflow-hidden py-[clamp(60px,9vh,100px)]">
|
||||
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={1000}
|
||||
style={{ top: -280, left: "50%", transform: "translateX(-50%)" }} />
|
||||
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={550} style={{ top: "30%", left: -180 }} />
|
||||
<Glow color="oklch(0.45 0.10 35 / 0.15)" size={500} style={{ top: "20%", right: -150 }} />
|
||||
|
||||
<div className="wrap relative max-w-[760px]">
|
||||
{submitted ? (
|
||||
<Confirmed form={form} queuePos={queuePos} />
|
||||
) : (
|
||||
<>
|
||||
<header className="text-center mb-14">
|
||||
<Eyebrow>Closed beta · invite-only</Eyebrow>
|
||||
<h1 className="mt-[18px] font-medium tracking-[-0.03em] leading-none text-[clamp(40px,6.4vw,80px)] text-balance">
|
||||
Be one of the first to{" "}
|
||||
<em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>
|
||||
vibe with Vibn
|
||||
</em>.
|
||||
</h1>
|
||||
<p className="mt-6 text-[clamp(16px,1.6vw,19px)] text-fg-dim max-w-[540px] mx-auto text-balance">
|
||||
We're letting in <b className="text-fg font-medium">50 new builders a week</b>.
|
||||
Tell us what you want to build — the most exciting ideas get the invite first.
|
||||
</p>
|
||||
</header>
|
||||
<BetaForm form={form} setForm={setForm} submitting={submitting} onSubmit={handleSubmit} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Benefits />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="py-6 border-t border-hairline" style={{ background: "oklch(0.14 0.008 60)" }}>
|
||||
<div className="wrap flex justify-between items-center gap-4 flex-wrap font-mono text-[11px] text-fg-faint tracking-[0.03em]">
|
||||
<span>🇨🇦 Built in Canada · Your data stays safe · No credit card to start</span>
|
||||
<span>© 2026 Vibn Inc.</span>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
design-templates/VIBN (2)/vibn-app/src/beta-main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import BetaApp from "./BetaApp.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BetaApp />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Eyebrow } from "../lib/primitives.jsx";
|
||||
|
||||
const AUDIENCE = [
|
||||
{
|
||||
label: "Small business owners", icon: "shop",
|
||||
quote: "I'm paying $312/month for software that does 60% of what I need and zero of the rest.",
|
||||
source: "u/coffeeshop_owner · r/smallbusiness",
|
||||
answer: "Build the tool that actually fits your shop — exactly your workflow, no monthly fee bleed.",
|
||||
},
|
||||
{
|
||||
label: "Freelancers building for clients", icon: "spark",
|
||||
quote: "My client wants a quote tool. I can mock the frontend in a day. The backend? Two weeks I don't have.",
|
||||
source: "u/agency_of_one · r/freelance",
|
||||
answer: "Deliver the whole thing — login, data, hosting — in the same chat where you built the screens.",
|
||||
},
|
||||
{
|
||||
label: "Anyone with an idea", icon: "spark2",
|
||||
quote: "I built the homepage in an afternoon. Then the AI told me to 'just deploy it' and I cried.",
|
||||
source: "u/first_time_builder · r/sideproject",
|
||||
answer: "No deploys. No GitHub. No fear. The thing you described is online, with logins, ready for users.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Audience() {
|
||||
return (
|
||||
<section className="relative py-[clamp(80px,11vh,140px)]">
|
||||
<div className="wrap">
|
||||
<div className="text-center max-w-[820px] mx-auto mb-14">
|
||||
<Eyebrow>Who Vibn is for</Eyebrow>
|
||||
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
|
||||
People who have an idea — not a stack.
|
||||
</h2>
|
||||
<p className="mt-5 text-fg-mute text-[17px]">If you've ever felt this, Vibn was built for you.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-[18px] grid-cols-1 lg:grid-cols-3">
|
||||
{AUDIENCE.map((a) => <ACard key={a.label} a={a} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ACard({ a }) {
|
||||
return (
|
||||
<div className="relative flex flex-col min-h-[380px] p-7 pb-[26px] rounded-[18px] border border-hairline overflow-hidden"
|
||||
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55))" }}>
|
||||
<span aria-hidden="true" className="absolute top-0 left-6 right-6 h-px opacity-60"
|
||||
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent), transparent)" }} />
|
||||
<div className="w-10 h-10 rounded-[10px] grid place-items-center border border-hairline mb-[18px] text-accent"
|
||||
style={{ background: "oklch(0.22 0.011 60)" }}>
|
||||
<Icon name={a.icon} />
|
||||
</div>
|
||||
<div className="text-[19px] font-medium tracking-[-0.015em] text-fg">{a.label}</div>
|
||||
|
||||
<div className="mt-[18px] py-4 px-[18px] italic text-fg-dim text-[14.5px] leading-[1.5] rounded-[4px_10px_10px_4px] border-l-2"
|
||||
style={{ background: "oklch(0.16 0.008 60 / 0.55)", borderLeftColor: "var(--c-accent)" }}>
|
||||
"{a.quote}"
|
||||
<div className="mt-2 not-italic font-mono text-[11px] text-fg-faint tracking-[0.02em]">— {a.source}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-[22px] flex gap-2.5 items-start text-[15px] text-fg leading-[1.5]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-accent px-[7px] py-[3px] rounded shrink-0 border mt-px"
|
||||
style={{ background: "oklch(0.74 0.175 35 / 0.12)", borderColor: "oklch(0.74 0.175 35 / 0.4)" }}>
|
||||
Vibn
|
||||
</span>
|
||||
<span>{a.answer}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ name }) {
|
||||
const p = {
|
||||
width: 20, height: 20, viewBox: "0 0 20 20", fill: "none",
|
||||
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round",
|
||||
};
|
||||
if (name === "shop") return <svg {...p}><path d="M3.5 6.5h13l-1 9.5h-11l-1-9.5Z"/><path d="M7 6.5V5a3 3 0 0 1 6 0v1.5"/></svg>;
|
||||
if (name === "spark") return <svg {...p}><path d="M10 3v4M10 13v4M3 10h4M13 10h4M5.3 5.3l2.8 2.8M11.9 11.9l2.8 2.8M14.7 5.3l-2.8 2.8M8.1 11.9l-2.8 2.8"/></svg>;
|
||||
if (name === "spark2") return <svg {...p}><path d="M10 2.5v3M10 14.5v3M2.5 10h3M14.5 10h3"/><circle cx="10" cy="10" r="3"/></svg>;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Arrow, Glow, TrustStrip } from "../lib/primitives.jsx";
|
||||
|
||||
export default function Closing() {
|
||||
return (
|
||||
<section className="relative overflow-hidden text-center py-[clamp(100px,14vh,180px)]">
|
||||
<Glow color="oklch(0.74 0.175 35 / 0.35)" size={1000}
|
||||
style={{ top: "20%", left: "50%", transform: "translateX(-50%)" }} />
|
||||
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={600}
|
||||
style={{ bottom: -200, left: "50%", transform: "translateX(-50%)" }} />
|
||||
|
||||
<div className="wrap max-w-[900px] mx-auto relative">
|
||||
<h2 className="font-medium tracking-[-0.03em] leading-[1.02] text-balance text-[clamp(40px,6vw,84px)]">
|
||||
If you can <em className="not-italic bg-clip-text text-transparent"
|
||||
style={{ backgroundImage: "linear-gradient(180deg, var(--c-accent), oklch(0.62 0.18 18))" }}>describe</em> it,
|
||||
<br />you can <em className="not-italic bg-clip-text text-transparent"
|
||||
style={{ backgroundImage: "linear-gradient(180deg, var(--c-accent), oklch(0.62 0.18 18))" }}>build</em> it.
|
||||
</h2>
|
||||
<p className="mt-7 text-fg-dim text-balance max-w-[640px] mx-auto text-[clamp(17px,1.6vw,21px)]">
|
||||
And you can keep building it — all the way to customers.
|
||||
<br />No new tools. No homework. No going back to the wall.
|
||||
</p>
|
||||
<div className="mt-9 inline-flex flex-col items-center gap-3.5">
|
||||
<div className="flex gap-3 items-center flex-wrap justify-center">
|
||||
<a href="/beta.html" className="btn btn-primary h-14 px-7 text-base">
|
||||
Request invite <Arrow />
|
||||
</a>
|
||||
<a href="#how" className="btn btn-ghost">See how it works</a>
|
||||
</div>
|
||||
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Eyebrow } from "../lib/primitives.jsx";
|
||||
|
||||
const CROSSED_TERMS = [
|
||||
"Databases", "Auth providers", "GitHub", "Hosting",
|
||||
"API keys", "Environment variables", "Deployment", "Backend code",
|
||||
"Servers", "DNS records", "SSL certificates", "CORS errors",
|
||||
"Webhooks", "Build pipelines", "package.json", "npm install",
|
||||
];
|
||||
|
||||
export default function CrossedOut() {
|
||||
return (
|
||||
<section className="relative py-[clamp(70px,10vh,130px)]">
|
||||
<div className="wrap">
|
||||
<div className="text-center max-w-[760px] mx-auto mb-14">
|
||||
<Eyebrow>What you don't have to learn</Eyebrow>
|
||||
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
|
||||
All the stuff that made you give up last time.
|
||||
</h2>
|
||||
<p className="mt-[18px] text-fg-mute text-[17px]">Forget every word on this list.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-x-3.5 gap-y-2.5 max-w-[880px] mx-auto">
|
||||
{CROSSED_TERMS.map((term, i) => (
|
||||
<Term key={term} term={term} delay={0.12 + i * 0.06} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-14 mx-auto max-w-[760px] text-center font-medium tracking-[-0.02em] leading-[1.18] text-balance text-[clamp(24px,3vw,36px)]">
|
||||
Your AI handles <span className="text-accent">all of it</span>.
|
||||
<span className="block w-12 h-px bg-hairline mx-auto my-7" />
|
||||
You just keep building.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Term({ term, delay }) {
|
||||
return (
|
||||
<span className="relative overflow-hidden px-3.5 py-2 rounded-lg border border-hairline font-mono tracking-[0.005em] text-[clamp(15px,1.7vw,22px)] text-fg-mute"
|
||||
style={{ background: "oklch(0.20 0.009 60 / 0.45)" }}>
|
||||
<span
|
||||
className="inline-block opacity-100"
|
||||
style={{ animation: `fade-half 0.4s ease ${delay}s forwards` }}
|
||||
>{term}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-2 right-2 top-1/2 h-0.5 rounded-sm opacity-0"
|
||||
style={{
|
||||
background: "var(--c-accent)",
|
||||
boxShadow: "0 0 12px var(--c-accent-glow)",
|
||||
animation: `strike 0.6s cubic-bezier(.7,.1,.3,1) ${delay}s forwards`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
29
design-templates/VIBN (2)/vibn-app/src/components/Footer.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Logo } from "../lib/primitives.jsx";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="relative pt-10 pb-8 border-t border-hairline" style={{ background: "oklch(0.14 0.008 60)" }}>
|
||||
<div className="wrap">
|
||||
<div className="flex items-start justify-between gap-8 flex-wrap">
|
||||
<Logo />
|
||||
<div className="flex flex-wrap items-center gap-5 font-mono text-[12px] text-fg-mute tracking-[0.03em]">
|
||||
<span>🇨🇦 Built in Canada</span>
|
||||
<span className="text-fg-faint">·</span>
|
||||
<span>Your data stays safe</span>
|
||||
<span className="text-fg-faint">·</span>
|
||||
<span>No credit card to start</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 pt-5 border-t border-hairline flex justify-between items-center gap-4 flex-wrap font-mono text-[11px] text-fg-faint tracking-[0.04em]">
|
||||
<span>© 2026 Vibn Inc. · Made for makers, not engineers.</span>
|
||||
<div className="flex gap-[18px]">
|
||||
<a href="#" className="hover:text-fg-dim">Privacy</a>
|
||||
<a href="#" className="hover:text-fg-dim">Terms</a>
|
||||
<a href="#" className="hover:text-fg-dim">Status</a>
|
||||
<a href="#" className="hover:text-fg-dim">Changelog</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
210
design-templates/VIBN (2)/vibn-app/src/components/Hero.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Arrow, TrustStrip, Glow } from "../lib/primitives.jsx";
|
||||
|
||||
const HERO_PLACEHOLDERS = [
|
||||
"A booking site for my dog grooming business…",
|
||||
"An invoice tracker for my freelance clients…",
|
||||
"A members-only recipe site for my supper club…",
|
||||
"A custom CRM for our 3-person real estate team…",
|
||||
"A tip calculator app for our restaurant staff…",
|
||||
"A waitlist site for my new ceramics studio…",
|
||||
];
|
||||
|
||||
const HERO_CHIPS = [
|
||||
"📋 Client intake form",
|
||||
"📅 Booking site",
|
||||
"🧾 Invoice tracker",
|
||||
"🛒 Online store",
|
||||
"📰 Email newsletter",
|
||||
];
|
||||
|
||||
export default function Hero({ onStart, variant = "promise" }) {
|
||||
const [text, setText] = useState("");
|
||||
const [phIdx, setPhIdx] = useState(0);
|
||||
const [phChars, setPhChars] = useState(0);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const taRef = useRef(null);
|
||||
|
||||
// Type-on placeholder while empty
|
||||
useEffect(() => {
|
||||
if (text.length > 0) return undefined;
|
||||
const full = HERO_PLACEHOLDERS[phIdx];
|
||||
const speed = deleting ? 18 : 38;
|
||||
const t = setTimeout(() => {
|
||||
if (!deleting) {
|
||||
if (phChars < full.length) setPhChars(phChars + 1);
|
||||
else setTimeout(() => setDeleting(true), 1700);
|
||||
} else {
|
||||
if (phChars > 0) setPhChars(phChars - 1);
|
||||
else { setDeleting(false); setPhIdx((phIdx + 1) % HERO_PLACEHOLDERS.length); }
|
||||
}
|
||||
}, speed);
|
||||
return () => clearTimeout(t);
|
||||
}, [text, phIdx, phChars, deleting]);
|
||||
|
||||
const placeholder = HERO_PLACEHOLDERS[phIdx].slice(0, phChars);
|
||||
|
||||
const submit = () => {
|
||||
const value = text || HERO_PLACEHOLDERS[phIdx];
|
||||
onStart?.(value);
|
||||
};
|
||||
const useChip = (chip) => {
|
||||
const clean = chip.replace(/^[^\w]+/, "").trim();
|
||||
setText(`Build me ${clean.toLowerCase()} for my business.`);
|
||||
taRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="relative overflow-hidden pt-[clamp(60px,9vh,120px)] pb-[clamp(60px,10vh,120px)]">
|
||||
{/* Ambient glows */}
|
||||
<Glow color="oklch(0.74 0.175 35 / 0.30)" size={900}
|
||||
style={{ top: -200, left: "50%", transform: "translateX(-50%)" }} />
|
||||
<Glow color="oklch(0.45 0.10 35 / 0.30)" size={600} style={{ top: "20%", left: -200 }} />
|
||||
<Glow color="oklch(0.45 0.10 35 / 0.20)" size={500} style={{ top: "30%", right: -150 }} />
|
||||
|
||||
<div className="wrap relative flex flex-col items-center text-center gap-7">
|
||||
{variant === "promise" ? (
|
||||
<>
|
||||
<h1 className="font-medium leading-[0.98] tracking-[-0.035em] text-[clamp(44px,7.4vw,104px)] text-balance">
|
||||
Keep <span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>vibing</span>.
|
||||
<br />All the way to launch.
|
||||
</h1>
|
||||
<div className="font-mono text-[12px] text-fg-faint tracking-[0.04em] inline-flex items-center gap-2 -mt-2">
|
||||
<span className="w-6 h-px bg-hairline" />
|
||||
idea → live → marketed → customers
|
||||
<span className="w-6 h-px bg-hairline" />
|
||||
</div>
|
||||
<p className="text-[clamp(20px,2.2vw,28px)] text-fg-dim tracking-[-0.01em] max-w-[720px] text-balance">
|
||||
<b className="text-fg font-medium">"I built my product, now what?"</b> Vibn is the answer.
|
||||
<br />Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="font-medium leading-[0.98] tracking-[-0.035em] text-[clamp(44px,7.4vw,104px)] text-balance">
|
||||
<span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>“</span>I built my product,
|
||||
<br />now what<span className="text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>?”</span>
|
||||
</h1>
|
||||
<div className="font-mono text-[12px] text-fg-faint tracking-[0.04em] inline-flex items-center gap-2 -mt-2">
|
||||
<span className="w-6 h-px bg-hairline" />
|
||||
posted 2 hours ago · r/SideProject
|
||||
<span className="w-6 h-px bg-hairline" />
|
||||
</div>
|
||||
<p className="text-[clamp(20px,2.2vw,28px)] text-fg-dim tracking-[-0.01em] max-w-[720px] text-balance">
|
||||
<b className="text-fg font-medium">Keep vibing.</b> All the way to launch.
|
||||
<br />Your AI handles the technical stuff, puts your idea online, and helps you find your first customers.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<PromptInput
|
||||
text={text} setText={setText}
|
||||
placeholder={placeholder}
|
||||
taRef={taRef}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-center mt-3 text-[13px]">
|
||||
{HERO_CHIPS.map((c) => (
|
||||
<button key={c} type="button"
|
||||
onClick={() => useChip(c)}
|
||||
className="px-3.5 py-[7px] rounded-full border border-hairline bg-[oklch(0.20_0.009_60/0.4)] text-fg-dim transition-all hover:border-hairline-2 hover:text-fg hover:-translate-y-px"
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 items-center mt-2.5 flex-wrap justify-center">
|
||||
<button type="button" onClick={submit} className="btn btn-primary">
|
||||
Start building free <Arrow />
|
||||
</button>
|
||||
<a href="#how" className="btn btn-ghost">See how it works</a>
|
||||
</div>
|
||||
|
||||
<TrustStrip items={["No credit card", "No homework", "No new tools to learn"]} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptInput({ text, setText, placeholder, taRef, onSubmit }) {
|
||||
return (
|
||||
<div className="w-full max-w-[720px] relative mt-3.5">
|
||||
<div className="relative rounded-3xl p-px"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, oklch(0.50 0.06 35 / 0.6), oklch(0.30 0.012 60 / 0.4) 40%, oklch(0.25 0.012 60 / 0.4))",
|
||||
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6), 0 0 80px -20px var(--c-accent-glow)",
|
||||
}}
|
||||
>
|
||||
<div className="rounded-[27px] px-[18px] pt-[18px] pb-3.5 backdrop-blur-xl"
|
||||
style={{ background: "linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92))" }}
|
||||
>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(); }}
|
||||
className="w-full min-h-[96px] bg-transparent border-0 outline-none resize-none text-fg text-[17px] leading-[1.45] py-1.5 px-1 placeholder:text-fg-faint"
|
||||
aria-label="Describe what you want to build"
|
||||
placeholder=""
|
||||
/>
|
||||
{text.length === 0 && (
|
||||
<div className="absolute top-[22px] left-[6px] right-[6px] pointer-events-none text-fg-faint text-[17px] leading-[1.45] text-left">
|
||||
{placeholder}
|
||||
<span className="inline-block w-2 h-[18px] align-[-3px] ml-0.5 animate-[blink_1s_steps(2)_infinite]"
|
||||
style={{ background: "var(--c-accent)", boxShadow: "0 0 12px var(--c-accent-glow)" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3.5 mt-1.5 pt-3 border-t border-hairline">
|
||||
<div className="hidden sm:flex gap-1.5 text-fg-mute">
|
||||
<PromptTool icon="paperclip" label="Screenshot" />
|
||||
<PromptTool icon="mic" label="Voice" />
|
||||
<PromptTool icon="grid" label="Templates" />
|
||||
</div>
|
||||
<button
|
||||
type="button" onClick={onSubmit}
|
||||
className="inline-flex items-center gap-2 h-9 px-3.5 pr-3.5 rounded-full font-medium text-sm transition-transform hover:-translate-y-px"
|
||||
style={{
|
||||
background: "var(--c-accent)", color: "var(--c-accent-fg)",
|
||||
boxShadow: "0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset, 0 8px 28px -8px var(--c-accent-glow)",
|
||||
}}
|
||||
>
|
||||
Start building <Arrow size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptTool({ icon, label }) {
|
||||
return (
|
||||
<button type="button" title={label}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-[12px] text-fg-mute border border-transparent transition-colors hover:border-hairline hover:text-fg-dim">
|
||||
<PromptIcon name={icon} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptIcon({ name }) {
|
||||
const props = {
|
||||
width: 13, height: 13, viewBox: "0 0 16 16", fill: "none",
|
||||
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round",
|
||||
};
|
||||
if (name === "paperclip") return (
|
||||
<svg {...props}><path d="M11.5 6.5 6.6 11.4a2 2 0 1 1-2.8-2.8l5.4-5.4a3.5 3.5 0 1 1 5 5L8.6 13.7"/></svg>
|
||||
);
|
||||
if (name === "mic") return (
|
||||
<svg {...props}><rect x="6" y="2" width="4" height="8" rx="2"/><path d="M3.5 8a4.5 4.5 0 0 0 9 0M8 13v2"/></svg>
|
||||
);
|
||||
if (name === "grid") return (
|
||||
<svg {...props}><rect x="2.5" y="2.5" width="4.5" height="4.5"/><rect x="9" y="2.5" width="4.5" height="4.5"/><rect x="2.5" y="9" width="4.5" height="4.5"/><rect x="9" y="9" width="4.5" height="4.5"/></svg>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
165
design-templates/VIBN (2)/vibn-app/src/components/Journey.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Eyebrow } from "../lib/primitives.jsx";
|
||||
|
||||
const JOURNEY_STEPS = [
|
||||
{
|
||||
num: "01", title: "You describe it.", sub: "The AI builds it.",
|
||||
body: "Talk to it like you'd talk to a friend who codes. It builds the screens, the buttons, the logic — whatever your idea needs.",
|
||||
demo: "describe",
|
||||
},
|
||||
{
|
||||
num: "02", title: "It goes live.", sub: "The AI puts it online.",
|
||||
body: "Logins, saving your stuff, hosting — handled. You get a live link from minute one. Share it. Show your friends. It just works.",
|
||||
demo: "live",
|
||||
},
|
||||
{
|
||||
num: "03", title: "It gets seen.", sub: "The AI markets it.",
|
||||
body: "Posts, emails, social — written, scheduled, and shipped on autopilot. The tone matches your brand because you trained it talking to your AI.",
|
||||
demo: "seen",
|
||||
},
|
||||
{
|
||||
num: "04", title: "It gets customers.", sub: "Your first 100.",
|
||||
body: "Through our Google partnership, Vibn helps the right people find your product when they're searching for what you built.",
|
||||
demo: "customers",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Journey() {
|
||||
return (
|
||||
<section id="how" className="relative py-[clamp(80px,11vh,140px)]">
|
||||
<div className="wrap">
|
||||
<div className="text-center max-w-[820px] mx-auto mb-16">
|
||||
<Eyebrow>The journey</Eyebrow>
|
||||
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
|
||||
From idea to first 100 customers.
|
||||
<br /><span className="text-accent">In one chat.</span>
|
||||
</h2>
|
||||
<p className="mt-5 text-fg-mute text-[17px] text-balance">
|
||||
Other tools take you to step two and wave goodbye. Vibn keeps building with you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{/* "Where everyone else stops" — only meaningful on the 4-col layout */}
|
||||
<div className="hidden xl:flex absolute inset-y-0 flex-col items-center pointer-events-none z-[2]"
|
||||
style={{ left: "calc(50% - 1px)", width: 16 }}>
|
||||
<div className="flex-1 w-px" style={{
|
||||
background: "repeating-linear-gradient(180deg, var(--c-accent) 0 6px, transparent 6px 12px)",
|
||||
opacity: .7,
|
||||
}}/>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-accent bg-bg px-3 py-1.5 rounded-full border whitespace-nowrap"
|
||||
style={{
|
||||
borderColor: "oklch(0.74 0.175 35 / 0.5)",
|
||||
boxShadow: "0 0 24px var(--c-accent-glow)",
|
||||
transform: "translateY(-1px)",
|
||||
}}>
|
||||
↑ Where every other tool stops
|
||||
</span>
|
||||
<div className="flex-1 w-px" style={{
|
||||
background: "repeating-linear-gradient(180deg, var(--c-accent) 0 6px, transparent 6px 12px)",
|
||||
opacity: .7,
|
||||
}}/>
|
||||
</div>
|
||||
|
||||
{JOURNEY_STEPS.map((s, i) => (
|
||||
<StepCard key={s.num} step={s} stopped={i >= 2} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-12 text-center text-fg-mute text-[15px] text-balance">
|
||||
<b className="text-fg font-medium">One tool. One chat.</b> From "wouldn't it be cool if…" to{" "}
|
||||
<b className="text-fg font-medium">real customers paying you money.</b>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({ step, stopped }) {
|
||||
return (
|
||||
<div className={[
|
||||
"relative flex flex-col rounded-2xl border border-hairline overflow-hidden isolate min-h-[380px] pt-6 px-6",
|
||||
stopped ? "opacity-[0.46]" : "",
|
||||
].join(" ")}
|
||||
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55))" }}
|
||||
>
|
||||
{!stopped && (
|
||||
<span className="absolute top-0 left-0 right-0 h-px opacity-70"
|
||||
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent) 50%, transparent)" }} />
|
||||
)}
|
||||
{stopped && (
|
||||
<span aria-hidden="true" className="absolute inset-0 pointer-events-none"
|
||||
style={{ background: "linear-gradient(180deg, transparent 40%, oklch(0.155 0.008 60 / 0.6))" }} />
|
||||
)}
|
||||
<div className="font-mono text-[11px] text-fg-faint tracking-[0.08em]">{step.num}</div>
|
||||
<h3 className="mt-3 text-[22px] font-medium tracking-[-0.018em]">{step.title}</h3>
|
||||
<div className={`mt-1 text-[15px] font-medium ${stopped ? "text-fg-mute" : "text-accent"}`}>{step.sub}</div>
|
||||
<p className="mt-3 text-fg-dim text-[14px] leading-[1.55]">{step.body}</p>
|
||||
<StepDemo demo={step.demo} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepDemo({ demo }) {
|
||||
const wrapStyle = "mt-auto -mx-6 px-[18px] py-4 border-t border-hairline font-mono text-[12px] leading-[1.55] text-fg-dim flex flex-col gap-[7px] min-h-[116px]";
|
||||
const wrapBg = { background: "oklch(0.16 0.008 60 / 0.6)" };
|
||||
|
||||
if (demo === "describe") return (
|
||||
<div className={wrapStyle} style={wrapBg}>
|
||||
<DemoRow tag="YOU" tagKind="you">build a booking site for my dog grooming biz</DemoRow>
|
||||
<DemoRow tag="VIBN" tagKind="ai">on it — designing screens…</DemoRow>
|
||||
<DemoRow tag="VIBN" tagKind="ai" align="center"><span className="text-ok">✓ booking flow ready</span></DemoRow>
|
||||
</div>
|
||||
);
|
||||
if (demo === "live") return (
|
||||
<div className={wrapStyle} style={wrapBg}>
|
||||
<DemoRow tag="VIBN" tagKind="ai" align="center">put it online</DemoRow>
|
||||
<div className="h-1 rounded-full overflow-hidden relative" style={{ background: "oklch(0.25 0.01 60)" }}>
|
||||
<span className="absolute inset-0 w-[64%]" style={{ background: "var(--c-accent)", boxShadow: "0 0 8px var(--c-accent-glow)" }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-fg-dim">
|
||||
<i className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--c-ok)", boxShadow: "0 0 6px oklch(0.78 0.16 155 / 0.6)" }} />
|
||||
pawsandposh.vibn.app
|
||||
</div>
|
||||
<div><span className="text-ok">✓ logins · ✓ saving · ✓ live</span></div>
|
||||
</div>
|
||||
);
|
||||
if (demo === "seen") return (
|
||||
<div className={wrapStyle} style={wrapBg}>
|
||||
<DemoRow tag="VIBN" tagKind="ai">draft a launch post for Instagram + email blast</DemoRow>
|
||||
<div className="text-fg-faint">↳ scheduled for Tue 9:00 AM</div>
|
||||
<div className="text-fg-faint">↳ scheduled for Thu 6:00 PM</div>
|
||||
<div><span className="text-ok">✓ 3 channels on autopilot</span></div>
|
||||
</div>
|
||||
);
|
||||
if (demo === "customers") return (
|
||||
<div className={wrapStyle} style={wrapBg}>
|
||||
<div className="flex items-center">
|
||||
<span className="font-mono text-[22px] font-medium text-accent tracking-[-0.02em]">
|
||||
+47<small className="text-fg-mute text-[11px] font-normal ml-1">this week</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{["35", "260", "155", "80"].map((h) => (
|
||||
<span key={h} className="w-4 h-4 rounded-full shrink-0" style={{ background: `oklch(0.55 0.14 ${h})` }} />
|
||||
))}
|
||||
<span className="text-fg-mute">found you via Google</span>
|
||||
</div>
|
||||
<div><span className="text-ok">✓ tracking toward 100</span></div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function DemoRow({ tag, tagKind, align, children }) {
|
||||
const styles = tagKind === "you"
|
||||
? { color: "oklch(0.85 0.06 250)", background: "oklch(0.28 0.04 250)" }
|
||||
: tagKind === "ai"
|
||||
? { color: "var(--c-accent)", background: "oklch(0.35 0.10 35 / 0.4)" }
|
||||
: { color: "var(--c-fg-faint)", background: "oklch(0.22 0.01 60)" };
|
||||
return (
|
||||
<div className={`flex gap-2 ${align === "center" ? "items-center" : "items-start"}`}>
|
||||
<span className="font-mono text-[10px] px-1.5 py-px rounded shrink-0 tracking-[0.04em] mt-px" style={styles}>{tag}</span>
|
||||
<span className="text-fg-dim">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STEPS = [
|
||||
"Drafting the screens",
|
||||
"Setting up logins",
|
||||
"Saving your stuff",
|
||||
"Putting it online",
|
||||
];
|
||||
|
||||
export default function LaunchModal({ prompt, onClose }) {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step >= STEPS.length) return undefined;
|
||||
const t = setTimeout(() => setStep(step + 1), 700);
|
||||
return () => clearTimeout(t);
|
||||
}, [step]);
|
||||
|
||||
return (
|
||||
<div onClick={onClose}
|
||||
className="fixed inset-0 z-[100] grid place-items-center p-6 backdrop-blur-md"
|
||||
style={{ background: "oklch(0.10 0.005 60 / 0.7)", animation: "fadein .2s ease" }}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
className="relative w-full max-w-[540px] rounded-[20px] p-7 pb-6 border border-hairline-2"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, oklch(0.20 0.009 60), oklch(0.17 0.008 60))",
|
||||
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6), 0 0 60px -20px var(--c-accent-glow)",
|
||||
}}>
|
||||
<button type="button" onClick={onClose}
|
||||
className="absolute top-3.5 right-3.5 w-7 h-7 rounded-md text-fg-mute hover:text-fg hover:bg-[oklch(0.25_0.01_60)]"
|
||||
aria-label="Close">✕</button>
|
||||
|
||||
<div className="flex items-center gap-2.5 text-accent font-mono text-[11px] uppercase tracking-[0.1em]">
|
||||
<i className="w-1.5 h-1.5 rounded-full animate-pulse-ok"
|
||||
style={{ background: "var(--c-accent)", boxShadow: "0 0 12px var(--c-accent-glow)" }} />
|
||||
Vibn is on it
|
||||
</div>
|
||||
<h3 className="mt-3 text-[24px] font-medium tracking-[-0.018em] leading-[1.15]">Keep vibing — we've got the rest.</h3>
|
||||
|
||||
<div className="mt-3.5 p-3.5 rounded-[10px] border border-hairline font-mono text-[13px] text-fg-dim leading-[1.5]"
|
||||
style={{ background: "oklch(0.16 0.008 60)" }}>
|
||||
"{prompt}"
|
||||
</div>
|
||||
|
||||
<div className="mt-[18px] flex flex-col gap-2.5">
|
||||
{STEPS.map((s, i) => (
|
||||
<Step key={s} label={s} state={i < step ? "done" : i === step ? "active" : "pending"} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-[18px] text-center font-mono text-[11px] text-fg-faint tracking-[0.04em]">
|
||||
No homework · No setup · No new tools to learn
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ label, state }) {
|
||||
return (
|
||||
<div className={[
|
||||
"flex items-center gap-3 px-3.5 py-[11px] rounded-[10px] border border-hairline text-[14px] transition-colors",
|
||||
state === "done" ? "text-fg" : "text-fg-dim",
|
||||
].join(" ")} style={{ background: "oklch(0.165 0.008 60)" }}>
|
||||
{state === "done" ? (
|
||||
<svg className="w-[18px] h-[18px] text-ok shrink-0" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M4 10.5 8 14.5 16 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
) : state === "active" ? (
|
||||
<span className="w-3.5 h-3.5 rounded-full border-2 shrink-0"
|
||||
style={{
|
||||
borderColor: "oklch(0.30 0.01 60)",
|
||||
borderTopColor: "var(--c-accent)",
|
||||
animation: "spin .9s linear infinite",
|
||||
}} />
|
||||
) : (
|
||||
<svg className="w-[18px] h-[18px] text-fg-faint shrink-0" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="6" stroke="currentColor" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
design-templates/VIBN (2)/vibn-app/src/components/Nav.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Logo, Arrow } from "../lib/primitives.jsx";
|
||||
|
||||
export default function Nav({ scrolled }) {
|
||||
return (
|
||||
<nav
|
||||
className={[
|
||||
"sticky top-0 z-50 backdrop-blur-md transition-colors",
|
||||
"bg-[oklch(0.155_0.008_60/0.55)]",
|
||||
scrolled ? "border-b border-hairline" : "border-b border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="wrap flex items-center justify-between h-16">
|
||||
<Logo href="/" />
|
||||
<div className="hidden md:flex gap-7 text-fg-mute text-[14px]">
|
||||
<a href="#how" className="hover:text-fg">How it works</a>
|
||||
<a href="#" className="hover:text-fg">Templates</a>
|
||||
<a href="#" className="hover:text-fg">Pricing</a>
|
||||
<a href="#" className="hover:text-fg">Stories</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<a href="#" className="btn btn-ghost h-9 px-4 text-sm hidden sm:inline-flex">Sign in</a>
|
||||
<a href="/beta.html" className="btn btn-primary h-9 px-4 text-sm">
|
||||
Request invite <Arrow size={12} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
140
design-templates/VIBN (2)/vibn-app/src/components/Wall.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Eyebrow } from "../lib/primitives.jsx";
|
||||
|
||||
export default function Wall() {
|
||||
return (
|
||||
<section id="the-wall" className="relative py-[clamp(60px,9vh,110px)]">
|
||||
<div className="wrap">
|
||||
<div className="text-center max-w-[760px] mx-auto mb-14">
|
||||
<Eyebrow>The wall</Eyebrow>
|
||||
<h2 className="mt-[18px] font-medium text-[clamp(36px,4.8vw,64px)] tracking-[-0.025em] leading-[1.02] text-balance">
|
||||
Every other tool stops{" "}
|
||||
<em className="not-italic text-accent" style={{ textShadow: "0 0 30px var(--c-accent-glow)" }}>
|
||||
right here
|
||||
</em>.
|
||||
</h2>
|
||||
<p className="mt-5 text-fg-mute text-[17px] text-balance">
|
||||
You built it. It works on your laptop. Then the chat hands you a list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Faux app window */}
|
||||
<div className="relative max-w-[880px] mx-auto rounded-2xl border border-hairline overflow-hidden backdrop-blur-md"
|
||||
style={{ background: "oklch(0.165 0.008 60 / 0.85)", boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6)" }}>
|
||||
<WindowBar />
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<Msg who="user" name="You · just now" body="okay it works!! how do i put this online so my customers can use it?" />
|
||||
<Msg who="ai" name="Generic AI · just now" body={<HomeworkBody />} />
|
||||
<Msg who="user" name="You · now" body={<Typing />} muted />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Punchline */}
|
||||
<div className="mt-14 text-center">
|
||||
<div className="w-px h-14 mx-auto mb-7"
|
||||
style={{ background: "linear-gradient(180deg, transparent, var(--c-hairline), transparent)" }} />
|
||||
<p className="font-medium tracking-[-0.022em] leading-[1.2] text-balance text-fg-mute text-[clamp(28px,3.4vw,42px)]">
|
||||
And just like that — <em className="italic text-fg">the vibe is gone.</em>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowBar() {
|
||||
return (
|
||||
<div className="flex items-center gap-3.5 px-3.5 py-[11px] border-b border-hairline font-mono text-[12px] text-fg-mute"
|
||||
style={{ background: "oklch(0.20 0.009 60 / 0.85)" }}>
|
||||
<div className="flex gap-[7px]">
|
||||
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
|
||||
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
|
||||
<i className="w-[11px] h-[11px] rounded-full" style={{ background: "oklch(0.40 0.01 60)" }} />
|
||||
</div>
|
||||
<span className="ml-2 text-fg-faint tracking-[0.02em]">untitled-project · main</span>
|
||||
<span className="ml-auto px-2 py-0.5 rounded text-fg-faint text-[11px]" style={{ background: "oklch(0.25 0.01 60)" }}>
|
||||
generic ai coder · chat
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Msg({ who, name, body, muted }) {
|
||||
return (
|
||||
<div className="flex gap-3 items-start text-[14.5px] leading-[1.55]">
|
||||
<div className={[
|
||||
"w-[26px] h-[26px] rounded-[7px] grid place-items-center font-mono text-[11px] font-semibold shrink-0",
|
||||
who === "user" ? "" : "",
|
||||
].join(" ")}
|
||||
style={who === "user"
|
||||
? { background: "oklch(0.28 0.01 60)", color: "var(--c-fg-dim)" }
|
||||
: { background: "oklch(0.30 0.02 250)", color: "oklch(0.85 0.06 250)" }
|
||||
}>
|
||||
{who === "user" ? "YOU" : "AI"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-[11px] text-fg-faint tracking-[0.04em] uppercase mb-0.5">{name}</div>
|
||||
<div className={who === "user" && !muted ? "text-fg" : "text-fg-dim"}>{body}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeworkBody() {
|
||||
const items = [
|
||||
["Sign up for Supabase", "and create a project for your database."],
|
||||
["Configure authentication", "with Supabase Auth or Clerk — pick one."],
|
||||
["Create a GitHub repo", ", commit your code, and push it."],
|
||||
["Deploy to Vercel", ": connect repo, configure framework preset."],
|
||||
["Add environment variables", "for your API keys and DB url in the Vercel dashboard."],
|
||||
["Set up DNS", "for your custom domain and verify nameservers with your registrar."],
|
||||
["Configure SSL / TLS certificates", "for HTTPS (or use Vercel's automatic provisioning)."],
|
||||
["Set up Stripe", "if you want to take payments, and configure webhooks."],
|
||||
];
|
||||
// Per-row fade values for "overload" feeling
|
||||
const opacities = [1, 1, 1, 0.82, 0.65, 0.48, 0.34, 0.22];
|
||||
const blurs = [0, 0, 0, 0, 0, 0.2, 0.4, 0.7];
|
||||
return (
|
||||
<>
|
||||
<p className="text-fg-dim">Great job 🎉 Your app is running locally. To take it live, you'll need to set a few things up first:</p>
|
||||
<ol className="list-none p-0 mt-3 flex flex-col gap-2">
|
||||
{items.map(([title, rest], i) => (
|
||||
<li key={i}
|
||||
className="flex items-start gap-3 px-3.5 py-3 rounded-[10px] border border-hairline text-fg-dim text-[13.5px] transition-opacity"
|
||||
style={{
|
||||
background: "oklch(0.20 0.009 60)",
|
||||
opacity: opacities[i],
|
||||
filter: blurs[i] ? `blur(${blurs[i]}px)` : "none",
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[11px] text-fg-faint px-1.5 py-px rounded shrink-0"
|
||||
style={{ background: "oklch(0.16 0.008 60)" }}>
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span>
|
||||
<b className="text-fg font-medium">{title}</b> {rest}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[11px] text-fg-faint px-[7px] py-px border border-hairline rounded shrink-0">
|
||||
↗ external
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="-mt-2.5 pt-[30px] font-mono text-[11px] text-fg-faint text-center"
|
||||
style={{ background: "linear-gradient(180deg, transparent, oklch(0.165 0.008 60 / 0.85))" }}>
|
||||
↓ 23 more steps
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Typing() {
|
||||
return (
|
||||
<span className="inline-flex gap-[3px] items-center py-1 text-fg-mute">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<i key={i}
|
||||
className="w-[5px] h-[5px] rounded-full bg-fg-mute"
|
||||
style={{ animation: `bounce-dot 1.2s ${i * 0.15}s infinite ease-in-out` }} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Eyebrow } from "../../lib/primitives.jsx";
|
||||
|
||||
const BENEFITS = [
|
||||
{ icon: "lightning", title: "First access", body: "Skip the queue when public beta opens. You build before everyone else." },
|
||||
{ icon: "gift", title: "90 days of Pro, free", body: "Full launch features — hosting, marketing, customer acquisition — on the house." },
|
||||
{ icon: "chat", title: "Direct line to the team", body: "Private channel with the people building Vibn. Your feedback ships." },
|
||||
];
|
||||
|
||||
export default function Benefits() {
|
||||
return (
|
||||
<section className="mt-16">
|
||||
<div className="text-center mb-7"><Eyebrow>What you get on the inside</Eyebrow></div>
|
||||
<div className="grid gap-3.5 grid-cols-1 md:grid-cols-3">
|
||||
{BENEFITS.map((b) => (
|
||||
<div key={b.title} className="p-6 rounded-[14px] border border-hairline"
|
||||
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.35), oklch(0.17 0.008 60 / 0.35))" }}>
|
||||
<div className="w-9 h-9 rounded-[9px] grid place-items-center border border-hairline text-accent mb-3.5"
|
||||
style={{ background: "oklch(0.22 0.011 60)" }}>
|
||||
<Icon name={b.icon} />
|
||||
</div>
|
||||
<h3 className="text-[16px] font-medium tracking-[-0.01em]">{b.title}</h3>
|
||||
<p className="mt-1.5 text-fg-mute text-[13.5px] leading-[1.5]">{b.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ name }) {
|
||||
const p = { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none",
|
||||
stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
|
||||
if (name === "lightning") return <svg {...p}><path d="M11 2 4 11h5l-1 7 7-9h-5l1-7Z"/></svg>;
|
||||
if (name === "gift") return <svg {...p}><rect x="3" y="7.5" width="14" height="10"/><path d="M3 11h14M10 7.5V18M7 7.5a2 2 0 1 1 3-2.5 2 2 0 1 1 3 2.5"/></svg>;
|
||||
if (name === "chat") return <svg {...p}><path d="M3.5 11.5a6 6 0 1 1 3.4 5.4L3 18l1.1-3.9a6 6 0 0 1-.6-2.6Z"/></svg>;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Arrow } from "../../lib/primitives.jsx";
|
||||
|
||||
const ROLES = [
|
||||
{ value: "smb", label: "Small business owner", hint: "I run a shop, salon, studio, café…" },
|
||||
{ value: "freelancer", label: "Freelancer / agency", hint: "I build tools for clients" },
|
||||
{ value: "ideaperson", label: "I just have an idea", hint: "First-time builder, no code" },
|
||||
];
|
||||
|
||||
const SOURCES = ["Reddit", "Twitter / X", "TikTok", "YouTube", "A friend", "Google", "Something else"];
|
||||
|
||||
export default function BetaForm({ form, setForm, submitting, onSubmit }) {
|
||||
const update = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
const valid = /\S+@\S+\.\S+/.test(form.email) && form.build.trim().length > 4;
|
||||
|
||||
const handle = (e) => {
|
||||
e.preventDefault();
|
||||
if (!valid || submitting) return;
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handle} noValidate
|
||||
className="relative flex flex-col gap-7 py-9 px-5 sm:px-11 rounded-[22px] border border-hairline backdrop-blur-xl"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))",
|
||||
boxShadow: "0 30px 80px -20px oklch(0 0 0 / 0.6)",
|
||||
}}>
|
||||
<span aria-hidden="true" className="absolute top-0 left-0 right-0 h-px opacity-60"
|
||||
style={{ background: "linear-gradient(90deg, transparent, var(--c-accent), transparent)" }} />
|
||||
|
||||
<Field num="01" title="What's your email?" hint="So we can send you the invite when it's your turn.">
|
||||
<input
|
||||
type="email" required
|
||||
className="f-input" placeholder="you@somewhere.com" autoComplete="email"
|
||||
value={form.email} onChange={(e) => update("email", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field num="02" title="What should we call you?" hint="Optional, but nice to know.">
|
||||
<input
|
||||
type="text" className="f-input" placeholder="First name or handle" autoComplete="given-name"
|
||||
value={form.name} onChange={(e) => update("name", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field num="03" title="What's the first thing you want to build?"
|
||||
hint="Free-form. The vibe matters more than the spec." required>
|
||||
<div className="rounded-xl border border-hairline overflow-hidden transition-all"
|
||||
style={{ background: "oklch(0.16 0.008 60 / 0.8)" }}>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="A booking site for my dog grooming business with reminders, payments and a wait list…"
|
||||
className="w-full border-0 bg-transparent text-fg text-[16px] leading-[1.5] py-3.5 px-4 pb-2.5 outline-none resize-y min-h-[110px] placeholder:text-fg-faint"
|
||||
value={form.build} onChange={(e) => update("build", e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-3.5 py-2.5 border-t border-hairline font-mono text-[11px] text-fg-faint tracking-[0.02em]">
|
||||
<span className="text-accent">{form.build.length > 0 ? `${form.build.length} chars` : "go wild"}</span>
|
||||
<span>⌘ + Enter to submit the form</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field num="04" title="Which one are you?">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{ROLES.map((r) => (
|
||||
<button key={r.value} type="button"
|
||||
className={[
|
||||
"relative text-left py-3.5 pr-12 pl-4 rounded-xl border flex flex-col gap-0.5 transition-all",
|
||||
form.role === r.value
|
||||
? "border-accent bg-[oklch(0.20_0.04_35/0.4)] shadow-[0_0_0_3px_oklch(0.74_0.175_35/0.1)]"
|
||||
: "border-hairline hover:border-hairline-2",
|
||||
].join(" ")}
|
||||
style={{ background: form.role !== r.value ? "oklch(0.16 0.008 60 / 0.6)" : undefined }}
|
||||
onClick={() => update("role", r.value)}
|
||||
>
|
||||
<span className="text-[15px] font-medium text-fg">{r.label}</span>
|
||||
<span className="text-[13px] text-fg-mute">{r.hint}</span>
|
||||
<span className={[
|
||||
"absolute top-1/2 right-4 -translate-y-1/2 w-5 h-5 rounded-full grid place-items-center transition-all",
|
||||
form.role === r.value
|
||||
? "bg-accent border-accent text-accent-fg"
|
||||
: "border-[1.5px] border-hairline-2 bg-transparent",
|
||||
].join(" ")}>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"
|
||||
className={form.role === r.value ? "opacity-100" : "opacity-0"}>
|
||||
<path d="M3 7.2 5.8 10 11 4.2" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field num="05" title="How'd you hear about us?" hint="Optional. Helps us know what's working.">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SOURCES.map((s) => (
|
||||
<button key={s} type="button"
|
||||
onClick={() => update("source", form.source === s ? "" : s)}
|
||||
className={[
|
||||
"px-3.5 py-2 rounded-full border text-[13px] transition-colors",
|
||||
form.source === s
|
||||
? "border-accent bg-[oklch(0.20_0.04_35/0.4)] text-fg"
|
||||
: "border-hairline bg-[oklch(0.16_0.008_60/0.6)] text-fg-dim hover:border-hairline-2 hover:text-fg",
|
||||
].join(" ")}
|
||||
>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col items-center gap-3.5 mt-2">
|
||||
<button type="submit" disabled={!valid || submitting}
|
||||
className="btn btn-primary w-full max-w-[320px] h-14 text-base">
|
||||
{submitting ? (<><Spinner /> Sending…</>) : (<>Request my invite <Arrow /></>)}
|
||||
</button>
|
||||
<p className="font-mono text-[11px] text-fg-faint tracking-[0.03em] text-center text-balance">
|
||||
No credit card · No spam, just one email when you're in · Unsubscribe anytime
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Component-level CSS for the field inputs (Tailwind doesn't cover the
|
||||
custom focus glow shadow cleanly). */}
|
||||
<style>{`
|
||||
.f-input {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 14px 16px;
|
||||
background: oklch(0.16 0.008 60 / 0.8);
|
||||
border: 1px solid var(--c-hairline);
|
||||
border-radius: 12px;
|
||||
color: var(--c-fg);
|
||||
font: 16px/1.5 'Geist', system-ui, sans-serif;
|
||||
outline: none;
|
||||
transition: border-color .15s, background .15s, box-shadow .15s;
|
||||
}
|
||||
.f-input::placeholder { color: var(--c-fg-faint); }
|
||||
.f-input:focus,
|
||||
form > div > div:focus-within {
|
||||
border-color: oklch(0.74 0.175 35 / 0.65);
|
||||
background: oklch(0.18 0.009 60 / 0.95);
|
||||
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.15), 0 0 30px -10px var(--c-accent-glow);
|
||||
}
|
||||
`}</style>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ num, title, hint, required, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<span className="font-mono text-[11px] tracking-[0.1em] text-fg-faint px-2 py-1 border border-hairline rounded-md shrink-0 mt-0.5">
|
||||
{num}{required && <em className="not-italic text-accent ml-px">*</em>}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-[17px] font-medium text-fg tracking-[-0.01em]">{title}</div>
|
||||
{hint && <div className="mt-0.5 text-[13px] text-fg-mute">{hint}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<span className="w-4 h-4 rounded-full border-2 inline-block"
|
||||
style={{
|
||||
borderColor: "oklch(0 0 0 / 0.2)",
|
||||
borderTopColor: "var(--c-accent-fg)",
|
||||
animation: "spin .9s linear infinite",
|
||||
}} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Eyebrow } from "../../lib/primitives.jsx";
|
||||
|
||||
export default function Confirmed({ form, queuePos }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const ref = useMemo(() => {
|
||||
const seed = form.email || form.name || "anon";
|
||||
let h = 5;
|
||||
for (const c of seed) h = (h * 33 + c.charCodeAt(0)) >>> 0;
|
||||
return "v-" + h.toString(36).slice(0, 6);
|
||||
}, [form.email, form.name]);
|
||||
|
||||
const link = typeof window !== "undefined"
|
||||
? `${window.location.origin}/join?ref=${ref}`
|
||||
: `vibn.app/join?ref=${ref}`;
|
||||
|
||||
const copyLink = () => {
|
||||
try { navigator.clipboard.writeText(link); } catch (e) { /* noop */ }
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
};
|
||||
|
||||
const pct = Math.max(2, Math.min(98, 100 - (queuePos - 2100) / 9));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-7">
|
||||
<div className="text-center">
|
||||
<div className="inline-grid place-items-center w-16 h-16 rounded-full mb-4 text-ok"
|
||||
style={{
|
||||
background: "oklch(0.78 0.16 155 / 0.1)",
|
||||
border: "1px solid oklch(0.78 0.16 155 / 0.4)",
|
||||
boxShadow: "0 0 40px oklch(0.78 0.16 155 / 0.3)",
|
||||
}}>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<circle cx="16" cy="16" r="15" stroke="currentColor" strokeWidth="1.5" opacity=".25"/>
|
||||
<path d="M10 16.5 14.5 21 22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<Eyebrow>You're on the list</Eyebrow>
|
||||
<h1 className="mt-3.5 font-medium tracking-[-0.03em] leading-none text-[clamp(40px,6.4vw,80px)] text-balance">
|
||||
{form.name
|
||||
? <>Welcome, <em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>{form.name}</em>.</>
|
||||
: <>You're <em className="not-italic text-accent" style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>in line</em>.</>}
|
||||
</h1>
|
||||
<p className="mt-5 text-[clamp(16px,1.6vw,19px)] text-fg-dim text-balance">
|
||||
We got your invite request — keep an eye on{" "}
|
||||
<b className="font-mono font-medium text-fg">{form.email}</b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Queue card */}
|
||||
<div className="p-7 rounded-[18px] border border-hairline"
|
||||
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))" }}>
|
||||
<div className="flex justify-between items-end gap-3.5 mb-6">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.1em] text-fg-faint">your spot in line</div>
|
||||
<div className="mt-2 font-mono font-medium text-accent tracking-[-0.04em] leading-none text-[clamp(48px,7vw,76px)]"
|
||||
style={{ textShadow: "0 0 40px var(--c-accent-glow)" }}>
|
||||
#{queuePos.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-[26px] font-medium text-fg leading-none">
|
||||
50<small className="text-fg-mute text-[13px] font-normal ml-0.5">/wk</small>
|
||||
</div>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.1em] text-fg-faint">letting in</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-1.5 rounded-full mb-6 mt-9" style={{ background: "oklch(0.22 0.01 60)" }}>
|
||||
<div className="absolute left-0 top-0 bottom-0 rounded-full transition-[width] duration-700"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: "linear-gradient(90deg, oklch(0.65 0.15 35), var(--c-accent))",
|
||||
boxShadow: "0 0 12px var(--c-accent-glow)",
|
||||
}} />
|
||||
<div className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full"
|
||||
style={{
|
||||
left: `${pct}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "var(--c-accent)",
|
||||
boxShadow: "0 0 0 3px var(--c-bg), 0 0 18px var(--c-accent-glow)",
|
||||
}}>
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 -translate-y-2 font-mono text-[11px] tracking-[0.04em] text-accent whitespace-nowrap">
|
||||
You
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-[12px] text-fg-mute tracking-[0.02em]">
|
||||
You should hear from us in ~<b className="text-fg font-medium">{Math.ceil((queuePos - 50) / 50)} weeks</b>. Don't want to wait?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refer card */}
|
||||
<div className="p-7 rounded-[18px] border border-hairline"
|
||||
style={{ background: "linear-gradient(180deg, oklch(0.20 0.009 60 / 0.6), oklch(0.17 0.008 60 / 0.6))" }}>
|
||||
<Eyebrow>Skip the line</Eyebrow>
|
||||
<h3 className="mt-3 text-[22px] font-medium tracking-[-0.018em]">Send 3 friends — jump to the front.</h3>
|
||||
<p className="mt-1.5 text-fg-mute text-[14px]">Each friend who joins via your link bumps you up 500 spots.</p>
|
||||
|
||||
<div className="mt-4 flex gap-2 items-stretch">
|
||||
<div className="flex-1 px-3.5 py-3 rounded-[10px] border border-hairline text-[13px] tracking-[0.01em] text-fg-dim flex items-center overflow-hidden whitespace-nowrap"
|
||||
style={{ background: "oklch(0.16 0.008 60)" }}>
|
||||
<span className="font-mono">
|
||||
<span className="text-fg-faint">vibn.app/join?ref=</span>
|
||||
<b className="text-accent font-medium">{ref}</b>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={copyLink} className="btn btn-ghost h-auto px-[18px]">
|
||||
{copied ? "Copied!" : "Copy link"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 flex flex-wrap gap-2">
|
||||
{[["x", "Share on X"], ["reddit", "Post to Reddit"], ["mail", "Email a friend"]].map(([k, label]) => (
|
||||
<a key={k} href="#"
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-full border border-hairline text-[13px] text-fg-dim transition-colors hover:text-fg hover:border-hairline-2"
|
||||
style={{ background: "oklch(0.16 0.008 60 / 0.5)" }}>
|
||||
<ShareIcon name={k} /> {label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.build && (
|
||||
<div className="p-6 px-7 rounded-2xl border border-dashed border-hairline">
|
||||
<Eyebrow>What we'll help you build first</Eyebrow>
|
||||
<div className="mt-3 italic text-fg text-[18px] tracking-[-0.005em] leading-[1.4] text-balance">
|
||||
"{form.build}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareIcon({ name }) {
|
||||
const p = { width: 14, height: 14, viewBox: "0 0 16 16", fill: "currentColor" };
|
||||
if (name === "x") return <svg {...p}><path d="M9.2 7 13.7 2h-1.4L8.6 6.3 5.6 2H2l4.7 6.8L2 14h1.4l4.1-4.7 3.3 4.7H14L9.2 7Z"/></svg>;
|
||||
if (name === "reddit") return <svg {...p}><circle cx="8" cy="9" r="6"/></svg>;
|
||||
if (name === "mail") return (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="3.5" width="12" height="9" rx="1.5"/>
|
||||
<path d="m3 5 5 3.8L13 5"/>
|
||||
</svg>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
73
design-templates/VIBN (2)/vibn-app/src/lib/primitives.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// Shared primitives — used across homepage and beta page.
|
||||
|
||||
export function LogoMark({ size = 26, blink = true }) {
|
||||
return (
|
||||
<span className="logo-mark" style={{ width: size, height: size }}>
|
||||
<svg
|
||||
viewBox="0 0 36 32"
|
||||
width="74%" height="74%"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
|
||||
<rect
|
||||
x="22.5" y="23" width="9.5" height="3.8" rx="0.7"
|
||||
className={blink ? "logo-caret" : ""}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Logo({ size = 26, href = "/" }) {
|
||||
return (
|
||||
<a href={href} className="inline-flex items-center gap-[9px] font-semibold text-[17px] tracking-[-0.02em]">
|
||||
<LogoMark size={size} />
|
||||
<span>vibn</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function Arrow({ size = 14 }) {
|
||||
return (
|
||||
<svg className="arrow" width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Eyebrow({ children }) {
|
||||
return <div className="eyebrow">{children}</div>;
|
||||
}
|
||||
|
||||
export function Glow({ color = "var(--c-accent-glow)", size = 700, opacity = 1, style = {} }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
width: size, height: size,
|
||||
background: `radial-gradient(circle at center, ${color} 0%, transparent 62%)`,
|
||||
filter: "blur(20px)",
|
||||
opacity,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrustStrip({ items }) {
|
||||
return (
|
||||
<div className="font-mono flex flex-wrap gap-x-[18px] gap-y-2 text-[12px] text-fg-mute tracking-[0.04em]">
|
||||
{items.map((item, i) => (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && <span className="text-fg-faint">·</span>}
|
||||
<span>{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
design-templates/VIBN (2)/vibn-app/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||