Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e92da55f2 | ||
|
|
f69ce33dd0 | ||
|
|
0cff3902b2 | ||
|
|
6947113027 | ||
|
|
913d7a8832 | ||
|
|
e314819629 | ||
|
|
b3fc7d2002 | ||
|
|
ab485703f2 | ||
|
|
1634763180 |
@@ -44,3 +44,5 @@ test-results/
|
||||
.idea/
|
||||
.vscode/
|
||||
e2e/fixtures/large-exports/
|
||||
.next/
|
||||
next-env.d.ts
|
||||
|
||||
@@ -10,7 +10,7 @@ Non-technical WordPress site owners describe what they want; Wursor makes it hap
|
||||
|
||||
```
|
||||
api/ Node.js + TypeScript API server (empty until Phase 0 gate)
|
||||
web/ React + TypeScript frontend (empty until Phase 0 gate)
|
||||
web/ Next.js (App Router) + TypeScript frontend (ADR 0018): app/ routes, src/product (app UI components), src/marketing, src/ui, src/styles (tokens)
|
||||
plugin/ WordPress plugin (PHP) — empty until Phase 0 gate
|
||||
infrastructure/ Docker images, warm pool, GC, deploy scripts
|
||||
e2e/ Playwright + e2e/golden/ harness
|
||||
@@ -46,8 +46,12 @@ The `.agents/skills/` directory is a family of rigor skills modeled on Cursor's
|
||||
|
||||
## Stack notes
|
||||
|
||||
- Backend: Node.js + TypeScript, Express/Fastify, PostgreSQL (Wursor data), Redis (SSE/queue)
|
||||
- Frontend: React + TypeScript, Vite
|
||||
- Backend: Node.js + TypeScript, Fastify (Wursor API), PostgreSQL (Wursor data), Redis (SSE/queue)
|
||||
- Frontend: Next.js (App Router) + React + TypeScript. Server layer holds tokens; browser gets a session cookie only (ADR 0018). Existing Vite shell is being migrated.
|
||||
- Sign-in: emailed 8-character code, emailed link and passkeys (ADR 0019)
|
||||
- Cloud sites: provisioned on WeOwn Cloud behind the platform interface (ADR 0020)
|
||||
- Hosting: DigitalOcean for Wursor and WeOwn Cloud, one VPC per plane, Terraform (ADR 0022)
|
||||
- Direction: see PRD.md (Headless, revision 4) and IMPLEMENTATION.md section 0
|
||||
- Sandboxes: Docker on VPS, pre-baked WordPress image, overlayfs layers, media proxied (not copied)
|
||||
- Plugin: standard WordPress PHP plugin, REST API + token auth
|
||||
- Tests: vitest (api, web), phpunit (plugin), Playwright (e2e)
|
||||
|
||||
@@ -8,6 +8,9 @@ The project is pre-1.0. Versions below `1.0.0` are internal milestones, not publ
|
||||
|
||||
### Added
|
||||
|
||||
- Web: working site search on the marketing site. Cmd+K (Ctrl+K on Windows and Linux), a Search button in the navigation and the Docs search field open a command palette over every page. It searches the pages, docs topics and any docs article that has a page, with arrow keys and Enter to choose and Escape to close. Docs topics now live in `content.ts` so the page and the search share one source.
|
||||
- Web: light/dark theme toggle in the marketing navigation. It follows the device until the visitor chooses, remembers the choice in `localStorage`, and sets the theme before first paint so there is no flash. Sun and moon icons added to `public/icons` (hand-drawn, not Figma exports).
|
||||
- Web: the five other marketing pages (Product, How it works, Trust, Pricing, Docs), a marketing e2e check (`pnpm --filter @wursor/e2e test:marketing`), and product components for setup, activity and search. Web: Next.js app with design tokens (light and dark, AA contrast), the marketing Home page (desktop and phone), and a product component library built at design size (conversation, workspace, board, compare and approval) that the marketing mockups use and the app will reuse (ADR 0023). ADRs 0018 to 0022 (0022: hosting on DigitalOcean): Next.js web app (partly supersedes ADR 0011), sign-in by code, link and passkeys, Cloud provisioning on WeOwn Cloud, and keeping the "never sees a diff" rule wording. PRD, IMPLEMENTATION and AGENTS.md updated to match.
|
||||
- `docs/prd-assets/headless-architecture.png`, the architecture diagram used in the PRD.
|
||||
- Design reference: Figma file "Wursor - Experiment" (Design system, Product Design, Marketing site and Brand Guideline pages).
|
||||
- **Monorepo workspace** — `pnpm` workspace (`api`, `web`, `e2e`), root `package.json` / `pnpm-workspace.yaml` / `tsconfig.base.json`, and a PHP `plugin` package (`composer.json`). Empty packages by design (see [ADR 8](docs/decisions/0008-empty-packages-not-stubs.md)).
|
||||
|
||||
@@ -48,19 +48,19 @@
|
||||
|
||||
### 0.2 Decisions to ratify before building on them
|
||||
|
||||
These conflict with existing ADRs or code and need a written decision (add ADRs 0018 onward) before work starts.
|
||||
These conflicted with existing ADRs or code. All four were decided on October 12, 2026 and recorded as ADRs 0018 to 0021.
|
||||
|
||||
1. **Next.js versus React + Vite.** [ADR 0011](docs/decisions/0011-fastify-react-stack.md) chose Fastify and React. Headless rev 4 needs a server layer for token handling. Options: adopt Next.js, or keep Vite and add a small backend-for-frontend inside the API. Decide before more of `web/` is built.
|
||||
2. **Sign-in method.** The design uses an emailed 8-character code. HL-03 still says passkey or emailed link (PRD D-HL-13).
|
||||
3. **Who provisions Cloud sites.** WeOwn Cloud infrastructure or a partner behind the same interface (PRD D-HL-7). Do not build the hosting plane until this is decided.
|
||||
4. **"The user never sees a diff" (AGENTS.md rule 3).** The PRD keeps technical detail one tap away in an expandable section for owners who want it. Confirm the wording of the rule.
|
||||
1. **Decided: Next.js ([ADR 0018](docs/decisions/0018-nextjs-web-app.md)).** Was: Next.js versus React + Vite. [ADR 0011](docs/decisions/0011-fastify-react-stack.md) chose Fastify and React. Headless rev 4 needs a server layer for token handling. Options: adopt Next.js, or keep Vite and add a small backend-for-frontend inside the API. Decide before more of `web/` is built.
|
||||
2. **Decided: code, link and passkeys ([ADR 0019](docs/decisions/0019-sign-in-methods.md)).** Was: sign-in method. The design uses an emailed 8-character code. HL-03 still says passkey or emailed link (PRD D-HL-13).
|
||||
3. **Decided: WeOwn Cloud ([ADR 0020](docs/decisions/0020-weown-cloud-provisioning.md)).** Was: who provisions Cloud sites. WeOwn Cloud infrastructure or a partner behind the same interface (PRD D-HL-7). Do not build the hosting plane until this is decided.
|
||||
4. **Decided: leave the wording ([ADR 0021](docs/decisions/0021-keep-no-diff-rule-wording.md)).** Was: "The user never sees a diff" (AGENTS.md rule 3). The PRD keeps technical detail one tap away in an expandable section for owners who want it. Confirm the wording of the rule.
|
||||
|
||||
### 0.3 How the existing sprints map
|
||||
|
||||
| Existing work | Status under the new direction |
|
||||
| :--- | :--- |
|
||||
| Phase 0 spikes and golden harness | Keep as they are. |
|
||||
| Sprint 1, web app scaffold and sandbox orchestration | Keep the sandbox work. Revisit the scaffold after decision 0.2.1. |
|
||||
| Sprint 1, web app scaffold and sandbox orchestration | Keep the sandbox work. Move the web scaffold to Next.js (ADR 0018). |
|
||||
| Sprint 2, WordPress plugin connector | Keep. Extend `site-info` to report the access level (Full access or Content only) and the operations the site supports. Wrap it behind the platform interface. |
|
||||
| Sprint 3, agent orchestrator and playbooks | Keep. Add the pre-run access check so a task that needs more access stops in the composer before any run starts. |
|
||||
| Sprints 4 and 5, content and design playbooks | Keep. Design playbooks gain the new-site path: a five-question brief and three design directions. |
|
||||
@@ -76,7 +76,7 @@ These conflict with existing ADRs or code and need a written decision (add ADRs
|
||||
| C | Identity: sign-up, sign-in, verify states, workspace naming, then Home. | PX-01, PX-02 |
|
||||
| D | Evidence-bound approval and the Publish entry point on desktop and mobile. | PX-03, SH-10 |
|
||||
| E | New-site path on Cloud: brief, three design directions, build on a safe copy, preview, approval. | SH-03 |
|
||||
| F | Hosting plane: provisioning, temporary address, backups, domain connection. Blocked on 0.2.3. | SH-04, SH-12, SH-14 |
|
||||
| F | Hosting plane: provisioning, temporary address, backups, domain connection. Unblocked by ADR 0020; staffing readiness still applies. | SH-04, SH-12, SH-14 |
|
||||
| G | Moves in both directions as releases, with dry run, page-by-page comparison and rollback. | SH-08 to SH-11 |
|
||||
| H | Surfaces: Home, Sites, Activity, Board with calendar, Settings, command palette, system states. | PX-04 to PX-08 |
|
||||
| I | Mobile and dark parity audits, accessibility scan, touch-target and contrast checks. | PX-09 to PX-12 |
|
||||
|
||||
@@ -653,7 +653,7 @@ PKCE is a standard, secure sign-in method for browser apps. It protects the code
|
||||
|
||||
1. The person selects Sign in. The Next.js server generates a code verifier and challenge, a state value and a nonce, stores them in a short-lived encrypted cookie and redirects the browser to the Wursor identity service.
|
||||
|
||||
2. The identity service authenticates the person with a passkey or an emailed link. In V1 it also accepts single sign-on from a company identity provider.
|
||||
2. The identity service authenticates the person with an emailed 8-character code, an emailed link or a passkey (ADR 0019). In V1 it also accepts single sign-on from a company identity provider.
|
||||
|
||||
3. The identity service redirects back with a code. The Next.js server validates the state, exchanges the code and verifier for tokens, validates the identity token and creates a session record on the server.
|
||||
|
||||
@@ -1062,7 +1062,7 @@ These requirements apply only to the Headless pattern in this revision. They rep
|
||||
|---|---|---|---|
|
||||
| HL-01 | The frontend is a Next.js application (App Router, TypeScript) deployed on its own domain, separate from every target platform. | MVP | The full core journey (create task, watch run, preview, review, approve) works in an environment where no WordPress site is part of the application stack. |
|
||||
| HL-02 | Wursor owns its control plane. Users, organizations, projects, tasks, approvals, policies and the audit log are stored in Wursor's own database (PostgreSQL) and served by the Wursor API. No product data is stored inside a customer's site. | MVP | Removing the connector plugin from a customer site deletes no Wursor records. A database restore drill recovers the control plane with no access to any customer site. |
|
||||
| HL-03 | Identity is Wursor-owned. People sign in with passkeys or an emailed link. The Next.js server and the identity service use the authorization code flow with PKCE. Single sign-on (OIDC or SAML) is added in V1. | MVP | Sign-in works with no WordPress account. A test that submits a wrong code verifier is rejected, and a replayed authorization code is rejected. |
|
||||
| HL-03 | Identity is Wursor-owned. People sign in with an emailed 8-character code, an emailed link or a passkey (ADR 0019). The Next.js server and the identity service use the authorization code flow with PKCE. Single sign-on (OIDC or SAML) is added in V1. | MVP | Sign-in works with no WordPress account. A test that submits a wrong code verifier is rejected, and a replayed authorization code is rejected. |
|
||||
| HL-04 | A backend for frontend holds all access and refresh tokens on the server. The browser receives only an HttpOnly session cookie and, while watching a run, a stream token. | MVP | Scans of browser storage, page source and responses during an end-to-end run find no access or refresh token. |
|
||||
| HL-05 | The API is contract-first. An OpenAPI description in the repository is the source of truth, and the typed client, request validators and mock server are generated from it. | MVP | Contract tests call the real Wursor API and Conductor with generated requests and validate responses. A breaking change without a version bump fails CI. |
|
||||
| HL-06 | The browser opens event streams directly to the Conductor with a two-minute, run-scoped stream token sent in an Authorization header, and resumes after a drop from the last event number. | MVP | Across 50 forced disconnects, no event is lost or duplicated, and tokens never appear in access logs. |
|
||||
@@ -1130,13 +1130,13 @@ These requirements apply only to the Headless pattern in this revision. They rep
|
||||
| D-HL-4 | How the connector plugin is distributed: WordPress.org directory, direct download, or both. | M1 | Product |
|
||||
| D-HL-5 | Which backup method the WordPress target relies on first, and whether a fleet connector supplies it. | Phase 0 | Product |
|
||||
| D-HL-6 | Whether single sign-on arrives at V1 or earlier for design-partner customers. | M2 | Product |
|
||||
| D-HL-7 | How Cloud sites are provisioned for the first release: on WeOwn Cloud infrastructure, or through a partner (WordPress.com, InstaWP, a managed host API) behind the same interface. Recommended: WeOwn Cloud only if on-call, patching and abuse handling are staffed from the first customer; otherwise a partner first. | Phase 0 | CTO |
|
||||
| D-HL-7 | **Decided (ADR 0020, October 12, 2026): WeOwn Cloud.** Cloud sites are provisioned on WeOwn Cloud infrastructure behind the same interface. The condition in section 5.3 still applies: on-call, patching and abuse handling staffed from the first customer. | Phase 0 | CTO |
|
||||
| D-HL-8 | Plans and prices: hosted and connect-only plans, work allowances, backup retention beyond 30 days, and what a free trial includes. Draft numbers in the design are placeholders. | M2 | Product and finance |
|
||||
| D-HL-9 | Whether WordPress.com sign-in is in the MVP or the connector plugin only. Recommended: both, because WordPress.com is the shortest path for non-technical owners; verify capabilities in Phase 0. | Phase 0 | Product |
|
||||
| D-HL-10 | Retention after a move out: 30 days live and read-only, then 30 days archived (proposed). | M2 | Product and legal |
|
||||
| D-HL-11 | Temporary address scheme and search-engine policy for Cloud sites before a domain is connected. | M2 | Engineering lead |
|
||||
| D-HL-12 | Acceptable-use policy, abuse handling and availability target for Cloud sites. | M2 | Operations and legal |
|
||||
| D-HL-13 | Whether sign-in uses an emailed 8-character code (as designed), an emailed link (as HL-03 says), or both. Recommended: the code, with the link as a fallback on mobile, and update HL-03. | M0 | Product and engineering |
|
||||
| D-HL-13 | **Decided (ADR 0019, October 12, 2026):** support the emailed 8-character code, the emailed link and passkeys. Build order: code, link, passkeys. HL-03 updated. | M0 | Product and engineering |
|
||||
| D-HL-14 | Cloud storage allowance per plan, and whether hosting is included in every plan. The billing screens show sample numbers only. | M2 | Product and finance |
|
||||
| D-HL-15 | Whether the theme follows the device only, or also offers a manual light and dark switch in Settings. | M1 | Design |
|
||||
| D-HL-16 | Whether the marketing pages move onto the product prototype so that landing page to onboarding can be walked as one flow, or stay separate and link by address. | Now | Design |
|
||||
@@ -1191,7 +1191,7 @@ This section records what the screen-level design contains, so that engineering,
|
||||
|
||||
| **Gap** | **What is missing or different** | **Where decided** |
|
||||
|---|---|---|
|
||||
| Sign-in method | The design uses an emailed 8-character code. HL-03 says passkey or emailed link. | D-HL-13 |
|
||||
| Sign-in method | Resolved by ADR 0019: code, link and passkeys are all supported. The designed screens cover the code; link and passkey screens still need design. | D-HL-13 |
|
||||
| Calendar extras | Not yet designed: drag to reschedule, an empty month, and a day with more than three changes. | PX-05 |
|
||||
| Mobile preview toolbar | Tag and device toggles are hidden on mobile to make room for Publish. | PX-09 |
|
||||
| Touch size | Icon buttons are 36 px, below the 44 px target. | PX-09 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 11. Fastify is the API server; React + Vite is the web shell
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Status:** Partially superseded by [ADR 0018](0018-nextjs-web-app.md) (the React + Vite web shell). Fastify for `api/` stands.
|
||||
- **Date:** 2026-08-15
|
||||
|
||||
## Context
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 18. The web app is Next.js (App Router); Fastify stays as the Wursor API
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-10-12
|
||||
- **Supersedes:** the web half of [ADR 0011](0011-fastify-react-stack.md). The Fastify half stands.
|
||||
|
||||
## Context
|
||||
|
||||
PRD Headless revision 4 (HL-01, HL-04, HL-07, HL-08, HL-15) requires a web app with a server layer that holds access and refresh tokens, so the browser only ever sees an HttpOnly session cookie and, while watching a run, a two-minute stream token. It also requires dynamically rendered authenticated pages that are never shared-cached, CSRF protection on state-changing requests, static marketing pages, and an installable PWA. [ADR 0011](0011-fastify-react-stack.md) chose React + Vite, which is a client-only shell with no server layer. `web/` currently holds the Sprint 1 and Sprint 2 components and hooks (signup, chat, preview, approve, site connector).
|
||||
|
||||
## Decision
|
||||
|
||||
Build the web app on **Next.js (App Router, TypeScript)**. Keep **Fastify** for the Wursor API in `api/`. Keep vitest and Testing Library for unit and component tests, and Playwright for end to end.
|
||||
|
||||
The Next.js server is the backend for frontend: it owns the session, holds tokens, validates input against the OpenAPI contract and calls the Wursor API on the person's behalf. It makes no authorization decisions; the Wursor API rechecks every permission. Run streams go directly from the browser to the Conductor, never through Next.js.
|
||||
|
||||
Existing `web/` React components and hooks are kept and moved under the Next.js app. The Vite shell is retired once the Next.js app serves the same screens.
|
||||
|
||||
### Options considered
|
||||
|
||||
- **Next.js (chosen).** Matches the PRD as written. Server Components, server actions and route handlers give the token-holding layer, per-request rendering and static marketing pages in one framework, and the team already writes React and TypeScript.
|
||||
- **Keep Vite and add a backend for frontend inside the Fastify API.** Smallest change to the repo, and one fewer framework. It puts session and cookie handling in the API, which the PRD wants kept separate from the control plane, and leaves marketing pages, SEO and PWA scope to be rebuilt by hand.
|
||||
- **Vite plus a separate small BFF service.** Clean separation, but one more deployable for a small team, which the PRD's risk register already flags.
|
||||
|
||||
### Rejected
|
||||
|
||||
- Keeping Vite alone: it cannot satisfy HL-04 (no tokens in the browser).
|
||||
- A separate BFF service: extra deployable with no benefit over the Next.js server layer.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `web/` becomes a Next.js app. The migration is step C and H in [IMPLEMENTATION.md](../../IMPLEMENTATION.md) section 0.4; existing Sprint 1 and 2 screens move first, and the in-memory session token flow from Sprint 1 is replaced by server-held sessions.
|
||||
- `web/package.json` changes its dev, build and start scripts. Tests stay on vitest.
|
||||
- The 90% coverage floor for `web/` continues to apply. Server Components and server actions are tested at the unit level where possible and covered by Playwright otherwise.
|
||||
- Hosting for the Next.js app must support server rendering and streaming responses (PRD section 21).
|
||||
- No more Vite-specific code is added to `web/` from this point.
|
||||
@@ -0,0 +1,37 @@
|
||||
# 19. Sign-in supports an emailed code, an emailed link and passkeys
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-10-12
|
||||
|
||||
## Context
|
||||
|
||||
The PRD text (HL-03) says people sign in with a passkey or an emailed link. The finished design (PX-01, PX-02) uses an emailed 8-character code with separate empty, filled, wrong, expired and resent states, and Google sign-in on both paths. Open decision D-HL-13 asked which to use.
|
||||
|
||||
## Decision
|
||||
|
||||
Support all three methods, behind one identity service and one authorization code flow with PKCE:
|
||||
|
||||
1. **Emailed 8-character code.** The designed default. Single use, expires, rate limited, ten wrong attempts lock the attempt (PX-02).
|
||||
2. **Emailed link.** The same one-time secret delivered as a link. Offered as the fallback, especially on mobile where opening the mail app and typing a code is awkward.
|
||||
3. **Passkeys.** Offered after a first successful sign-in, and required for roles that approve once enrolled (ID-02).
|
||||
|
||||
Google sign-in stays as designed (PX-01). No method depends on a WordPress account.
|
||||
|
||||
Build order: code first because it matches the screens, then the link, then passkeys. Each method is complete and tested before the next starts.
|
||||
|
||||
### Options considered
|
||||
|
||||
- **All three (chosen).** Covers the designed screens, a mobile fallback and the stronger credential, at the cost of three flows to test and maintain.
|
||||
- **Code only.** Matches the design exactly and is the smallest build, but gives no phishing-resistant option.
|
||||
- **Passkey or link only, as HL-03 was written.** Contradicts the screens already designed.
|
||||
|
||||
### Rejected
|
||||
|
||||
- Passwords: not in the PRD and not wanted for non-technical owners.
|
||||
|
||||
## Consequences
|
||||
|
||||
- PRD HL-03 and D-HL-13 are updated to match; PRD 26.4 no longer lists the sign-in method as a gap.
|
||||
- Whether the identity service is bought or built (D-HL-1) is still open. The recommendation in the PRD is to buy a managed provider that supports all three; confirm that it supports an 8-character code with the designed states before choosing, or build that part.
|
||||
- The unknown-email state must never reveal whether an account exists (PX-01), for every method.
|
||||
- Tests must cover wrong, expired, reused and rate-limited codes, a replayed authorization code and a wrong PKCE verifier, for each method.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 20. Cloud sites are provisioned on WeOwn Cloud
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-10-12
|
||||
|
||||
## Context
|
||||
|
||||
PRD decision D-HL-7 asked whether Wursor Cloud sites are provisioned on WeOwn Cloud infrastructure or through a partner (WordPress.com, InstaWP, a managed host API) behind the same interface. The hosting plane (steps F and G in [IMPLEMENTATION.md](../../IMPLEMENTATION.md) section 0.4) was blocked on this answer. The PRD recommended WeOwn Cloud only if on-call, security patching and abuse handling are staffed from the first customer.
|
||||
|
||||
## Decision
|
||||
|
||||
Provision Cloud sites on **WeOwn Cloud**. The hosting plane is a separate environment from the execution plane and is reached only through the platform interface's `provision` operation (SH-16), so the choice is replaceable.
|
||||
|
||||
### Options considered
|
||||
|
||||
- **WeOwn Cloud (chosen).** Most control and the cleanest story for customers who care about owning their stack. Largest operational load.
|
||||
- **Partner provisioning first.** Fastest start and least operational risk, with less control over price and behavior. Not chosen.
|
||||
|
||||
### Rejected
|
||||
|
||||
- Partner provisioning for the first release: Wursor is working with WeOwn Cloud.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The hosting plane work (SH-04, SH-12, SH-14) is unblocked and can be planned. It is the largest new commitment, and the PRD's rollout plan puts hardening at about 46 weeks.
|
||||
- The staffing condition in the PRD still stands and is now an open readiness item, not a reason to delay the decision: on-call, patching to the 72-hour target, abuse handling, backups verified by restore tests and a support path must exist before the first Cloud customer (PRD 5.3 and risk "Hosting makes Wursor an operations business").
|
||||
- The `provision` operation stays behind the interface, so a partner backend can be added later without changing the product (SH-16, V2).
|
||||
- Cap the number of Cloud sites per plan during beta, as the PRD risk register proposes.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 21. Keep the "never sees a diff" rule in AGENTS.md as written
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-10-12
|
||||
|
||||
## Context
|
||||
|
||||
AGENTS.md hard rule 3 says the user never sees a diff, a terminal, a settings screen or an error log. PRD Headless revision 4 keeps technical detail (diffs, hashes, logs, traces) one tap away in an expandable section for owners who want it (section 16, RV-01). IMPLEMENTATION section 0.2 asked whether the rule's wording should change.
|
||||
|
||||
## Decision
|
||||
|
||||
Leave rule 3 as it is. It describes the default experience and the test for any change: if a change would put engineer-only UI in front of a non-technical owner by default, it is wrong. The optional, collapsed technical detail in the PRD is read as consistent with that default.
|
||||
|
||||
### Options considered
|
||||
|
||||
- **Leave the wording (chosen).**
|
||||
- Reword to "by default" and mention the expandable detail. Not chosen.
|
||||
|
||||
## Consequences
|
||||
|
||||
- AGENTS.md rule 3 is unchanged.
|
||||
- Reviewers apply the rule to the default view. The expandable technical section must never be the only way to complete a task, and must be collapsed unless the person opens it.
|
||||
@@ -0,0 +1,50 @@
|
||||
# 22. Wursor and WeOwn Cloud run on DigitalOcean
|
||||
|
||||
- **Status:** Accepted for the platform choice. The service-level picks below are recommendations to confirm in the infrastructure spike (see Consequences).
|
||||
- **Date:** 2026-10-12
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0020](0020-weown-cloud-provisioning.md) puts Cloud sites on WeOwn Cloud, and WeOwn uses DigitalOcean. Wursor itself will be hosted there too. The PRD describes three separate environments that must not share a network trust boundary: the control plane (Next.js app, Wursor API, Conductor, identity), the execution plane (sandboxes, verification workers, evidence store) and the hosting plane (Cloud sites). The existing infrastructure brief ([DEVOPS.md](../../infrastructure/DEVOPS.md)) assumes a single Docker VPS and a Vite static web build, and `infrastructure/terraform/` is empty.
|
||||
|
||||
## Decision
|
||||
|
||||
Host all three planes on **DigitalOcean**, each in its own VPC, defined as code with **Terraform** (DigitalOcean provider) in `infrastructure/terraform/`.
|
||||
|
||||
Recommended mapping:
|
||||
|
||||
| Need | DigitalOcean product | Why |
|
||||
|---|---|---|
|
||||
| Next.js app, Wursor API, Conductor | App Platform (containers) if its streaming and timeout limits fit the run stream; otherwise Droplets or DOKS | Managed TLS, deploys from the container registry, small ops load. |
|
||||
| Wursor database | Managed PostgreSQL with point-in-time recovery | PRD section 21 requires PITR and 15-minute backups, and a quarterly restore drill. |
|
||||
| Queue and cache | Managed Redis or Valkey | Event queue and short caches. |
|
||||
| Evidence store, site archives, backups | Spaces (S3-compatible) with versioning, private by default | Signed expiring links for evidence and archive downloads. |
|
||||
| Sandboxes (execution plane) | Droplets running Docker, in a separate VPC | Matches the existing `DockerodeClient`, warm pool and GC. |
|
||||
| Cloud sites (hosting plane) | Droplets (or DOKS) in a separate VPC, one isolated container per site, own database credentials and file space | PRD SH-14 and the Cloud isolation rows in section 18.1. |
|
||||
| Images | Container Registry | Base WordPress image and service images. |
|
||||
| DNS, domains, certificates | DigitalOcean DNS plus automated certificates (for example Caddy with on-demand TLS) for customer domains | SH-12 needs certificates issued as customers connect domains. |
|
||||
| Preview origin | Wildcard certificate on a separate registrable domain behind a load balancer | PRD requires previews on a different site from the app (SB-02). |
|
||||
|
||||
Network rules: the hosting plane has no inbound path to the Wursor API. Sandboxes have deny-by-default egress through a proxy (SB-03). Only the Conductor reaches the hosting plane, with a service token.
|
||||
|
||||
### Options considered
|
||||
|
||||
- **DigitalOcean (chosen).** WeOwn's existing provider, so no new vendor or contract, and the existing Docker and `dockerode` work carries over.
|
||||
- **Another cloud for Wursor, DigitalOcean only for Cloud sites.** More capability, but two clouds to run for a small team.
|
||||
|
||||
### Rejected
|
||||
|
||||
- Another cloud for the control plane: adds a second provider with no stated need.
|
||||
|
||||
## Consequences
|
||||
|
||||
- [DEVOPS.md](../../infrastructure/DEVOPS.md) is out of date: it describes React + Vite and a single VPS. Update it after the spike.
|
||||
- Confirm these before building on them, each as a short written result in `spikes/`:
|
||||
1. Whether App Platform supports the long-lived run stream (HTTP timeouts, resume after reconnect), or the Conductor needs Droplets or DOKS.
|
||||
2. The isolation level achievable for Cloud sites on Droplets. The PRD's open choice is container versus microVM. Do not assume a microVM runtime is available on Droplets; test it, and treat container hardening plus a cross-site isolation test in CI as the baseline.
|
||||
3. Customer-domain certificate issuance at the expected volume.
|
||||
4. Backup and restore: daily verified restores of Cloud sites, 30-day retention, a backup before every change (SH-04, SH-14).
|
||||
5. Cost per Cloud site and per sandbox, to inform plans and pricing (D-HL-8).
|
||||
- WeOwn operations staffing (on-call, patching to the 72-hour target, abuse handling) is a readiness item from ADR 0020, not something DigitalOcean removes.
|
||||
- Secrets live in the platform secret store and the Terraform state is stored in a private Spaces bucket with locking; never in the repo.
|
||||
- Terraform plans run in CI against a staging project. Nothing is applied to production from a developer machine.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 23. Product UI is built once at design size and reused; text contrast tokens meet AA
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-10-12
|
||||
|
||||
## Context
|
||||
|
||||
The marketing site shows the product (conversation, preview, board, approval) in four large mockups. The same screens become the app (PRD sections 15 and 26, PX-11: one component library, no one-off controls). Figma's generated code is absolutely positioned and uses Tailwind classes, and the mockups are drawn at several scales (the hero at 0.76, the Surfaces windows at 1.27, 1.08 and 1.39). Separately, two pairs of Figma tokens fail WCAG AA for small text, which PX-10 requires: secondary and tertiary text, and white text on the accent orange.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Build each product component once, at its design size (1x),** in `web/src/product/`, with plain CSS in `src/styles/product.css` that uses only the design tokens. No Tailwind. Marketing scales whole components with `Fit` (follows the container width) or `Zoom` (fixed ratio), in `src/ui/`. The app uses them unscaled.
|
||||
2. **Icons** are the exact Figma SVG exports in `public/icons`, drawn with a CSS mask so their colour comes from the theme token (`src/ui/Icon.tsx`). The brand mark and the Wursor avatar need separate light and dark files because they have two colours.
|
||||
3. **Sample content** is one fictional site, Harbor Bakery (`harbor-bakery.ts`), as in the design. Real previews replace it in the app.
|
||||
4. **Contrast.** `--color-text-secondary` and `--color-text-tertiary` are darker in light and lighter in dark than the Figma values, so every text pair is at least 4.5:1 (a test checks 24 pairs per theme). Chips and counters that carry text on the accent use a new `--color-accent-fill` (`#c24a08` in light) instead of `--color-accent-default` (`#e8590c`, white on it is 3.6:1). The dark link blue is lightened. The design file should adopt these values.
|
||||
5. **Claims.** Plan names, prices and allowances stay out of the marketing site until decided (PRD D-HL-8, PX-13): `PRICING_CONFIRMED` is false and the pricing section says so. FAQ answers other than the first are drafted from the PRD and flagged in `content.ts` for review.
|
||||
|
||||
### Options considered
|
||||
|
||||
- **One component set at 1x, scaled where needed (chosen).** One source of truth for the marketing mockups and the app.
|
||||
- **Convert Figma's generated code per mockup.** Fast, but each mockup becomes its own fixed-pixel copy, and the app would need a second build of every screen.
|
||||
- **Export the mockups as images.** Smallest, and not reusable. Rejected because the components are needed for the app.
|
||||
- **Keep Figma's contrast values.** Faithful to the file, but fails PX-10.
|
||||
|
||||
### Rejected
|
||||
|
||||
- Tailwind: not in the stack, and the Figma output depends on it only as a convenience.
|
||||
- Recolouring icons by editing the SVG files: they must stay exactly as exported.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The product components are a library for the app screens in `IMPLEMENTATION.md` step H, and need tests as they grow (about 90 exist).
|
||||
- Only the states that are designed exist so far. Not built: Composer states other than working, risk levels other than Everyday, check results other than passed, approval states other than ready, the phone device frame. The components say so where it applies.
|
||||
- `Fit` measures the container in the browser, so the mockups reflow once after load.
|
||||
- The design file should adopt the contrast values, and the dark link blue, so design and code agree.
|
||||
@@ -24,13 +24,19 @@ Each ADR is a single file following the [Nygard format](https://cognitect.com/bl
|
||||
| [0008](0008-empty-packages-not-stubs.md) | Workspace ships empty packages, not placeholder source | Accepted |
|
||||
| [0009](0009-repo-rename.md) | Repository renamed originmain → wursor | Accepted |
|
||||
| [0010](0010-openrouter-live-golden.md) | Golden harness scores live runs through a provider-agnostic LLM client (OpenRouter first) | Accepted |
|
||||
| [0011](0011-fastify-react-stack.md) | Fastify is the API server; React + Vite is the web shell | Accepted |
|
||||
| [0011](0011-fastify-react-stack.md) | Fastify is the API server; React + Vite is the web shell | Partially superseded by [0018](0018-nextjs-web-app.md) (web half) |
|
||||
| [0012](0012-in-memory-user-store.md) | In-memory user store behind a UserStore interface; Postgres deferred | Accepted |
|
||||
| [0013](0013-docker-boundary-mock.md) | Sandbox orchestration mocks the Docker boundary; real daemon client deferred | Accepted |
|
||||
| [0014](0014-postgres-queryable.md) | Postgres user store via a Queryable boundary; schema in SQL migrations | Accepted |
|
||||
| [0015](0015-dockerode-engine-gating.md) | Docker daemon client via dockerode behind an injected engine; sandbox gated by env | Accepted |
|
||||
| [0016](0016-pairing-code-ownership.md) | Pairing-code TTL/lockout lives on the Wursor API; the plugin enforces token/HMAC/scope | Accepted |
|
||||
| [0017](0017-semantic-tool-schema.md) | The agent is given a semantic, allowlisted tool schema — not a raw wp_cli surface | Accepted |
|
||||
| [0018](0018-nextjs-web-app.md) | The web app is Next.js (App Router); Fastify stays as the Wursor API | Accepted |
|
||||
| [0019](0019-sign-in-methods.md) | Sign-in supports an emailed code, an emailed link and passkeys | Accepted |
|
||||
| [0020](0020-weown-cloud-provisioning.md) | Cloud sites are provisioned on WeOwn Cloud | Accepted |
|
||||
| [0021](0021-keep-no-diff-rule-wording.md) | Keep the "never sees a diff" rule in AGENTS.md as written | Accepted |
|
||||
| [0022](0022-digitalocean-hosting.md) | Wursor and WeOwn Cloud run on DigitalOcean | Accepted (service picks to confirm) |
|
||||
| [0023](0023-product-components-and-contrast.md) | Product UI is built once at design size and reused; text contrast tokens meet AA | Accepted |
|
||||
|
||||
## How to add one
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"test": "vitest run",
|
||||
"golden": "tsx golden/src/run-golden.ts",
|
||||
"mirror:time": "tsx golden/src/run-mirror-timing.ts",
|
||||
"agent:spike": "tsx agent/run-agent-spike.ts"
|
||||
"agent:spike": "tsx agent/run-agent-spike.ts",
|
||||
"test:marketing": "playwright test --config playwright.marketing.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
/** Marketing-site checks only: needs the web app, not the API. Run with `pnpm --filter @wursor/e2e test:marketing`. */
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
testMatch: 'marketing.test.ts',
|
||||
fullyParallel: true,
|
||||
retries: 0,
|
||||
use: { baseURL: 'http://localhost:5173', launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH ?? undefined } },
|
||||
webServer: {
|
||||
command: 'pnpm --filter @wursor/web dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('user signs up and sees the chat interface', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.goto('/app');
|
||||
|
||||
await page.getByLabel('Email').fill(`e2e-${Date.now()}@example.com`);
|
||||
await page.getByLabel('Password').fill('password123');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const PAGES = ['/', '/product', '/how-it-works', '/trust', '/pricing', '/docs'];
|
||||
|
||||
for (const path of PAGES) {
|
||||
test(`${path}: every Get started and Sign in goes to sign-up or sign-in (PX-13)`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
const links = page.getByRole('link', { name: /^(Get started|Sign in)$/ });
|
||||
const hrefs = await links.evaluateAll((els) => els.map((e) => e.getAttribute('href')));
|
||||
expect(hrefs.length).toBeGreaterThanOrEqual(2);
|
||||
for (const href of hrefs) expect(['/signup', '/signin']).toContain(href);
|
||||
});
|
||||
|
||||
test(`${path}: has one page heading and no horizontal scroll on a phone`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(path);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('the navigation reaches every marketing page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
for (const [name, path] of [['Product', '/product'], ['How it works', '/how-it-works'], ['Trust', '/trust'], ['Pricing', '/pricing'], ['Docs', '/docs']] as const) {
|
||||
await page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`${path}$`));
|
||||
await expect(page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name })).toHaveAttribute('aria-current', 'page');
|
||||
}
|
||||
});
|
||||
|
||||
test('the mobile menu opens and lists every page', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Open menu' }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Mobile' }).getByRole('link')).toHaveCount(6);
|
||||
});
|
||||
@@ -56,6 +56,12 @@ importers:
|
||||
|
||||
web:
|
||||
dependencies:
|
||||
geist:
|
||||
specifier: ^1.7.2
|
||||
version: 1.7.2(next@16.4.0(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))
|
||||
next:
|
||||
specifier: ^16.4.0
|
||||
version: 16.4.0(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
react:
|
||||
specifier: ^19.2.8
|
||||
version: 19.2.8
|
||||
@@ -162,6 +168,9 @@ packages:
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -359,6 +368,168 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.5':
|
||||
resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.4':
|
||||
resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.4':
|
||||
resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.4':
|
||||
resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.4':
|
||||
resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.4':
|
||||
resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.4':
|
||||
resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
|
||||
resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.5':
|
||||
resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.5':
|
||||
resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.5':
|
||||
resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.5':
|
||||
resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.5':
|
||||
resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.5':
|
||||
resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.5':
|
||||
resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.35.5':
|
||||
resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
@@ -372,6 +543,61 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/env@16.4.0':
|
||||
resolution: {integrity: sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.4.0':
|
||||
resolution: {integrity: sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.4.0':
|
||||
resolution: {integrity: sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.4.0':
|
||||
resolution: {integrity: sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.4.0':
|
||||
resolution: {integrity: sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.4.0':
|
||||
resolution: {integrity: sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.4.0':
|
||||
resolution: {integrity: sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.4.0':
|
||||
resolution: {integrity: sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.4.0':
|
||||
resolution: {integrity: sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-project/types@0.144.0':
|
||||
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
||||
|
||||
@@ -641,6 +867,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -806,6 +1035,11 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.11.28:
|
||||
resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||
|
||||
@@ -826,6 +1060,9 @@ packages:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
caniuse-lite@1.0.30001815:
|
||||
resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -837,6 +1074,9 @@ packages:
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -987,6 +1227,11 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
geist@1.7.2:
|
||||
resolution: {integrity: sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==}
|
||||
peerDependencies:
|
||||
next: '>=13.2.0'
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
@@ -1155,6 +1400,27 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
next@16.4.0:
|
||||
resolution: {integrity: sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
'@playwright/test': ^1.51.1
|
||||
babel-plugin-react-compiler: '*'
|
||||
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
sass: ^1.3.0
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@playwright/test':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -1233,6 +1499,10 @@ packages:
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.23:
|
||||
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.26:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1365,6 +1635,15 @@ packages:
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
sharp@0.35.5:
|
||||
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '*'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@@ -1410,6 +1689,19 @@ packages:
|
||||
strip-literal@3.1.0:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '*'
|
||||
babel-plugin-macros: '*'
|
||||
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
@@ -1465,6 +1757,9 @@ packages:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsx@4.23.12:
|
||||
resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1721,6 +2016,11 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
optional: true
|
||||
|
||||
@@ -1843,6 +2143,113 @@ snapshots:
|
||||
protobufjs: 7.6.5
|
||||
yargs: 17.7.3
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.5
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.5
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2': {}
|
||||
@@ -1850,6 +2257,32 @@ snapshots:
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
optional: true
|
||||
|
||||
'@next/env@16.4.0': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.4.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.4.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.4.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.4.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.4.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.4.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.4.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.4.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-project/types@0.144.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
@@ -1997,6 +2430,10 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.62.4':
|
||||
optional: true
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -2171,6 +2608,8 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.11.28: {}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
@@ -2195,6 +2634,8 @@ snapshots:
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
caniuse-lite@1.0.30001815: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -2207,6 +2648,8 @@ snapshots:
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -2387,6 +2830,10 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
geist@1.7.2(next@16.4.0(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)):
|
||||
dependencies:
|
||||
next: 16.4.0(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
@@ -2525,6 +2972,32 @@ snapshots:
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
next@16.4.0(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
'@next/env': 16.4.0
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.28
|
||||
caniuse-lite: 1.0.30001815
|
||||
postcss: 8.5.23
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
styled-jsx: 5.1.6(react@19.2.8)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.4.0
|
||||
'@next/swc-darwin-x64': 16.4.0
|
||||
'@next/swc-linux-arm64-gnu': 16.4.0
|
||||
'@next/swc-linux-arm64-musl': 16.4.0
|
||||
'@next/swc-linux-x64-gnu': 16.4.0
|
||||
'@next/swc-linux-x64-musl': 16.4.0
|
||||
'@next/swc-win32-arm64-msvc': 16.4.0
|
||||
'@next/swc-win32-x64-msvc': 16.4.0
|
||||
'@playwright/test': 1.62.1
|
||||
sharp: 0.35.5(@types/node@22.20.1)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
once@1.4.0:
|
||||
@@ -2606,6 +3079,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss@8.5.23:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.26:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
@@ -2763,6 +3242,40 @@ snapshots:
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
sharp@0.35.5(@types/node@22.20.1):
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.5
|
||||
'@img/sharp-darwin-x64': 0.35.5
|
||||
'@img/sharp-freebsd-wasm32': 0.35.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||
'@img/sharp-linux-arm': 0.35.5
|
||||
'@img/sharp-linux-arm64': 0.35.5
|
||||
'@img/sharp-linux-ppc64': 0.35.5
|
||||
'@img/sharp-linux-riscv64': 0.35.5
|
||||
'@img/sharp-linux-s390x': 0.35.5
|
||||
'@img/sharp-linux-x64': 0.35.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.5
|
||||
'@img/sharp-linuxmusl-x64': 0.35.5
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.5
|
||||
'@img/sharp-win32-arm64': 0.35.5
|
||||
'@img/sharp-win32-ia32': 0.35.5
|
||||
'@img/sharp-win32-x64': 0.35.5
|
||||
'@types/node': 22.20.1
|
||||
optional: true
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
@@ -2809,6 +3322,11 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.8):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.8
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tar-fs@2.1.5:
|
||||
@@ -2861,6 +3379,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.23.12:
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 0 spikes
|
||||
|
||||
These notes are the gate. Do not implement `web/` chat, playbooks, or deploy until every note below is `done`.
|
||||
These notes are the gate. Do not implement `web/` chat, playbooks, or deploy until every note below is `done`. The hosting spikes gate only the hosting plane and the Conductor deployment (IMPLEMENTATION.md section 0.4, steps F and G), not the other steps.
|
||||
|
||||
Throwaway fixtures and scripts are allowed. Product UI is not.
|
||||
|
||||
@@ -11,5 +11,6 @@ Throwaway fixtures and scripts are allowed. Product UI is not.
|
||||
| Pairing threat model (R9) | [pairing-threat-model.md](./pairing-threat-model.md) | done |
|
||||
| Large-site mirror timing (R4) | [mirror-timing.md](./mirror-timing.md) | done — synthetic 2GB |
|
||||
| P0 plugin catalog | [plugin-catalog.md](./plugin-catalog.md) | done |
|
||||
| Hosting on DigitalOcean (5 spikes) | [hosting-digitalocean.md](./hosting-digitalocean.md) | planned |
|
||||
|
||||
Batch writeup: [phase-0-harness.md](./phase-0-harness.md).
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Hosting on DigitalOcean: spike plan
|
||||
|
||||
**Status:** planned. Nothing here has been run.
|
||||
**Decision it supports:** [ADR 0022](../docs/decisions/0022-digitalocean-hosting.md) (service picks), [ADR 0020](../docs/decisions/0020-weown-cloud-provisioning.md) (WeOwn Cloud).
|
||||
**Gates:** IMPLEMENTATION.md section 0.4 steps F and G (hosting plane, moves) and the deployment of the Conductor. It does not gate step A, B, C or D, which need no cloud account.
|
||||
|
||||
Throwaway scripts and a staging DigitalOcean project are allowed. Product code and production credentials are not. Each spike ends in a short written result added to this folder and a row in [README.md](./README.md). A spike that cannot pass its criteria says so, names the fallback, and updates ADR 0022.
|
||||
|
||||
Time-boxes are proposals, not measurements. Dollar figures are not estimated here; spike 5 produces them.
|
||||
|
||||
## Setup (once, before any spike)
|
||||
|
||||
- A separate DigitalOcean project named for staging, with a spend alert, and no link to production or to any customer site.
|
||||
- Three VPCs, one per plane (control, execution, hosting), as in ADR 0022.
|
||||
- Terraform for the above in `infrastructure/terraform/`, plan-only in CI, applied by hand to staging. State in a private Spaces bucket.
|
||||
- Everything created is tagged `spike` and torn down at the end of each spike. List what was left running in the result.
|
||||
|
||||
## Spike 1. Can the run stream live on App Platform?
|
||||
|
||||
**Question.** Does App Platform hold a long-lived streaming response (a run can last tens of minutes, PRD AG-05) and let the browser resume from the last event number (HL-06), or does the Conductor need Droplets or DOKS?
|
||||
|
||||
**Method.**
|
||||
1. Deploy a throwaway service that emits one event per second with a sequence number, as a streaming `fetch` endpoint, behind App Platform. Deploy the same service on a Droplet behind a load balancer.
|
||||
2. Hold a connection for 60 minutes on each. Record when and how it drops (platform timeout, load balancer, idle timeout).
|
||||
3. Force 50 disconnects mid-stream with a client that resumes from the last sequence number.
|
||||
4. Deploy a new version of the service during an open stream and record what the client sees.
|
||||
|
||||
**Pass.** The chosen host keeps a stream open for a full 60 minutes (or the platform limit is known and the client reconnects inside it), across 50 forced disconnects no event is lost or duplicated (HL-06), and a deploy during a stream loses nothing after resume.
|
||||
|
||||
**Result decides.** App Platform for the Conductor, or Droplets/DOKS. Also whether the Next.js server can sit on App Platform (it does not hold the stream, ADR 0018, so it likely can).
|
||||
|
||||
**Time-box.** 2 days.
|
||||
|
||||
## Spike 2. What isolation can Cloud sites get on a Droplet?
|
||||
|
||||
**Question.** PRD 5.3 leaves container versus microVM open. Which isolation level is actually available on DigitalOcean, and does it hold up to a hostile neighbor (SH-14)?
|
||||
|
||||
**Method.**
|
||||
1. Check whether nested virtualization or a microVM runtime (for example Firecracker or Kata) works on the Droplet types in use. Record yes or no per type; do not assume.
|
||||
2. Build the baseline: one container per site on a shared Droplet, with its own database user, its own file volume, a read-only root filesystem, dropped capabilities, a seccomp profile, resource limits and egress limits.
|
||||
3. Run a deliberately malicious "site" as a neighbor and try: reading another site's files and database, reaching the Docker socket, reaching the DigitalOcean metadata service, reaching the control-plane VPC, fork bombs and memory exhaustion, and outbound spam to a test sink.
|
||||
4. Repeat with a microVM runtime if step 1 found one.
|
||||
|
||||
**Pass.** Every attack in step 3 fails and is logged, a noisy neighbor cannot starve another site beyond the stated limits, and the test is scripted so it can run in CI on every release (SH-14).
|
||||
|
||||
**Result decides.** The Cloud site isolation level, the density per Droplet (feeds spike 5), and whether Cloud beta is capped to fewer sites per plan.
|
||||
|
||||
**Time-box.** 4 days. This is the riskiest spike; do it first or in parallel with spike 1.
|
||||
|
||||
## Spike 3. Can we issue certificates for customer domains at volume?
|
||||
|
||||
**Question.** Can certificates be issued and renewed automatically for customer domains (SH-12) and for the wildcard preview origin (SB-02), without manual steps?
|
||||
|
||||
**Method.**
|
||||
1. Put a TLS-terminating proxy with on-demand issuance in front of two test Cloud sites. Point five test domains at it, using real DNS records on a domain we own.
|
||||
2. Time issuance from "record correct" to "Secure", and check the error text when the record is wrong, missing, or points somewhere else.
|
||||
3. Issue a wildcard certificate for the preview origin using DNS validation through DigitalOcean DNS.
|
||||
4. Check issuer rate limits against the target volume. Run a bulk test of issuances against the issuer's staging environment, not production, and work out how many new domains per week the limits allow.
|
||||
5. Force a renewal early and confirm no downtime.
|
||||
|
||||
**Pass.** Issuance completes without a human, the error states map to the plain-language messages in SH-12, renewal causes no downtime, and the rate-limit headroom is stated as a number against the expected growth.
|
||||
|
||||
**Result decides.** The certificate approach, whether a second issuer is needed, and how the "waiting for DNS" screen polls.
|
||||
|
||||
**Time-box.** 2 days.
|
||||
|
||||
## Spike 4. Backups and restores that we have actually proven
|
||||
|
||||
**Question.** Can Cloud sites be backed up daily, kept 30 days, backed up before every change, and restored within the time the product promises (SH-04, SH-14, RL-03)?
|
||||
|
||||
**Method.**
|
||||
1. Back up a test WordPress site (database plus uploads plus configuration) to Spaces with versioning, using the mechanism the WordPress target will call.
|
||||
2. Restore to a clean Droplet and run the smoke test. Time it, for a small site, a 2 GB site and a 10 GB site.
|
||||
3. Take a backup before a simulated change, make the change fail, and restore. Time to restored site must be under 10 minutes (section 16).
|
||||
4. Verify retention: age out day 31 and confirm it is removed, and that day 30 is still restorable.
|
||||
5. Corrupt a backup on purpose and confirm the restore test notices.
|
||||
6. Restore the control-plane PostgreSQL to a point in time on a separate instance and record the data-loss window (PRD section 7 target: 15 minutes).
|
||||
|
||||
**Pass.** All restores pass the smoke test, the pre-change backup restores in under 10 minutes for the small and 2 GB sites, the corrupted backup is caught, and the database restore meets the 4-hour recovery and 15-minute loss targets.
|
||||
|
||||
**Result decides.** Backup mechanism for the WordPress target (D-HL-5), retention beyond 30 days as a paid option (D-HL-8), and which sizes need a different method.
|
||||
|
||||
**Time-box.** 3 days.
|
||||
|
||||
## Spike 5. What does a Cloud site and a sandbox cost?
|
||||
|
||||
**Question.** What do we pay per Cloud site and per sandbox, so that plans and prices (D-HL-8, D-HL-14) are not guesses?
|
||||
|
||||
**Method.**
|
||||
1. Using the density from spike 2, run N sites of different sizes (small brochure, WooCommerce-style, media-heavy) for 7 days on a shared Droplet. Record CPU, memory, disk, bandwidth and backup storage per site.
|
||||
2. Run sandboxes through the existing warm pool and garbage collection (DEVOPS.md items B and C) and record the cost of a warm spare, a paused sandbox and an active one over a day.
|
||||
3. Add the managed services (PostgreSQL, Redis or Valkey, Spaces, load balancers) pro rata.
|
||||
4. Report cost per Cloud site per month and per verified change, as ranges with the assumptions that produce them, from DigitalOcean's current price list at the time of the test.
|
||||
|
||||
**Pass.** A table of cost per Cloud site by site type, cost per sandbox-hour, and a statement of what density the figures assume.
|
||||
|
||||
**Result decides.** Plan structure and the Cloud-sites-per-plan cap during beta; whether partner provisioning should be reconsidered (SH-16) if the numbers are out of reach.
|
||||
|
||||
**Time-box.** 1 day of setup plus a 7-day run.
|
||||
|
||||
## Order and dependencies
|
||||
|
||||
1. Setup.
|
||||
2. Spikes 1 and 2 in parallel. Spike 2 is the one most likely to change the plan.
|
||||
3. Spike 3, then spike 4. Spike 4 needs a running Cloud-style site from spike 2.
|
||||
4. Spike 5 last, since it uses spike 2's density.
|
||||
|
||||
Total about 12 working days plus the 7-day cost run, on one engineer, less if two work in parallel.
|
||||
|
||||
## What a failure changes
|
||||
|
||||
- Spike 2 fails (no acceptable isolation on Droplets): cap Cloud sites per plan, add a hardened runtime, or move provisioning to a partner behind the same interface (SH-16, ADR 0020 alternative). Leadership decision.
|
||||
- Spike 1 fails on App Platform: Conductor goes on Droplets or DOKS. No product change.
|
||||
- Spike 4 misses the restore targets: restore time becomes a published promise only after it is met; the "under 10 minutes" copy in section 16 changes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Model provider choice, sandbox provider alternatives (SB-08), domain purchase, email hosting, and the identity provider decision (D-HL-1).
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Button } from '../../src/marketing/Button.tsx';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders a link with its label and destination', () => {
|
||||
render(<Button href="/signup">Get started</Button>);
|
||||
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('is the primary (inverse) pill by default', () => {
|
||||
render(<Button href="/signup">Get started</Button>);
|
||||
expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn', 'btn-primary', 'btn-md');
|
||||
});
|
||||
|
||||
it('supports the ghost and secondary variants and the large size', () => {
|
||||
const { rerender } = render(<Button href="/product" variant="ghost" size="lg">See how it works</Button>);
|
||||
expect(screen.getByRole('link', { name: 'See how it works' })).toHaveClass('btn-ghost', 'btn-lg');
|
||||
rerender(<Button href="/trust" variant="secondary">How approval works</Button>);
|
||||
expect(screen.getByRole('link', { name: 'How approval works' })).toHaveClass('btn-secondary');
|
||||
});
|
||||
|
||||
it('supports a light variant for use on the dark panel', () => {
|
||||
render(<Button href="/signup" variant="light" size="lg">Get started</Button>);
|
||||
expect(screen.getByRole('link', { name: 'Get started' })).toHaveClass('btn-light');
|
||||
});
|
||||
|
||||
it('can end with an arrow that is not announced', () => {
|
||||
render(<Button href="/how-it-works" variant="ghost" size="lg" trailingIcon="arrow-right">See how it works</Button>);
|
||||
const link = screen.getByRole('link', { name: 'See how it works' });
|
||||
expect(link.querySelector('.icon')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CommandPaletteProvider } from '../../src/marketing/CommandPalette.tsx';
|
||||
import { DocsSearch } from '../../src/marketing/pages/DocsSearch.tsx';
|
||||
import { Nav } from '../../src/marketing/Nav.tsx';
|
||||
|
||||
function setup() {
|
||||
return render(
|
||||
<CommandPaletteProvider>
|
||||
<Nav />
|
||||
<DocsSearch />
|
||||
<button type="button">Elsewhere</button>
|
||||
</CommandPaletteProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const dialog = () => screen.queryByRole('dialog', { name: 'Search' });
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }) as unknown as typeof window.matchMedia;
|
||||
});
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
it('is closed until asked for', () => {
|
||||
setup();
|
||||
expect(dialog()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Ctrl+K', '{Control>}k{/Control}'],
|
||||
['Cmd+K', '{Meta>}k{/Meta}'],
|
||||
])('opens with %s and focuses the search box', async (_name, keys) => {
|
||||
setup();
|
||||
await userEvent.keyboard(keys);
|
||||
expect(dialog()).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: 'Search the site' })).toHaveFocus();
|
||||
});
|
||||
|
||||
it('opens while typing in a text field, and stops the browser acting on the shortcut', async () => {
|
||||
render(
|
||||
<CommandPaletteProvider>
|
||||
<input aria-label="Name" />
|
||||
</CommandPaletteProvider>,
|
||||
);
|
||||
screen.getByRole('textbox', { name: 'Name' }).focus();
|
||||
const handled: boolean[] = [];
|
||||
const spy = (e: KeyboardEvent) => e.key === 'k' && handled.push(e.defaultPrevented);
|
||||
document.addEventListener('keydown', spy);
|
||||
try {
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
} finally {
|
||||
document.removeEventListener('keydown', spy);
|
||||
}
|
||||
expect(dialog()).toBeInTheDocument();
|
||||
expect(handled).toEqual([true]);
|
||||
});
|
||||
|
||||
it('toggles closed with the same shortcut', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
expect(dialog()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes with Escape and hands focus back to where it was', async () => {
|
||||
setup();
|
||||
screen.getByRole('button', { name: 'Elsewhere' }).focus();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(dialog()).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Elsewhere' })).toHaveFocus();
|
||||
});
|
||||
|
||||
it('closes when the backdrop is clicked, but not when the panel is', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
expect(dialog()).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('palette-backdrop'));
|
||||
expect(dialog()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('suggests the pages before anything is typed', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
const options = within(screen.getByRole('listbox')).getAllByRole('option');
|
||||
expect(options.map((o) => o.textContent)).toEqual(expect.arrayContaining([expect.stringContaining('Pricing'), expect.stringContaining('Docs')]));
|
||||
});
|
||||
|
||||
it('filters as you type and links each result to its page', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
await userEvent.keyboard('pricing');
|
||||
const options = within(screen.getByRole('listbox')).getAllByRole('option');
|
||||
expect(options[0]).toHaveTextContent('Pricing');
|
||||
expect(options[0]).toHaveAttribute('href', '/pricing');
|
||||
});
|
||||
|
||||
it('says so when nothing matches', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
await userEvent.keyboard('zzzz');
|
||||
expect(screen.queryByRole('option')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/No results for/)).toHaveTextContent('zzzz');
|
||||
});
|
||||
|
||||
it('moves the selection with the arrow keys and wraps around', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
const input = screen.getByRole('combobox');
|
||||
const options = () => within(screen.getByRole('listbox')).getAllByRole('option');
|
||||
expect(options()[0]).toHaveAttribute('aria-selected', 'true');
|
||||
expect(input).toHaveAttribute('aria-activedescendant', options()[0]!.id);
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
expect(options()[1]).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}{ArrowUp}');
|
||||
expect(options().at(-1)).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('opens the selected result with Enter and closes', async () => {
|
||||
setup();
|
||||
const clicked = vi.fn();
|
||||
const onClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
clicked((e.target as HTMLElement).closest('a')?.getAttribute('href'));
|
||||
};
|
||||
document.addEventListener('click', onClick);
|
||||
try {
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
await userEvent.keyboard('pricing{Enter}');
|
||||
expect(clicked).toHaveBeenCalledWith('/pricing');
|
||||
expect(dialog()).not.toBeInTheDocument();
|
||||
} finally {
|
||||
document.removeEventListener('click', onClick);
|
||||
}
|
||||
});
|
||||
|
||||
it('starts from the top of the list each time it opens', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
await userEvent.keyboard('zzzz{Escape}');
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
expect(screen.getByRole('combobox')).toHaveValue('');
|
||||
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('keeps the page from scrolling behind it while open', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('keeps focus in the search box when Tab is pressed', async () => {
|
||||
setup();
|
||||
await userEvent.keyboard('{Control>}k{/Control}');
|
||||
await userEvent.tab();
|
||||
expect(screen.getByRole('combobox')).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search triggers', () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }) as unknown as typeof window.matchMedia;
|
||||
});
|
||||
|
||||
it('opens from the Search button in the navigation', async () => {
|
||||
setup();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
|
||||
expect(dialog()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens from the docs search field, which is no longer disabled', async () => {
|
||||
setup();
|
||||
const field = screen.getByRole('searchbox', { name: 'Search the docs' });
|
||||
expect(field).toBeEnabled();
|
||||
await userEvent.click(field);
|
||||
expect(dialog()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the shortcut on the docs search field', () => {
|
||||
setup();
|
||||
expect(screen.getByText('Ctrl K')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does nothing, and does not crash, when there is no provider', async () => {
|
||||
render(<Nav />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
|
||||
expect(dialog()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Footer } from '../../src/marketing/Footer.tsx';
|
||||
|
||||
describe('Footer', () => {
|
||||
it('shows the brand and tagline', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('Your website, handled.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has the four link columns from the design', () => {
|
||||
render(<Footer />);
|
||||
for (const heading of ['Product', 'Company', 'Resources', 'Legal']) {
|
||||
expect(screen.getByRole('heading', { name: heading })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('links only to pages that exist', () => {
|
||||
render(<Footer />);
|
||||
const nav = screen.getByRole('navigation', { name: 'Footer' });
|
||||
const links = within(nav).getAllByRole('link').map((a) => [a.textContent, a.getAttribute('href')]);
|
||||
expect(links).toEqual([
|
||||
['Overview', '/product'],
|
||||
['How it works', '/how-it-works'],
|
||||
['Trust and safety', '/trust'],
|
||||
['Pricing', '/pricing'],
|
||||
['Docs', '/docs'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows pages not built yet as plain text, not dead links (D-HL-17)', () => {
|
||||
render(<Footer />);
|
||||
for (const label of ['Changelog', 'About', 'Blog', 'Contact', 'Terms', 'Privacy']) {
|
||||
expect(screen.getByText(label).closest('a')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the copyright line', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('© 2026 Wursor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Nav } from '../../src/marketing/Nav.tsx';
|
||||
|
||||
function deviceTheme(dark: boolean) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: dark && query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
describe('Nav', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
deviceTheme(false);
|
||||
});
|
||||
|
||||
it('links the brand to the home page', () => {
|
||||
render(<Nav />);
|
||||
expect(screen.getByRole('link', { name: 'Wursor' })).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('lists the five page links', () => {
|
||||
render(<Nav />);
|
||||
const primary = screen.getByRole('navigation', { name: 'Primary' });
|
||||
const hrefs = within(primary).getAllByRole('link').map((a) => [a.textContent, a.getAttribute('href')]);
|
||||
expect(hrefs).toEqual([
|
||||
['Product', '/product'],
|
||||
['How it works', '/how-it-works'],
|
||||
['Trust', '/trust'],
|
||||
['Pricing', '/pricing'],
|
||||
['Docs', '/docs'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends Sign in to sign-in and Get started to sign-up (PX-13)', () => {
|
||||
render(<Nav />);
|
||||
expect(screen.getAllByRole('link', { name: 'Sign in' })[0]).toHaveAttribute('href', '/signin');
|
||||
expect(screen.getAllByRole('link', { name: 'Get started' })[0]).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('keeps the mobile menu closed until the menu button is pressed', async () => {
|
||||
render(<Nav />);
|
||||
const button = screen.getByRole('button', { name: 'Open menu' });
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Close menu' })).toHaveAttribute('aria-expanded', 'true');
|
||||
const menu = screen.getByRole('navigation', { name: 'Mobile' });
|
||||
expect(within(menu).getAllByRole('link').map((a) => a.textContent)).toEqual([
|
||||
'Product', 'How it works', 'Trust and safety', 'Pricing', 'Docs', 'Get started',
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes the mobile menu with Escape', async () => {
|
||||
render(<Nav />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the mobile menu after a link is chosen', async () => {
|
||||
render(<Nav />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
|
||||
const menu = screen.getByRole('navigation', { name: 'Mobile' });
|
||||
await userEvent.click(within(menu).getByRole('link', { name: 'Pricing' }));
|
||||
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('theme toggle', () => {
|
||||
it('is off on a light device', () => {
|
||||
render(<Nav />);
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('is on when the device is dark and nothing is saved', () => {
|
||||
deviceTheme(true);
|
||||
render(<Nav />);
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('is on when dark was chosen earlier, even on a light device', () => {
|
||||
localStorage.setItem('wursor-theme', 'dark');
|
||||
render(<Nav />);
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('switches the page to dark and remembers it', async () => {
|
||||
render(<Nav />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dark theme' }));
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
expect(localStorage.getItem('wursor-theme')).toBe('dark');
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('switches a dark device to light', async () => {
|
||||
deviceTheme(true);
|
||||
render(<Nav />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dark theme' }));
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'light');
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ApproveWithProof } from '../../src/marketing/home/ApproveWithProof.tsx';
|
||||
import { ClosingCta } from '../../src/marketing/home/ClosingCta.tsx';
|
||||
import { Examples } from '../../src/marketing/home/Examples.tsx';
|
||||
import { Faq } from '../../src/marketing/home/Faq.tsx';
|
||||
import { Hero } from '../../src/marketing/home/Hero.tsx';
|
||||
import { PricingTeaser } from '../../src/marketing/home/PricingTeaser.tsx';
|
||||
import { ProofStrip } from '../../src/marketing/home/ProofStrip.tsx';
|
||||
import { SafeByDesign } from '../../src/marketing/home/SafeByDesign.tsx';
|
||||
import { SectionHeader } from '../../src/marketing/home/SectionHeader.tsx';
|
||||
import { SurfacesShowcase } from '../../src/marketing/home/SurfacesShowcase.tsx';
|
||||
import { WatchTheWork } from '../../src/marketing/home/WatchTheWork.tsx';
|
||||
import HomePage from '../../app/(marketing)/page.tsx';
|
||||
import { PRICING_CONFIRMED } from '../../src/marketing/content.ts';
|
||||
|
||||
describe('SectionHeader', () => {
|
||||
it('has an eyebrow, a heading and a supporting sentence', () => {
|
||||
render(<SectionHeader eyebrow="One screen" title="Talk. Watch. Decide." subtitle="All in one place." />);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Talk. Watch. Decide.' })).toBeInTheDocument();
|
||||
expect(screen.getByText('One screen')).toBeInTheDocument();
|
||||
expect(screen.getByText('All in one place.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hero', () => {
|
||||
it('leads with the promise as the page heading', () => {
|
||||
render(<Hero />);
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Your website, handled.' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Tell Wursor what to change. Watch it work. Approve what you like.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends Get started to sign-up and See how it works to the how-it-works page (PX-13)', () => {
|
||||
render(<Hero />);
|
||||
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
|
||||
expect(screen.getByRole('link', { name: 'See how it works' })).toHaveAttribute('href', '/how-it-works');
|
||||
});
|
||||
|
||||
it('announces the approval feature, linking to how approval works', () => {
|
||||
render(<Hero />);
|
||||
expect(screen.getByRole('link', { name: /Approve with proof: nine checks on every change/ })).toHaveAttribute('href', '/trust');
|
||||
});
|
||||
|
||||
it('says who it is for', () => {
|
||||
render(<Hero />);
|
||||
expect(screen.getByText('For WordPress sites · We host it, or you do')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the product as a picture, hidden from assistive technology and not focusable', () => {
|
||||
const { container } = render(<Hero />);
|
||||
const visual = container.querySelector('.m-hero-visual [aria-hidden="true"]');
|
||||
expect(visual).not.toBeNull();
|
||||
expect(visual).toHaveAttribute('inert');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProofStrip', () => {
|
||||
it('shows the three proof points', () => {
|
||||
render(<ProofStrip />);
|
||||
expect(screen.getByText('9')).toBeInTheDocument();
|
||||
expect(screen.getByText('checks before anything goes live')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 click')).toBeInTheDocument();
|
||||
expect(screen.getByText('to undo any change, any time')).toBeInTheDocument();
|
||||
expect(screen.getByText('Every step')).toBeInTheDocument();
|
||||
expect(screen.getByText('on the record, in plain words')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SurfacesShowcase', () => {
|
||||
it('starts on the Conversation tab with its caption', () => {
|
||||
render(<SurfacesShowcase />);
|
||||
expect(screen.getByRole('tab', { name: 'Conversation' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('Conversation: ask in plain words. Wursor explains each step as it goes.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the Preview and Board views and their captions', async () => {
|
||||
render(<SurfacesShowcase />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Preview' }));
|
||||
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('Preview: see the real page on a phone, tablet and laptop before anything goes live.')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Board' }));
|
||||
expect(screen.getByText('Board: every change in one place, from idea to published.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves between tabs with the arrow keys', async () => {
|
||||
render(<SurfacesShowcase />);
|
||||
screen.getByRole('tab', { name: 'Conversation' }).focus();
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WatchTheWork', () => {
|
||||
it('shows the same change at three levels of detail', () => {
|
||||
render(<WatchTheWork />);
|
||||
expect(screen.getByRole('heading', { level: 2, name: /Watch the work, your way\./ })).toBeInTheDocument();
|
||||
// The design words the heading more briefly on a phone; both are in the page and CSS shows one.
|
||||
expect(screen.getByText('Watch every step.')).toHaveClass('m-m');
|
||||
for (const [name, text] of [
|
||||
['Headline', 'Only what changed and what needs you.'],
|
||||
['Steps', 'Each step, in a sentence. The default.'],
|
||||
['Everything', 'Every look, edit, check and decision.'],
|
||||
] as const) {
|
||||
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument();
|
||||
expect(screen.getByText(text)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApproveWithProof', () => {
|
||||
it('lists what the owner gets and links to how approval works', () => {
|
||||
render(<ApproveWithProof />);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'See it before it’s real.' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Nine checks, written in plain words')).toBeInTheDocument();
|
||||
expect(screen.getByText('Looks right on a phone, a tablet and a laptop')).toBeInTheDocument();
|
||||
expect(screen.getByText('One click to approve and publish, one click to undo')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'How approval works' })).toHaveAttribute('href', '/trust');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SafeByDesign', () => {
|
||||
it('states the four rules', () => {
|
||||
render(<SafeByDesign />);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Nothing goes live until you say so.' })).toBeInTheDocument();
|
||||
for (const rule of ['Works on a safe copy', 'Nothing goes live without you', 'Everything is recorded', 'Undo in one click']) {
|
||||
expect(screen.getByRole('heading', { level: 3, name: rule })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Examples', () => {
|
||||
it('shows five small jobs, each with a before and after', () => {
|
||||
render(<Examples />);
|
||||
const list = screen.getByRole('list', { name: 'Examples' });
|
||||
const items = within(list).getAllByRole('listitem');
|
||||
expect(items).toHaveLength(5);
|
||||
expect(within(items[0]!).getByText('Update the holiday hours')).toBeInTheDocument();
|
||||
expect(within(items[0]!).getByText('Page · 2 min')).toBeInTheDocument();
|
||||
expect(within(items[0]!).getByText('Before')).toBeInTheDocument();
|
||||
expect(within(items[0]!).getByText('After')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PricingTeaser', () => {
|
||||
it('does not show prices or allowances until they are decided (PRD PX-13, D-HL-8)', () => {
|
||||
expect(PRICING_CONFIRMED).toBe(false);
|
||||
render(<PricingTeaser />);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Simple plans. No surprises.' })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/\$\d/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/free for 14 days/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/sites ·/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Plans and prices will be published before launch.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still leads to sign-up and the pricing page', () => {
|
||||
render(<PricingTeaser />);
|
||||
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
|
||||
expect(screen.getByRole('link', { name: 'See pricing' })).toHaveAttribute('href', '/pricing');
|
||||
});
|
||||
|
||||
it('shows the plan cards when pricing is confirmed', () => {
|
||||
render(<PricingTeaser confirmed />);
|
||||
for (const plan of ['Starter', 'Studio', 'Agency']) {
|
||||
expect(screen.getByRole('heading', { level: 3, name: plan })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getAllByRole('link', { name: 'Choose plan' })).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Faq', () => {
|
||||
it('lists the questions, with the first answered and open', () => {
|
||||
render(<Faq />);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Questions, answered.' })).toBeInTheDocument();
|
||||
const first = screen.getByText('Will Wursor change my live site without asking?').closest('details');
|
||||
expect(first).toHaveAttribute('open');
|
||||
expect(screen.getByText(/Nothing goes live until you review the preview and approve it\./)).toBeInTheDocument();
|
||||
for (const q of ['Do I need my own hosting?', 'Can I undo a change?', 'Does it work with my theme and plugins?', 'How is my site kept safe?']) {
|
||||
expect(screen.getByText(q).closest('details')).not.toHaveAttribute('open');
|
||||
}
|
||||
});
|
||||
|
||||
it('opens an item to show its answer', async () => {
|
||||
render(<Faq />);
|
||||
await userEvent.click(screen.getByText('Do I need my own hosting?'));
|
||||
expect(screen.getByText('Do I need my own hosting?').closest('details')).toHaveAttribute('open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClosingCta', () => {
|
||||
it('ends with one clear action', () => {
|
||||
render(<ClosingCta />);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Hand off the busywork. Keep the final say.' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('has one page heading, a nav and a footer', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument();
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes every call to action to sign-up or sign-in (PX-13)', () => {
|
||||
render(<HomePage />);
|
||||
const ctas = [...screen.getAllByRole('link', { name: /^(Get started|Sign in)$/ })];
|
||||
expect(ctas.length).toBeGreaterThanOrEqual(3);
|
||||
for (const a of ctas) expect(['/signup', '/signin']).toContain(a.getAttribute('href'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import DocsPage from '../../app/(marketing)/docs/page.tsx';
|
||||
import HowItWorksPage from '../../app/(marketing)/how-it-works/page.tsx';
|
||||
import PricingPage from '../../app/(marketing)/pricing/page.tsx';
|
||||
import ProductPage from '../../app/(marketing)/product/page.tsx';
|
||||
import TrustPage from '../../app/(marketing)/trust/page.tsx';
|
||||
import { AlwaysNever } from '../../src/marketing/pages/AlwaysNever.tsx';
|
||||
import { ComparePlans } from '../../src/marketing/pages/ComparePlans.tsx';
|
||||
import { FeatureGrid } from '../../src/marketing/pages/FeatureGrid.tsx';
|
||||
import { FeatureRow } from '../../src/marketing/pages/FeatureRow.tsx';
|
||||
import { PageHero } from '../../src/marketing/pages/PageHero.tsx';
|
||||
import { PopularArticles } from '../../src/marketing/pages/PopularArticles.tsx';
|
||||
import { PricingContent } from '../../src/marketing/pages/PricingContent.tsx';
|
||||
import { Button } from '../../src/marketing/Button.tsx';
|
||||
|
||||
const PAGES = [
|
||||
['Product', ProductPage, 'One workspace for every change.'],
|
||||
['How it works', HowItWorksPage, 'Four steps. You stay in charge.'],
|
||||
['Trust', TrustPage, 'Safe by design, not by promise.'],
|
||||
['Pricing', PricingPage, 'Simple plans. No surprises.'],
|
||||
['Docs', DocsPage, 'Find an answer fast.'],
|
||||
] as const;
|
||||
|
||||
describe.each(PAGES)('%s page', (_name, Page, heading) => {
|
||||
it('has one page heading, the navigation and the footer', () => {
|
||||
render(<Page />);
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||
expect(screen.getByRole('heading', { level: 1, name: heading })).toBeInTheDocument();
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument();
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends every Get started and Sign in to sign-up or sign-in (PX-13)', () => {
|
||||
render(<Page />);
|
||||
const ctas = screen.getAllByRole('link', { name: /^(Get started|Sign in)$/ });
|
||||
expect(ctas.length).toBeGreaterThanOrEqual(2);
|
||||
for (const a of ctas) expect(['/signup', '/signin']).toContain(a.getAttribute('href'));
|
||||
});
|
||||
|
||||
it('marks its own link in the navigation', () => {
|
||||
render(<Page />);
|
||||
const current = within(screen.getByRole('navigation', { name: 'Primary' })).queryAllByRole('link', { current: 'page' });
|
||||
expect(current.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageHero', () => {
|
||||
it('has the page heading, a supporting sentence and its actions', () => {
|
||||
render(
|
||||
<PageHero eyebrow="Product" title="One workspace for every change." subtitle="Talk to Wursor.">
|
||||
<Button href="/signup" size="lg">Get started</Button>
|
||||
</PageHero>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'One workspace for every change.' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Product')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Get started' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FeatureRow', () => {
|
||||
it('has a heading, bullets and a picture that is hidden from assistive technology', () => {
|
||||
const { container } = render(
|
||||
<FeatureRow eyebrow="Step 1" title="Start or connect your site" subtitle="Start on Cloud." bullets={['No jargon', 'Undo any time']}>
|
||||
<p>card</p>
|
||||
</FeatureRow>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Start or connect your site' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('listitem').map((i) => i.textContent)).toEqual(['No jargon', 'Undo any time']);
|
||||
expect(container.querySelector('[aria-hidden="true"][inert]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('can put the picture first', () => {
|
||||
const { container } = render(
|
||||
<FeatureRow eyebrow="Step 2" title="Ask" subtitle="x" bullets={[]} reverse>
|
||||
<p>card</p>
|
||||
</FeatureRow>,
|
||||
);
|
||||
expect(container.querySelector('.m-row')).toHaveClass('m-row-reverse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FeatureGrid', () => {
|
||||
it('shows a heading and one card per feature', () => {
|
||||
render(
|
||||
<FeatureGrid
|
||||
eyebrow="What you get"
|
||||
title="Everything you need."
|
||||
cards={[
|
||||
{ icon: 'sparkles', title: 'Ask in plain words', body: 'Describe the change.' },
|
||||
{ icon: 'undo', title: 'One-click undo', body: 'Take it back.' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Everything you need.' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent)).toEqual(['Ask in plain words', 'One-click undo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlwaysNever', () => {
|
||||
it('lists what Wursor always does and never does', () => {
|
||||
render(<AlwaysNever />);
|
||||
expect(screen.getByRole('list', { name: 'Always' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Never' })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole('list', { name: 'Always' })).getAllByRole('listitem')).toHaveLength(5);
|
||||
expect(within(screen.getByRole('list', { name: 'Never' })).getAllByRole('listitem')).toHaveLength(5);
|
||||
expect(screen.getByText('Publishes without your approval')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ComparePlans', () => {
|
||||
it('compares the three plans in a table', () => {
|
||||
render(<ComparePlans />);
|
||||
const table = screen.getByRole('table', { name: 'Compare plans' });
|
||||
expect(within(table).getAllByRole('columnheader').map((c) => c.textContent)).toEqual(['Feature', 'Starter', 'Studio', 'Agency']);
|
||||
expect(within(table).getAllByRole('row').length).toBeGreaterThan(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PopularArticles', () => {
|
||||
it('lists articles; only those with a page are links', () => {
|
||||
render(<PopularArticles />);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Popular articles' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Undo a published change')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'How approval works' })).toHaveAttribute('href', '/trust');
|
||||
expect(screen.queryByRole('link', { name: 'Invite your team' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pricing page content', () => {
|
||||
it('does not show prices, allowances or the free trial until they are decided (PX-13)', () => {
|
||||
render(<PricingPage />);
|
||||
expect(screen.queryByText(/\$\d/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/free for 14 days/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Plans and prices will be published before launch.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PricingContent when pricing is confirmed', () => {
|
||||
it('shows the free trial, the plan cards and the comparison table', () => {
|
||||
render(<PricingContent confirmed />);
|
||||
expect(screen.getByText(/Start free for 14 days/)).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('heading', { level: 3, name: /Starter|Studio|Agency/ })).toHaveLength(3);
|
||||
expect(screen.getByRole('table', { name: 'Compare plans' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Draft pricing. Final plans and prices will be confirmed before launch.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docs page content', () => {
|
||||
it('has a search field, six topics and the popular articles', () => {
|
||||
render(<DocsPage />);
|
||||
expect(screen.getByRole('searchbox')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Browse by topic' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(6);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Popular articles' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('How it works page content', () => {
|
||||
it('walks through the four steps', () => {
|
||||
render(<HowItWorksPage />);
|
||||
for (const step of ['Start or connect your site', 'Ask for a change', 'Review the proof', 'Publish, or undo']) {
|
||||
expect(screen.getByRole('heading', { level: 2, name: step })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trust page content', () => {
|
||||
it('shows the four rules, the boundaries, the record and the controls', () => {
|
||||
render(<TrustPage />);
|
||||
expect(screen.getByRole('heading', { level: 3, name: 'Works on a safe copy' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'What Wursor does, and what it never does.' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'A record you can read.' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'You decide who can do what.' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Product page content', () => {
|
||||
it('shows the workspace, six features and where the site lives', () => {
|
||||
render(<ProductPage />);
|
||||
expect(screen.getByRole('tab', { name: 'Conversation' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Everything you need to hand off the work.' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(6);
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Choose where it lives. Keep your hands free.' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SEARCH_ENTRIES, isPaletteShortcut, searchEntries, shortcutLabel, type SearchEntry } from '../../src/marketing/search.ts';
|
||||
|
||||
const entry = (id: string, title: string, description = '', group: SearchEntry['group'] = 'Page'): SearchEntry => ({
|
||||
id, title, description, group, href: `/${id}`,
|
||||
});
|
||||
|
||||
describe('searchEntries', () => {
|
||||
const entries = [
|
||||
entry('a', 'Pricing', 'Plans for every size'),
|
||||
entry('b', 'Review and approve', 'Compare before and after, then publish', 'Docs topic'),
|
||||
entry('c', 'Trust', 'We never publish without approval'),
|
||||
];
|
||||
|
||||
it('shows the pages when nothing has been typed', () => {
|
||||
expect(searchEntries('', entries).map((e) => e.id)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('ignores case and surrounding spaces', () => {
|
||||
expect(searchEntries(' PRICING ', entries).map((e) => e.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('finds an entry by its description', () => {
|
||||
expect(searchEntries('compare', entries).map((e) => e.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('needs every word to match', () => {
|
||||
expect(searchEntries('pricing banana', entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts title matches before description-only matches', () => {
|
||||
expect(searchEntries('approv', entries).map((e) => e.id)).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
it('puts titles that start with the query first', () => {
|
||||
const list = [entry('x', 'Undo a change'), entry('y', 'How to undo')];
|
||||
expect(searchEntries('undo', list).map((e) => e.id)).toEqual(['x', 'y']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SEARCH_ENTRIES', () => {
|
||||
it('only holds results that go somewhere', () => {
|
||||
expect(SEARCH_ENTRIES.length).toBeGreaterThan(0);
|
||||
for (const e of SEARCH_ENTRIES) expect(e.href).toMatch(/^\//);
|
||||
});
|
||||
|
||||
it('has unique ids', () => {
|
||||
const ids = SEARCH_ENTRIES.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('lists every navigation page', () => {
|
||||
const titles = searchEntries('').map((e) => e.title);
|
||||
expect(titles).toEqual(expect.arrayContaining(['Product', 'How it works', 'Trust and safety', 'Pricing', 'Docs']));
|
||||
});
|
||||
|
||||
it('finds a docs topic by what it covers', () => {
|
||||
expect(searchEntries('invite').map((e) => e.title)).toContain('Team and billing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPaletteShortcut', () => {
|
||||
const key = (k: string, mods: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean } = {}) =>
|
||||
isPaletteShortcut({ key: k, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...mods });
|
||||
|
||||
it('accepts Cmd+K and Ctrl+K', () => {
|
||||
expect(key('k', { metaKey: true })).toBe(true);
|
||||
expect(key('k', { ctrlKey: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a capital K (Caps Lock)', () => {
|
||||
expect(key('K', { metaKey: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects K alone, other letters, and extra modifiers', () => {
|
||||
expect(key('k')).toBe(false);
|
||||
expect(key('j', { metaKey: true })).toBe(false);
|
||||
expect(key('k', { metaKey: true, shiftKey: true })).toBe(false);
|
||||
expect(key('k', { ctrlKey: true, altKey: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcutLabel', () => {
|
||||
it('shows the Command symbol on Apple devices and Ctrl elsewhere', () => {
|
||||
expect(shortcutLabel(true)).toBe('⌘K');
|
||||
expect(shortcutLabel(false)).toBe('Ctrl K');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { THEME_KEY, applyTheme, currentTheme, readStoredTheme, themeInitScript } from '../../src/marketing/theme.ts';
|
||||
|
||||
describe('theme', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
it('has no stored theme until the visitor chooses one', () => {
|
||||
expect(readStoredTheme()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a stored value that is not light or dark', () => {
|
||||
localStorage.setItem(THEME_KEY, 'purple');
|
||||
expect(readStoredTheme()).toBeNull();
|
||||
});
|
||||
|
||||
it('applies a theme to the page and remembers it', () => {
|
||||
applyTheme('dark');
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
expect(readStoredTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('still applies the theme when storage is blocked', () => {
|
||||
const original = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = () => {
|
||||
throw new Error('blocked');
|
||||
};
|
||||
try {
|
||||
expect(() => applyTheme('light')).not.toThrow();
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'light');
|
||||
} finally {
|
||||
Storage.prototype.setItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('gives the head script that sets the saved theme before first paint', () => {
|
||||
localStorage.setItem(THEME_KEY, 'dark');
|
||||
new Function(themeInitScript)();
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
});
|
||||
|
||||
it('leaves the page on the device theme when nothing is saved', () => {
|
||||
new Function(themeInitScript)();
|
||||
expect(document.documentElement).not.toHaveAttribute('data-theme');
|
||||
});
|
||||
|
||||
it('treats a browser without matchMedia as light instead of crashing', () => {
|
||||
const original = window.matchMedia;
|
||||
// @ts-expect-error simulating an environment that lacks matchMedia
|
||||
window.matchMedia = undefined;
|
||||
try {
|
||||
expect(currentTheme()).toBe('light');
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ChangeEvent } from '../../src/product/ChangeEvent.tsx';
|
||||
import { CheckEvent } from '../../src/product/CheckEvent.tsx';
|
||||
import { Composer } from '../../src/product/Composer.tsx';
|
||||
import { ConversationPanel } from '../../src/product/ConversationPanel.tsx';
|
||||
import { Message } from '../../src/product/Message.tsx';
|
||||
import { Milestone } from '../../src/product/Milestone.tsx';
|
||||
import { StepGroup } from '../../src/product/StepGroup.tsx';
|
||||
import { StepLine } from '../../src/product/StepLine.tsx';
|
||||
import { Thought } from '../../src/product/Thought.tsx';
|
||||
|
||||
describe('Message', () => {
|
||||
it('puts what the owner said in a soft box', () => {
|
||||
render(<Message role="user">Make the phone number tappable.</Message>);
|
||||
expect(screen.getByText('Make the phone number tappable.').closest('.p-message')).toHaveClass('p-message-user');
|
||||
});
|
||||
|
||||
it('shows Wursor replies beside the Wursor avatar', () => {
|
||||
render(<Message role="wursor">On it.</Message>);
|
||||
expect(screen.getByRole('img', { name: 'Wursor' })).toBeInTheDocument();
|
||||
expect(screen.getByText('On it.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepGroup', () => {
|
||||
it('is collapsed by default and opens on press', async () => {
|
||||
render(<StepGroup title="Looked at 4 pages" count={4}>Home, Menu, Contact, About</StepGroup>);
|
||||
const toggle = screen.getByRole('button', { name: /Looked at 4 pages/ });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('Home, Menu, Contact, About')).not.toBeInTheDocument();
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('Home, Menu, Contact, About')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the count', () => {
|
||||
render(<StepGroup title="Looked at 4 pages" count={4} />);
|
||||
expect(screen.getByText('4')).toHaveClass('p-counter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Thought', () => {
|
||||
it('is collapsed by default, with the reasoning hidden', () => {
|
||||
render(<Thought seconds={6}>The number is plain text.</Thought>);
|
||||
expect(screen.getByRole('button', { name: 'Thought for 6 seconds' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('The number is plain text.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the reasoning in plain words when expanded', async () => {
|
||||
render(<Thought seconds={6}>The number is plain text.</Thought>);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Thought for 6 seconds' }));
|
||||
expect(screen.getByText('The number is plain text.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can start expanded', () => {
|
||||
render(<Thought seconds={6} defaultExpanded>The number is plain text.</Thought>);
|
||||
expect(screen.getByText('The number is plain text.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StepLine', () => {
|
||||
it('shows what Wursor did and how long it took', () => {
|
||||
render(<StepLine kind="edit" time="4s">Made the Contact page number a tap-to-call link</StepLine>);
|
||||
expect(screen.getByText('Made the Contact page number a tap-to-call link')).toBeInTheDocument();
|
||||
expect(screen.getByText('4s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks a running step so it is announced', () => {
|
||||
render(<StepLine kind="running" time="now">Taking a photo of the page on a phone</StepLine>);
|
||||
expect(screen.getByRole('status', { name: 'In progress' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChangeEvent', () => {
|
||||
const props = {
|
||||
title: 'Changed the Contact page',
|
||||
meta: '1 section · 2 places',
|
||||
risk: 'everyday',
|
||||
added: 8,
|
||||
removed: 2,
|
||||
} as const;
|
||||
|
||||
it('shows the change, its risk and how much changed', () => {
|
||||
render(<ChangeEvent {...props} />);
|
||||
expect(screen.getByText('Changed the Contact page')).toBeInTheDocument();
|
||||
expect(screen.getByText('Everyday')).toBeInTheDocument();
|
||||
expect(screen.getByText('+8 lines')).toBeInTheDocument();
|
||||
expect(screen.getByText('−2 lines')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the full comparison', async () => {
|
||||
const onSeeChanges = vi.fn();
|
||||
render(<ChangeEvent {...props} onSeeChanges={onSeeChanges} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'See what changed' }));
|
||||
expect(onSeeChanges).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CheckEvent', () => {
|
||||
it('reports a passed check quietly', () => {
|
||||
render(<CheckEvent result="passed" title="Checked 9 things. All passed." detail="Phone link works, 3 screen sizes." />);
|
||||
expect(screen.getByText('Checked 9 things. All passed.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Phone link works, 3 screen sizes.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Milestone', () => {
|
||||
it('marks a stage in the life of a change', () => {
|
||||
render(<Milestone>Plan ready</Milestone>);
|
||||
expect(screen.getByRole('separator', { name: 'Plan ready' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Composer', () => {
|
||||
it('invites a note while Wursor is working, and offers to stop', async () => {
|
||||
const onStop = vi.fn();
|
||||
render(<Composer state="working" autonomy="Ask first" onStop={onStop} />);
|
||||
expect(screen.getByPlaceholderText('Wursor is working. Add a note, or stop.')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Stop' }));
|
||||
expect(onStop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows the autonomy level and the toolbar actions', () => {
|
||||
render(<Composer state="working" autonomy="Ask first" />);
|
||||
expect(screen.getByRole('button', { name: 'Ask first' })).toBeInTheDocument();
|
||||
for (const name of ['Attach a file', 'Point at something on the page', 'Dictate']) {
|
||||
expect(screen.getByRole('button', { name })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets the owner keep typing while it works', async () => {
|
||||
render(<Composer state="working" autonomy="Ask first" />);
|
||||
await userEvent.type(screen.getByRole('textbox', { name: 'Message to Wursor' }), 'Also the header');
|
||||
expect(screen.getByRole('textbox', { name: 'Message to Wursor' })).toHaveValue('Also the header');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConversationPanel', () => {
|
||||
it('has a header with the three detail levels, and a stream', () => {
|
||||
render(
|
||||
<ConversationPanel density="steps" onDensityChange={() => {}}>
|
||||
<Message role="user">Hello</Message>
|
||||
</ConversationPanel>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Conversation' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Steps' })).toBeChecked();
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports a change of detail level', async () => {
|
||||
const onDensityChange = vi.fn();
|
||||
render(<ConversationPanel density="steps" onDensityChange={onDensityChange} />);
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
|
||||
expect(onDensityChange).toHaveBeenCalledWith('everything');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ActivityCard } from '../../src/product/ActivityCard.tsx';
|
||||
import { Kbd } from '../../src/product/Kbd.tsx';
|
||||
import { SearchField } from '../../src/product/SearchField.tsx';
|
||||
import { SetupChecklist } from '../../src/product/SetupChecklist.tsx';
|
||||
|
||||
const STEPS = [
|
||||
{ state: 'done', title: 'Site address added', detail: 'harborbakery.com' },
|
||||
{ state: 'done', title: 'Connection verified', detail: 'Secure, read and write' },
|
||||
{ state: 'next', title: 'Safe copy ready', detail: 'Wursor is creating it' },
|
||||
{ state: 'todo', title: 'Make your first change', detail: 'Pick a suggestion' },
|
||||
] as const;
|
||||
|
||||
describe('SetupChecklist', () => {
|
||||
it('lists the steps in order, saying which is done, which is next and which is to do', () => {
|
||||
render(<SetupChecklist steps={STEPS} />);
|
||||
const items = within(screen.getByRole('list', { name: 'Setup steps' })).getAllByRole('listitem');
|
||||
expect(items).toHaveLength(4);
|
||||
expect(items.map((i) => i.getAttribute('data-state'))).toEqual(['done', 'done', 'next', 'todo']);
|
||||
expect(within(items[0]!).getByText('Done')).toBeInTheDocument();
|
||||
expect(within(items[2]!).getByText('Next')).toBeInTheDocument();
|
||||
expect(within(items[3]!).getByText('To do')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the next step as the current one', () => {
|
||||
render(<SetupChecklist steps={STEPS} />);
|
||||
expect(screen.getByText('Safe copy ready').closest('li')).toHaveAttribute('aria-current', 'step');
|
||||
});
|
||||
|
||||
it('offers Continue on the next step only', async () => {
|
||||
const onContinue = vi.fn();
|
||||
render(<SetupChecklist steps={STEPS} onContinue={onContinue} />);
|
||||
expect(screen.getAllByRole('button', { name: 'Continue' })).toHaveLength(1);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
expect(onContinue).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActivityCard', () => {
|
||||
const rows = [
|
||||
{ kind: 'published', text: 'Published the Contact page', time: 'Just now' },
|
||||
{ kind: 'check', text: 'Checked 9 things, all passed', time: '2 min ago' },
|
||||
{ kind: 'change', text: 'Changed the Contact page', time: '3 min ago' },
|
||||
{ kind: 'safe-copy', text: 'Made a safe copy of your site', time: '5 min ago' },
|
||||
] as const;
|
||||
|
||||
it('shows what happened and when, newest first', () => {
|
||||
render(<ActivityCard rows={rows} />);
|
||||
const items = within(screen.getByRole('list', { name: 'Activity' })).getAllByRole('listitem');
|
||||
expect(items.map((i) => i.querySelector('.p-activity-text')?.textContent)).toEqual(rows.map((r) => r.text));
|
||||
expect(within(items[0]!).getByText('Just now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives each kind its own icon', () => {
|
||||
render(<ActivityCard rows={rows} />);
|
||||
const kinds = screen.getAllByRole('listitem').map((i) => i.getAttribute('data-kind'));
|
||||
expect(kinds).toEqual(['published', 'check', 'change', 'safe-copy']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kbd and SearchField', () => {
|
||||
it('shows a key cap', () => {
|
||||
render(<Kbd>⌘K</Kbd>);
|
||||
expect(screen.getByText('⌘K').tagName).toBe('KBD');
|
||||
});
|
||||
|
||||
it('is a labelled search box with a shortcut hint', async () => {
|
||||
render(<SearchField label="Search the docs" shortcut="⌘K" />);
|
||||
const box = screen.getByRole('searchbox', { name: 'Search the docs' });
|
||||
expect(box).toHaveAttribute('placeholder', 'Search or ask…');
|
||||
expect(screen.getByText('⌘K')).toBeInTheDocument();
|
||||
await userEvent.type(box, 'undo');
|
||||
expect(box).toHaveValue('undo');
|
||||
});
|
||||
|
||||
it('can be shown but not used until search exists', () => {
|
||||
render(<SearchField label="Search the docs" disabled />);
|
||||
expect(screen.getByRole('searchbox', { name: 'Search the docs' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Avatar } from '../../src/product/Avatar.tsx';
|
||||
import { Button } from '../../src/product/Button.tsx';
|
||||
import { Counter } from '../../src/product/Counter.tsx';
|
||||
import { IconButton } from '../../src/product/IconButton.tsx';
|
||||
import { RiskChip } from '../../src/product/RiskChip.tsx';
|
||||
import { SegmentedControl } from '../../src/product/SegmentedControl.tsx';
|
||||
import { StatusPill } from '../../src/product/StatusPill.tsx';
|
||||
import { Tab } from '../../src/product/Tab.tsx';
|
||||
import { Tag } from '../../src/product/Tag.tsx';
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('shows initials for a person', () => {
|
||||
render(<Avatar kind="initials" initials="AO" name="Amara Okoye" />);
|
||||
expect(screen.getByRole('img', { name: 'Amara Okoye' })).toHaveTextContent('AO');
|
||||
});
|
||||
|
||||
it('shows the brand mark for Wursor', () => {
|
||||
render(<Avatar kind="wursor" />);
|
||||
expect(screen.getByRole('img', { name: 'Wursor' })).toHaveClass('p-avatar-wursor');
|
||||
});
|
||||
|
||||
it('has a small and a medium size', () => {
|
||||
render(<Avatar kind="initials" initials="AO" name="A" size="md" />);
|
||||
expect(screen.getByRole('img', { name: 'A' })).toHaveClass('p-avatar-md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Counter', () => {
|
||||
it('shows the count', () => {
|
||||
render(<Counter count={3} />);
|
||||
expect(screen.getByText('3')).toHaveClass('p-counter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusPill', () => {
|
||||
it.each([
|
||||
['idea', 'Idea'],
|
||||
['working', 'Working'],
|
||||
['needs-you', 'Needs you'],
|
||||
['done', 'Done'],
|
||||
] as const)('labels the %s status in plain words', (status, label) => {
|
||||
render(<StatusPill status={status} />);
|
||||
expect(screen.getByText(label).closest('.p-status')).toHaveAttribute('data-status', status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RiskChip', () => {
|
||||
it('shows the plain-word risk level', () => {
|
||||
render(<RiskChip level="everyday" />);
|
||||
expect(screen.getByText('Everyday')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('is named by its label and calls onClick', async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<IconButton icon="search" label="Search" onClick={onClick} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('supports the inverse variant', () => {
|
||||
render(<IconButton icon="stop" label="Stop" variant="inverse" />);
|
||||
expect(screen.getByRole('button', { name: 'Stop' })).toHaveClass('p-iconbtn-inverse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders its label and calls onClick', async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>See what changed</Button>);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'See what changed' }));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('defaults to secondary and small, and supports primary and ghost', () => {
|
||||
const { rerender } = render(<Button>A</Button>);
|
||||
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-secondary', 'p-btn-sm');
|
||||
rerender(<Button variant="primary" size="md">A</Button>);
|
||||
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-primary', 'p-btn-md');
|
||||
rerender(<Button variant="ghost">A</Button>);
|
||||
expect(screen.getByRole('button', { name: 'A' })).toHaveClass('p-btn-ghost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SegmentedControl', () => {
|
||||
const options = [
|
||||
{ value: 'headline', label: 'Headline' },
|
||||
{ value: 'steps', label: 'Steps' },
|
||||
{ value: 'everything', label: 'Everything' },
|
||||
];
|
||||
|
||||
it('is a radio group with the current choice checked', () => {
|
||||
render(<SegmentedControl label="Detail" options={options} value="steps" onChange={() => {}} />);
|
||||
expect(screen.getByRole('radiogroup', { name: 'Detail' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Steps' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: 'Headline' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('reports the chosen value', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SegmentedControl label="Detail" options={options} value="steps" onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
|
||||
expect(onChange).toHaveBeenCalledWith('everything');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab', () => {
|
||||
it('marks the selected tab', () => {
|
||||
render(
|
||||
<div role="tablist">
|
||||
<Tab label="Preview" selected />
|
||||
<Tab label="Compare" />
|
||||
</div>,
|
||||
);
|
||||
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: 'Compare' })).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
it('can show a count', () => {
|
||||
render(<Tab label="Proof" count={3} />);
|
||||
expect(screen.getByRole('tab', { name: /Proof/ })).toHaveTextContent('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag', () => {
|
||||
it('shows its label', () => {
|
||||
render(<Tag label="Draft copy" />);
|
||||
expect(screen.getByText('Draft copy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can be removed when closable', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Tag label="Draft copy" closable onClose={onClose} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Remove Draft copy' }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ApprovalBar } from '../../src/product/ApprovalBar.tsx';
|
||||
import { BoardCard } from '../../src/product/BoardCard.tsx';
|
||||
import { BoardStrip } from '../../src/product/BoardStrip.tsx';
|
||||
import { CompareSlider } from '../../src/product/CompareSlider.tsx';
|
||||
import { DeviceFrame } from '../../src/product/DeviceFrame.tsx';
|
||||
import { PreviewToolbar } from '../../src/product/PreviewToolbar.tsx';
|
||||
import { Sidebar } from '../../src/product/Sidebar.tsx';
|
||||
import { SiteMock } from '../../src/product/SiteMock.tsx';
|
||||
import { TopBar } from '../../src/product/TopBar.tsx';
|
||||
import { Workspace } from '../../src/product/Workspace.tsx';
|
||||
import { HARBOR } from '../../src/product/harbor-bakery.ts';
|
||||
|
||||
describe('TopBar', () => {
|
||||
it('shows the site, the current change and its status', () => {
|
||||
render(<TopBar site="Harbor Bakery" siteInitial="H" change="Make phone number tappable" status="working" autonomy="Ask first" account={{ initials: 'AO', name: 'Amara Okoye' }} />);
|
||||
expect(screen.getByRole('button', { name: /Harbor Bakery/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('Make phone number tappable')).toBeInTheDocument();
|
||||
expect(screen.getByText('Working')).toBeInTheDocument();
|
||||
for (const name of ['Toggle sidebar', 'Search', 'Notifications']) {
|
||||
expect(screen.getByRole('button', { name })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sidebar', () => {
|
||||
const changes = [
|
||||
{ title: 'Make phone number tappable', status: 'working' as const, active: true },
|
||||
{ title: 'Update opening hours', status: 'needs-you' as const },
|
||||
{ title: 'Describe 3 images', status: 'idea' as const },
|
||||
{ title: 'Refresh footer links', status: 'done' as const },
|
||||
];
|
||||
|
||||
it('has the four places and the list of changes', () => {
|
||||
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
|
||||
const nav = screen.getByRole('navigation', { name: 'Workspace' });
|
||||
expect(within(nav).getAllByRole('link').map((a) => a.textContent)).toEqual(['Home', 'Board', 'Activity', 'Sites']);
|
||||
expect(screen.getByRole('list', { name: 'Changes' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('marks the open change', () => {
|
||||
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
|
||||
expect(screen.getByText('Make phone number tappable').closest('[aria-current]')).toHaveAttribute('aria-current', 'true');
|
||||
});
|
||||
|
||||
it('shows the account', () => {
|
||||
render(<Sidebar changes={changes} account={{ initials: 'AO', name: 'Amara Okoye', role: 'Owner' }} />);
|
||||
expect(screen.getByText('Amara Okoye')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PreviewToolbar', () => {
|
||||
it('has Preview, Compare and Proof tabs with the draft tag and device buttons', () => {
|
||||
render(<PreviewToolbar selected="preview" tag="Draft copy" />);
|
||||
expect(screen.getByRole('tablist', { name: 'View' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Preview' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Proof' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Draft copy')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Phone' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SiteMock and DeviceFrame', () => {
|
||||
it('shows the Harbor Bakery contact page', () => {
|
||||
render(<SiteMock state="after" />);
|
||||
expect(screen.getByText('Find us on Quay Street')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fresh bread from 6:30, every morning.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('before the change the number is plain text, after it is a tap-to-call link', () => {
|
||||
const { rerender } = render(<SiteMock state="before" />);
|
||||
expect(screen.queryByRole('link', { name: /Call \(555\) 014-2290/ })).not.toBeInTheDocument();
|
||||
rerender(<SiteMock state="after" />);
|
||||
expect(screen.getByRole('link', { name: /Call \(555\) 014-2290/ })).toHaveAttribute('href', 'tel:+55550142290');
|
||||
});
|
||||
|
||||
it('frames the page in a browser bar with its address', () => {
|
||||
render(<DeviceFrame url="harborbakery.com/contact"><SiteMock state="after" /></DeviceFrame>);
|
||||
expect(screen.getByText('harborbakery.com/contact')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BoardCard and BoardStrip', () => {
|
||||
it('asks for the owner when a change needs them', () => {
|
||||
render(<BoardCard status="needs-you" title="Update opening hours" meta="Home page · 1 hour ago" />);
|
||||
expect(screen.getByText('Wursor is waiting for your answer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs you')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows risk and progress on a working change', () => {
|
||||
render(<BoardCard status="working" title="Make phone number tappable" meta="Contact page · 1 min ago" risk="everyday" progress={0.62} />);
|
||||
expect(screen.getByText('Everyday')).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: 'Make phone number tappable' })).toHaveAttribute('aria-valuenow', '62');
|
||||
});
|
||||
|
||||
it('groups cards into the four lanes with counts', () => {
|
||||
render(<BoardStrip lanes={HARBOR.board} />);
|
||||
for (const lane of ['Needs you', 'Working', 'Idea', 'Done']) {
|
||||
expect(screen.getByRole('group', { name: lane })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText('Open board')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompareSlider', () => {
|
||||
it('is a slider the keyboard can move, labelled in plain words', async () => {
|
||||
render(<CompareSlider before={<span>old</span>} after={<span>new</span>} defaultPosition={50} />);
|
||||
const slider = screen.getByRole('slider', { name: 'Compare before and after' });
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '50');
|
||||
slider.focus();
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '55');
|
||||
await userEvent.keyboard('{ArrowLeft}{ArrowLeft}');
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '45');
|
||||
});
|
||||
|
||||
it('labels both sides', () => {
|
||||
render(<CompareSlider before={<span>old</span>} after={<span>new</span>} />);
|
||||
expect(screen.getByText('Before')).toBeInTheDocument();
|
||||
expect(screen.getByText('After')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApprovalBar', () => {
|
||||
it('says what is ready and offers one clear action', async () => {
|
||||
const onApprove = vi.fn();
|
||||
render(<ApprovalBar title="Ready for you" meta="2 changes · Everyday risk" onApprove={onApprove} />);
|
||||
expect(screen.getByText('Ready for you')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 changes · Everyday risk')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Approve and publish' }));
|
||||
expect(onApprove).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workspace', () => {
|
||||
it('assembles the top bar, sidebar, conversation, preview and board', () => {
|
||||
render(<Workspace defaultDensity="steps" />);
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||
expect(screen.getByRole('navigation', { name: 'Workspace' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Conversation' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tablist', { name: 'View' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Board' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes what it shows when the owner picks another detail level', async () => {
|
||||
render(<Workspace defaultDensity="steps" />);
|
||||
expect(screen.queryByText('Looked at home page')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Everything' }));
|
||||
expect(screen.getByText('Looked at home page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows less at the headline level and more at everything', () => {
|
||||
const { unmount } = render(<Workspace defaultDensity="headline" />);
|
||||
expect(screen.getByRole('separator', { name: 'Plan ready' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Looked at home page')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
render(<Workspace defaultDensity="everything" />);
|
||||
expect(screen.getByText('Looked at home page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/styles/tokens.css'), 'utf8');
|
||||
|
||||
/** Returns the declarations of the first block whose selector contains `selector`. */
|
||||
function block(selector: string): Record<string, string> {
|
||||
const start = css.indexOf(selector);
|
||||
if (start === -1) return {};
|
||||
const open = css.indexOf('{', start);
|
||||
const close = css.indexOf('}', open);
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of css.slice(open + 1, close).matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
|
||||
out[m[1]!] = m[2]!.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const light = block(':root {');
|
||||
const dark = block(':root[data-theme="dark"]');
|
||||
const auto = block(':root:not([data-theme="light"])');
|
||||
|
||||
const COLOR_TOKENS = [
|
||||
'--color-bg-canvas', '--color-bg-surface', '--color-bg-sunken', '--color-bg-inverse',
|
||||
'--color-text-primary', '--color-text-secondary', '--color-text-tertiary', '--color-text-inverse',
|
||||
'--color-text-on-accent', '--color-text-link',
|
||||
'--color-border-subtle', '--color-border-default', '--color-border-strong',
|
||||
'--color-accent-default', '--color-accent-fill', '--color-accent-text', '--color-accent-subtle',
|
||||
'--color-status-working-dot', '--color-status-working-fg', '--color-status-working-bg',
|
||||
'--color-status-needs-you-dot', '--color-status-needs-you-fg', '--color-status-needs-you-bg',
|
||||
'--color-status-success-dot', '--color-status-success-fg', '--color-status-success-bg',
|
||||
'--color-diff-add-fg', '--color-diff-remove-fg', '--icon-color',
|
||||
];
|
||||
|
||||
describe('design tokens', () => {
|
||||
it('defines every color token for the light theme', () => {
|
||||
expect(COLOR_TOKENS.filter((t) => !(t in light))).toEqual([]);
|
||||
});
|
||||
|
||||
it('defines every color token for the dark theme (explicit override)', () => {
|
||||
expect(COLOR_TOKENS.filter((t) => !(t in dark))).toEqual([]);
|
||||
});
|
||||
|
||||
it('follows the device setting for dark unless the light theme is forced', () => {
|
||||
expect(COLOR_TOKENS.filter((t) => !(t in auto))).toEqual([]);
|
||||
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/);
|
||||
});
|
||||
|
||||
it('uses the Figma light values', () => {
|
||||
expect(light['--color-bg-canvas']).toBe('#f7f7f4');
|
||||
expect(light['--color-accent-default']).toBe('#e8590c');
|
||||
expect(light['--color-text-primary']).toBe('#14120b');
|
||||
});
|
||||
|
||||
it('uses the Figma dark values', () => {
|
||||
expect(dark['--color-bg-canvas']).toBe('#0e0e0c');
|
||||
expect(dark['--color-accent-default']).toBe('#ff7a33');
|
||||
});
|
||||
|
||||
it('keeps dark and automatic dark in agreement', () => {
|
||||
expect(auto).toEqual(dark);
|
||||
});
|
||||
|
||||
it('defines the space, radius and control scales', () => {
|
||||
expect(light['--space-4']).toBe('16px');
|
||||
expect(light['--radius-full']).toBe('999px');
|
||||
expect(light['--size-control-lg']).toBe('44px');
|
||||
});
|
||||
|
||||
it('defines the Figma type styles as classes', () => {
|
||||
for (const name of ['display-xl', 'display-l', 'display-m', 'title-l', 'title-m', 'title-s',
|
||||
'body-l', 'body-m', 'body-m-strong', 'label-m', 'label-s', 'caption', 'overline', 'mono-s']) {
|
||||
expect(css).toContain(`.t-${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('converts Figma percent tracking to em (Display/XL -3 is -0.03em)', () => {
|
||||
expect(css).toMatch(/\.t-display-xl\s*\{[^}]*letter-spacing:\s*-0\.03em/);
|
||||
});
|
||||
});
|
||||
|
||||
/** WCAG 2.x relative luminance and contrast ratio. */
|
||||
function luminance(hex: string): number {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as [number, number, number];
|
||||
const f = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
function contrast(a: string, b: string): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
/** Text token on the background token it appears on. All are small text, so AA is 4.5:1 (PX-10). */
|
||||
const PAIRS: ReadonlyArray<[string, string]> = [
|
||||
['--color-text-primary', '--color-bg-canvas'], ['--color-text-primary', '--color-bg-surface'],
|
||||
['--color-text-primary', '--color-bg-sunken'], ['--color-text-primary', '--color-accent-subtle'],
|
||||
['--color-text-secondary', '--color-bg-canvas'], ['--color-text-secondary', '--color-bg-surface'],
|
||||
['--color-text-secondary', '--color-bg-sunken'], ['--color-text-secondary', '--color-accent-subtle'],
|
||||
['--color-text-tertiary', '--color-bg-canvas'], ['--color-text-tertiary', '--color-bg-surface'],
|
||||
['--color-text-tertiary', '--color-bg-sunken'],
|
||||
['--color-text-inverse', '--color-bg-inverse'],
|
||||
['--color-text-on-accent', '--color-accent-fill'],
|
||||
['--color-accent-text', '--color-bg-canvas'], ['--color-accent-text', '--color-bg-surface'],
|
||||
['--color-accent-text', '--color-accent-subtle'],
|
||||
['--color-text-link', '--color-bg-canvas'], ['--color-text-link', '--color-bg-surface'],
|
||||
['--color-text-link', '--color-bg-sunken'],
|
||||
['--color-status-working-fg', '--color-status-working-bg'],
|
||||
['--color-status-needs-you-fg', '--color-status-needs-you-bg'],
|
||||
['--color-status-success-fg', '--color-status-success-bg'],
|
||||
['--color-diff-add-fg', '--color-bg-surface'], ['--color-diff-remove-fg', '--color-bg-surface'],
|
||||
];
|
||||
|
||||
describe('contrast (WCAG 2.2 AA, PX-10)', () => {
|
||||
for (const [theme, tokens] of [['light', light], ['dark', dark]] as const) {
|
||||
it(`every text pair is at least 4.5:1 in the ${theme} theme`, () => {
|
||||
const failures = PAIRS.map(([fg, bg]) => ({ fg, bg, ratio: contrast(tokens[fg]!, tokens[bg]!) }))
|
||||
.filter((p) => p.ratio < 4.5)
|
||||
.map((p) => `${p.fg} on ${p.bg} = ${p.ratio.toFixed(2)}`);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import '../../../src/styles/global.css';
|
||||
|
||||
export const metadata = { title: 'Wursor' };
|
||||
|
||||
// Interim product shell from Sprint 1. Its own root layout keeps its dark theme
|
||||
// from leaking into the marketing site. Replaced by the designed screens.
|
||||
export default function LegacyLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LegacyApp } from '../../../src/LegacyApp.tsx';
|
||||
|
||||
export default function Page() {
|
||||
return <LegacyApp />;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx';
|
||||
import { FeatureGrid } from '../../../src/marketing/pages/FeatureGrid.tsx';
|
||||
import { PageHero } from '../../../src/marketing/pages/PageHero.tsx';
|
||||
import { PopularArticles } from '../../../src/marketing/pages/PopularArticles.tsx';
|
||||
import { DocsSearch } from '../../../src/marketing/pages/DocsSearch.tsx';
|
||||
import { Shell } from '../../../src/marketing/Shell.tsx';
|
||||
import { DOCS_TOPICS } from '../../../src/marketing/content.ts';
|
||||
|
||||
export const metadata: Metadata = { title: 'Docs' };
|
||||
|
||||
export default function DocsPage() {
|
||||
return (
|
||||
<Shell page>
|
||||
<PageHero
|
||||
eyebrow="Docs"
|
||||
title="Find an answer fast."
|
||||
subtitle="Guides for starting or connecting a site, moving between hosts, asking for changes and staying in control."
|
||||
>
|
||||
<div className="m-docs-search">
|
||||
{/* Opens the site search; it covers pages and topics until the articles are written. */}
|
||||
<DocsSearch />
|
||||
</div>
|
||||
</PageHero>
|
||||
<FeatureGrid
|
||||
title="Browse by topic"
|
||||
cards={DOCS_TOPICS}
|
||||
/>
|
||||
<PopularArticles />
|
||||
<ClosingCta />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { ActivityCard } from '../../../src/product/ActivityCard.tsx';
|
||||
import { ApprovalCard } from '../../../src/product/ApprovalCard.tsx';
|
||||
import { HarborConversation } from '../../../src/product/HarborConversation.tsx';
|
||||
import { SetupChecklist } from '../../../src/product/SetupChecklist.tsx';
|
||||
import { Zoom } from '../../../src/ui/Zoom.tsx';
|
||||
import { Button } from '../../../src/marketing/Button.tsx';
|
||||
import { ROUTES } from '../../../src/marketing/content.ts';
|
||||
import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx';
|
||||
import { Faq } from '../../../src/marketing/home/Faq.tsx';
|
||||
import { FeatureRow } from '../../../src/marketing/pages/FeatureRow.tsx';
|
||||
import { PageHero } from '../../../src/marketing/pages/PageHero.tsx';
|
||||
import { ACTIVITY_ROWS, SETUP_STEPS } from '../../../src/marketing/pages/setup-steps.ts';
|
||||
import { Shell } from '../../../src/marketing/Shell.tsx';
|
||||
|
||||
export const metadata: Metadata = { title: 'How it works' };
|
||||
|
||||
export default function HowItWorksPage() {
|
||||
return (
|
||||
<Shell page>
|
||||
<PageHero
|
||||
eyebrow="How it works"
|
||||
title="Four steps. You stay in charge."
|
||||
subtitle="From “please change this” to a live, checked page, with your approval in the middle."
|
||||
>
|
||||
<Button href={ROUTES.signUp} size="lg">
|
||||
Get started
|
||||
</Button>
|
||||
</PageHero>
|
||||
<FeatureRow
|
||||
eyebrow="Step 1"
|
||||
title="Start or connect your site"
|
||||
subtitle="Start on Wursor Cloud or connect your WordPress. Wursor works on a safe copy."
|
||||
bullets={['Standard themes and plugins just work, on either host', 'Access you can revoke at any time']}
|
||||
>
|
||||
<SetupChecklist steps={SETUP_STEPS} />
|
||||
</FeatureRow>
|
||||
<FeatureRow
|
||||
reverse
|
||||
eyebrow="Step 2"
|
||||
title="Ask for a change"
|
||||
subtitle="Type what you want in plain words, or point at the page. Wursor explains each step as it goes."
|
||||
bullets={['No jargon, no tickets', 'Watch at the level of detail you like']}
|
||||
card={{ left: 76, top: 30, width: 448, height: 424 }}
|
||||
>
|
||||
<div className="m-convowindow">
|
||||
<Zoom ratio={448 / 440}>
|
||||
<div style={{ width: 440, height: 852 }}>
|
||||
<HarborConversation />
|
||||
</div>
|
||||
</Zoom>
|
||||
</div>
|
||||
</FeatureRow>
|
||||
<FeatureRow
|
||||
eyebrow="Step 3"
|
||||
title="Review the proof"
|
||||
subtitle="Drag to compare before and after, read what Wursor checked, then approve or ask for changes."
|
||||
bullets={['Nine plain-language checks', 'Looks right on a phone, tablet and laptop']}
|
||||
card={{ left: 60, top: 20, width: 480 }}
|
||||
>
|
||||
<ApprovalCard />
|
||||
</FeatureRow>
|
||||
<FeatureRow
|
||||
reverse
|
||||
eyebrow="Step 4"
|
||||
title="Publish, or undo"
|
||||
subtitle="One click puts it live. Changed your mind? One click takes it back, and the record shows what happened."
|
||||
bullets={['Nothing goes live without you', 'Every step is recorded']}
|
||||
card={{ left: 70, top: 119, width: 460 }}
|
||||
>
|
||||
<ActivityCard rows={ACTIVITY_ROWS} />
|
||||
</FeatureRow>
|
||||
<Faq />
|
||||
<ClosingCta />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { CommandPaletteProvider } from '../../src/marketing/CommandPalette.tsx';
|
||||
import { themeInitScript } from '../../src/marketing/theme.ts';
|
||||
import '../../src/styles/tokens.css';
|
||||
import '../../src/styles/marketing.css';
|
||||
import '../../src/styles/product.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: 'Wursor', template: '%s · Wursor' },
|
||||
description: 'Your website, handled. Tell Wursor what to change. Watch it work. Approve what you like.',
|
||||
};
|
||||
|
||||
export default function MarketingLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
// suppressHydrationWarning: the head script sets data-theme on <html> before React hydrates.
|
||||
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`} suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
</head>
|
||||
<body>
|
||||
<CommandPaletteProvider>{children}</CommandPaletteProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Shell } from '../../src/marketing/Shell.tsx';
|
||||
import { ApproveWithProof } from '../../src/marketing/home/ApproveWithProof.tsx';
|
||||
import { ClosingCta } from '../../src/marketing/home/ClosingCta.tsx';
|
||||
import { Examples } from '../../src/marketing/home/Examples.tsx';
|
||||
import { Faq } from '../../src/marketing/home/Faq.tsx';
|
||||
import { Hero } from '../../src/marketing/home/Hero.tsx';
|
||||
import { PricingTeaser } from '../../src/marketing/home/PricingTeaser.tsx';
|
||||
import { ProofStrip } from '../../src/marketing/home/ProofStrip.tsx';
|
||||
import { SafeByDesign } from '../../src/marketing/home/SafeByDesign.tsx';
|
||||
import { SectionHeader } from '../../src/marketing/home/SectionHeader.tsx';
|
||||
import { SurfacesShowcase } from '../../src/marketing/home/SurfacesShowcase.tsx';
|
||||
import { WatchTheWork } from '../../src/marketing/home/WatchTheWork.tsx';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Shell>
|
||||
<Hero />
|
||||
<ProofStrip />
|
||||
<section className="m-section m-section-first">
|
||||
<SectionHeader
|
||||
eyebrow="One screen"
|
||||
title="Talk. Watch. Decide."
|
||||
subtitle="The workspace puts the conversation, your live site and the to-do board side by side, so you always know what Wursor is doing."
|
||||
mobileSubtitle="The workspace puts the conversation, your live site and the board side by side."
|
||||
/>
|
||||
<SurfacesShowcase />
|
||||
</section>
|
||||
<WatchTheWork />
|
||||
<ApproveWithProof />
|
||||
<SafeByDesign />
|
||||
<Examples />
|
||||
<PricingTeaser />
|
||||
<Faq />
|
||||
<ClosingCta />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { PricingContent } from '../../../src/marketing/pages/PricingContent.tsx';
|
||||
import { Shell } from '../../../src/marketing/Shell.tsx';
|
||||
|
||||
export const metadata: Metadata = { title: 'Pricing' };
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<Shell page>
|
||||
<PricingContent />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { SetupChecklist } from '../../../src/product/SetupChecklist.tsx';
|
||||
import { Button } from '../../../src/marketing/Button.tsx';
|
||||
import { ROUTES } from '../../../src/marketing/content.ts';
|
||||
import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx';
|
||||
import { Faq } from '../../../src/marketing/home/Faq.tsx';
|
||||
import { SurfacesShowcase } from '../../../src/marketing/home/SurfacesShowcase.tsx';
|
||||
import { FeatureGrid } from '../../../src/marketing/pages/FeatureGrid.tsx';
|
||||
import { FeatureRow } from '../../../src/marketing/pages/FeatureRow.tsx';
|
||||
import { PageHero } from '../../../src/marketing/pages/PageHero.tsx';
|
||||
import { SETUP_STEPS } from '../../../src/marketing/pages/setup-steps.ts';
|
||||
import { Shell } from '../../../src/marketing/Shell.tsx';
|
||||
|
||||
export const metadata: Metadata = { title: 'Product' };
|
||||
|
||||
export default function ProductPage() {
|
||||
return (
|
||||
<Shell page>
|
||||
<PageHero
|
||||
eyebrow="Product"
|
||||
title="One workspace for every change."
|
||||
subtitle="Talk to Wursor, watch your site change on a safe copy, and approve what you like."
|
||||
>
|
||||
<Button href={ROUTES.signUp} size="lg">
|
||||
Get started
|
||||
</Button>
|
||||
<Button href="/how-it-works" variant="ghost" size="lg" trailingIcon="arrow-right">
|
||||
See how it works
|
||||
</Button>
|
||||
</PageHero>
|
||||
<section className="m-grid-section m-grid-first">
|
||||
<SurfacesShowcase />
|
||||
</section>
|
||||
<FeatureGrid
|
||||
eyebrow="What you get"
|
||||
title="Everything you need to hand off the work."
|
||||
subtitle="Six things that make a change small, visible and reversible."
|
||||
cards={[
|
||||
{ icon: 'sparkles', title: 'Ask in plain words', body: 'Describe the change the way you would tell a colleague. Wursor works out the steps.' },
|
||||
{ icon: 'monitor', title: 'Live preview on every device', body: 'See the real page on a phone, a tablet and a laptop before anything goes live.' },
|
||||
{ icon: 'board', title: 'A board for every change', body: 'Ideas, work in progress and finished changes in one place, with what needs you on top.' },
|
||||
{ icon: 'shield-check', title: 'Proof before you publish', body: 'Nine checks, a before-and-after slider and a plain summary of what changed.' },
|
||||
{ icon: 'undo', title: 'One-click undo', body: 'Every published change can be taken back in one click, any time.' },
|
||||
{ icon: 'user', title: 'Built for teams', body: 'Invite teammates, choose who can approve, and keep one shared record.' },
|
||||
]}
|
||||
/>
|
||||
<FeatureRow
|
||||
eyebrow="Start"
|
||||
title="Choose where it lives. Keep your hands free."
|
||||
subtitle="Start on Wursor Cloud, connect your WordPress, or build here and publish to yours. Wursor works on a safe copy and never changes your live site without you."
|
||||
bullets={[
|
||||
'Works with standard WordPress themes and plugins, hosted by us or by you',
|
||||
'Access you can pause or revoke at any time',
|
||||
'Nothing installed on your visitors’ side',
|
||||
]}
|
||||
>
|
||||
<SetupChecklist steps={SETUP_STEPS} />
|
||||
</FeatureRow>
|
||||
<Faq />
|
||||
<ClosingCta />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { ActivityCard } from '../../../src/product/ActivityCard.tsx';
|
||||
import { Button } from '../../../src/marketing/Button.tsx';
|
||||
import { ROUTES } from '../../../src/marketing/content.ts';
|
||||
import { ClosingCta } from '../../../src/marketing/home/ClosingCta.tsx';
|
||||
import { Faq } from '../../../src/marketing/home/Faq.tsx';
|
||||
import { SafetyRules } from '../../../src/marketing/home/SafeByDesign.tsx';
|
||||
import { AlwaysNever } from '../../../src/marketing/pages/AlwaysNever.tsx';
|
||||
import { FeatureGrid } from '../../../src/marketing/pages/FeatureGrid.tsx';
|
||||
import { FeatureRow } from '../../../src/marketing/pages/FeatureRow.tsx';
|
||||
import { PageHero } from '../../../src/marketing/pages/PageHero.tsx';
|
||||
import { ACTIVITY_ROWS } from '../../../src/marketing/pages/setup-steps.ts';
|
||||
import { Shell } from '../../../src/marketing/Shell.tsx';
|
||||
|
||||
export const metadata: Metadata = { title: 'Trust and safety' };
|
||||
|
||||
export default function TrustPage() {
|
||||
return (
|
||||
<Shell page>
|
||||
<PageHero eyebrow="Trust" title="Safe by design, not by promise." subtitle="Four rules Wursor can’t break, however you set it up.">
|
||||
<Button href={ROUTES.signUp} size="lg">
|
||||
Get started
|
||||
</Button>
|
||||
</PageHero>
|
||||
<section className="m-grid-section m-grid-tight">
|
||||
<SafetyRules />
|
||||
</section>
|
||||
<AlwaysNever />
|
||||
<FeatureRow
|
||||
eyebrow="Everything is recorded"
|
||||
title="A record you can read."
|
||||
subtitle="Every request, check and decision is written down in plain words, so you can see what happened and why."
|
||||
bullets={['Who asked, who approved, and when', 'Checks and results, kept with each change']}
|
||||
card={{ left: 70, top: 119, width: 460 }}
|
||||
>
|
||||
<ActivityCard rows={ACTIVITY_ROWS} />
|
||||
</FeatureRow>
|
||||
<FeatureGrid
|
||||
eyebrow="Your control"
|
||||
title="You decide who can do what."
|
||||
cards={[
|
||||
{
|
||||
icon: 'key',
|
||||
title: 'Access you control',
|
||||
body: 'Pause or revoke Wursor’s access at any time. Choose Full access or Content only, and move your site in or out whenever you like.',
|
||||
},
|
||||
{ icon: 'user', title: 'Roles for your team', body: 'Choose who can ask for changes, who can approve and who can publish.' },
|
||||
{ icon: 'lock', title: 'Sensitive changes need more', body: 'Higher-risk changes ask for an extra confirmation before they go live.' },
|
||||
]}
|
||||
/>
|
||||
<Faq />
|
||||
<ClosingCta />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Wursor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const apiOrigin = process.env.WURSOR_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// `next dev` blocks its dev endpoints for any host but localhost, so a phone on the same Wi-Fi gets the HTML but a page that
|
||||
// never hydrates (menu, search and theme buttons all dead). Allow private-network addresses. Dev only; no effect on `next build`.
|
||||
allowedDevOrigins: ['10.*.*.*', '192.168.*.*', '172.*.*.*'],
|
||||
// Do not let `next dev` write its own agent instructions into web/. The repository's AGENTS.md is the guide.
|
||||
agentRules: false,
|
||||
// Interim: the Sprint 1 shell at /app still calls the API on the same origin.
|
||||
// Replaced by server-held sessions (ADR 0018, step C).
|
||||
async rewrites() {
|
||||
return ['/auth/:path*', '/chat/:path*', '/chat', '/sites/:path*', '/sites', '/health'].map(
|
||||
(source) => ({ source, destination: `${apiOrigin}${source}` }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -4,11 +4,14 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "next dev -p 5173",
|
||||
"build": "next build",
|
||||
"start": "next start -p 5173",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"geist": "^1.7.2",
|
||||
"next": "^16.4.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Single-colour line icons exported from the Figma file "Wursor - weown.net" (Icon/* components).
|
||||
Used through the `Icon` component (src/ui/Icon.tsx), which paints them with CSS masks so the colour comes from the
|
||||
current theme. Do not edit the files; re-export from Figma if an icon changes.
|
||||
|
||||
Exception: `sun.svg` and `moon.svg` (theme toggle) are not Figma exports. They are hand-drawn to match the set (16px, 1.5 stroke, round caps). Replace them with the Figma Icon/Sun and Icon/Moon exports when those exist.
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M16.5 9H13.5L11.25 15.75L6.75 2.25L4.5 9H1.5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Trailing icon">
|
||||
<path id="Vector" d="M4.16667 10H15.8333M10 15.8333L15.8333 10L10 4.16667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M6.86667 14C6.97826 14.203 7.1423 14.3722 7.34166 14.4901C7.54102 14.608 7.76839 14.6702 8 14.6702C8.23161 14.6702 8.45898 14.608 8.65834 14.4901C8.8577 14.3722 9.02175 14.203 9.13333 14M4 5.33333C4 4.27247 4.42143 3.25505 5.17157 2.50491C5.92172 1.75476 6.93913 1.33333 8 1.33333C9.06087 1.33333 10.0783 1.75476 10.8284 2.50491C11.5786 3.25505 12 4.27247 12 5.33333C12 10 14 11.3333 14 11.3333H2C2 11.3333 4 10 4 5.33333Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 722 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M4.5 3.75V12M9 3.75V8.25M13.5 3.75V14.25" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/camera">
|
||||
<path id="Vector" d="M12.0833 3.33333H7.91667L5.83333 5.83333H3.33333C2.89131 5.83333 2.46738 6.00893 2.15482 6.32149C1.84226 6.63405 1.66667 7.05797 1.66667 7.5V15C1.66667 15.442 1.84226 15.866 2.15482 16.1785C2.46738 16.4911 2.89131 16.6667 3.33333 16.6667H16.6667C17.1087 16.6667 17.5326 16.4911 17.8452 16.1785C18.1577 15.866 18.3333 15.442 18.3333 15V7.5C18.3333 7.05797 18.1577 6.63405 17.8452 6.32149C17.5326 6.00893 17.1087 5.83333 16.6667 5.83333H14.1667L12.0833 3.33333Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M10 13.3333C11.3807 13.3333 12.5 12.214 12.5 10.8333C12.5 9.45262 11.3807 8.33333 10 8.33333C8.61929 8.33333 7.5 9.45262 7.5 10.8333C7.5 12.214 8.61929 13.3333 10 13.3333Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/check-circle">
|
||||
<path id="Vector" d="M18.3333 9.23333V10C18.3323 11.797 17.7504 13.5456 16.6744 14.9849C15.5985 16.4241 14.0861 17.4771 12.3628 17.9866C10.6395 18.4961 8.79771 18.4349 7.11205 17.8122C5.4264 17.1894 3.98721 16.0384 3.00913 14.5309C2.03105 13.0234 1.56649 11.2401 1.68473 9.44693C1.80296 7.6538 2.49767 5.94694 3.66523 4.58089C4.83279 3.21485 6.41065 2.26282 8.16349 1.86679C9.91632 1.47076 11.7502 1.65195 13.3917 2.38333M18.3333 3.33333L10 11.675L7.5 9.175" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/check">
|
||||
<path id="Vector" d="M16.6667 5L7.5 14.1667L3.33333 10" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Trailing icon">
|
||||
<path id="Vector" d="M4 6L8 10L12 6" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 323 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/chevron-right">
|
||||
<path id="Vector" d="M7.5 15L12.5 10L7.5 5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 335 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/chevron-up">
|
||||
<path id="Vector" d="M15 12.5L10 7.5L5 12.5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/clock">
|
||||
<path id="Vector" d="M10 18.3333C14.6024 18.3333 18.3333 14.6024 18.3333 10C18.3333 5.39763 14.6024 1.66667 10 1.66667C5.39763 1.66667 1.66667 5.39763 1.66667 10C1.66667 14.6024 5.39763 18.3333 10 18.3333Z" stroke="#8F8B80" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M10 5V10L13.3333 11.6667" stroke="#8F8B80" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 624 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Close">
|
||||
<path id="Vector" d="M10.5 3.5L3.5 10.5M3.5 3.5L10.5 10.5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/credit-card">
|
||||
<path id="Vector" d="M1.66667 8.33333H18.3333M3.33333 4.16667H16.6667C17.1087 4.16667 17.5326 4.34226 17.8452 4.65482C18.1577 4.96738 18.3333 5.39131 18.3333 5.83333V14.1667C18.3333 14.6087 18.1577 15.0326 17.8452 15.3452C17.5326 15.6577 17.1087 15.8333 16.6667 15.8333H3.33333C2.89131 15.8333 2.46738 15.6577 2.15482 15.3452C1.84226 15.0326 1.66667 14.6087 1.66667 14.1667V5.83333C1.66667 5.39131 1.84226 4.96738 2.15482 4.65482C2.46738 4.34226 2.89131 4.16667 3.33333 4.16667Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 769 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="37.4737" height="7.49474" viewBox="0 0 37.4737 7.49474" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Dots">
|
||||
<circle id="Ellipse" cx="3.74737" cy="3.74737" r="3.74737" fill="#C4C2B8"/>
|
||||
<circle id="Ellipse_2" cx="18.7368" cy="3.74737" r="3.74737" fill="#C4C2B8"/>
|
||||
<circle id="Ellipse_3" cx="33.7263" cy="3.74737" r="3.74737" fill="#C4C2B8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/eye">
|
||||
<path id="Vector" d="M1.66667 10C1.66667 10 4.16667 4.16667 10 4.16667C15.8333 4.16667 18.3333 10 18.3333 10C18.3333 10 15.8333 15.8333 10 15.8333C4.16667 15.8333 1.66667 10 1.66667 10Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M10 12.5C11.3807 12.5 12.5 11.3807 12.5 10C12.5 8.61929 11.3807 7.5 10 7.5C8.61929 7.5 7.5 8.61929 7.5 10C7.5 11.3807 8.61929 12.5 10 12.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 717 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M9 16.5C13.1421 16.5 16.5 13.1421 16.5 9C16.5 4.85786 13.1421 1.5 9 1.5C4.85786 1.5 1.5 4.85786 1.5 9C1.5 13.1421 4.85786 16.5 9 16.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M1.5 9H16.5M9 1.5C10.876 3.55376 11.9421 6.21903 12 9C11.9421 11.781 10.876 14.4462 9 16.5C7.12404 14.4462 6.05794 11.781 6 9C6.05794 6.21903 7.12404 3.55376 9 1.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 708 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/help">
|
||||
<path id="Vector" d="M10 18.3333C14.6024 18.3333 18.3333 14.6024 18.3333 10C18.3333 5.39763 14.6024 1.66667 10 1.66667C5.39763 1.66667 1.66667 5.39763 1.66667 10C1.66667 14.6024 5.39763 18.3333 10 18.3333Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M7.575 7.5C7.77092 6.94306 8.15763 6.47342 8.66663 6.17428C9.17563 5.87513 9.77408 5.76578 10.356 5.86559C10.9379 5.9654 11.4657 6.26794 11.8459 6.71961C12.2261 7.17128 12.4342 7.74294 12.4333 8.33333C12.4333 10 9.93333 10.8333 9.93333 10.8333M10 14.1667H10.0083" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 861 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/home">
|
||||
<path id="Vector" d="M2.25 6.75L9 1.5L15.75 6.75V15C15.75 15.3978 15.592 15.7794 15.3107 16.0607C15.0294 16.342 14.6478 16.5 14.25 16.5H3.75C3.35218 16.5 2.97064 16.342 2.68934 16.0607C2.40804 15.7794 2.25 15.3978 2.25 15V6.75Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M6.75 16.5V9H11.25V16.5" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 644 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/key">
|
||||
<path id="Vector" d="M6.5 17C8.43 17 10 15.43 10 13.5C10 11.57 8.43 10 6.5 10C4.57 10 3 11.57 3 13.5C3 15.43 4.57 17 6.5 17Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M9 11L16.5 3.5" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_3" d="M14 6L16 8" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 651 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/list">
|
||||
<path id="Vector" d="M6.66667 5H17.5M6.66667 10H17.5M6.66667 15H17.5M2.5 5H2.50833M2.5 10H2.50833M2.5 15H2.50833" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/loader">
|
||||
<path id="Vector" d="M10 2.5V5.5M10 14.5V17.5M2.5 10H5.5M14.5 10H17.5M4.7 4.7L6.8 6.8M13.2 13.2L15.3 15.3M4.7 15.3L6.8 13.2M13.2 6.8L15.3 4.7" stroke="#2A52B0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18.7368" height="18.7368" viewBox="0 0 18.7368 18.7368" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/lock">
|
||||
<path id="Vector" d="M5.46491 8.58772V5.46491C5.46491 4.42964 5.87617 3.43676 6.60822 2.70472C7.34027 1.97267 8.33315 1.5614 9.36842 1.5614C10.4037 1.5614 11.3966 1.97267 12.1286 2.70472C12.8607 3.43676 13.2719 4.42964 13.2719 5.46491V8.58772M3.90351 8.58772H14.8333C15.2474 8.58772 15.6446 8.75222 15.9374 9.04504C16.2302 9.33786 16.3947 9.73501 16.3947 10.1491V15.614C16.3947 16.0281 16.2302 16.4253 15.9374 16.7181C15.6446 17.0109 15.2474 17.1754 14.8333 17.1754H3.90351C3.4894 17.1754 3.09225 17.0109 2.79943 16.7181C2.50661 16.4253 2.34211 16.0281 2.34211 15.614V10.1491C2.34211 9.73501 2.50661 9.33786 2.79943 9.04504C3.09225 8.75222 3.4894 8.58772 3.90351 8.58772Z" stroke="#8F8B80" stroke-width="1.40526" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 979 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M5.33333 4H14M5.33333 8H14M5.33333 12H14M2 4H2.00667M2 8H2.00667M2 12H2.00667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M3.33333 6.66667V8C3.33333 9.23768 3.825 10.4247 4.70017 11.2998C5.57534 12.175 6.76232 12.6667 8 12.6667C9.23768 12.6667 10.4247 12.175 11.2998 11.2998C12.175 10.4247 12.6667 9.23768 12.6667 8V6.66667M8 12.6667V14.6667M8 1.33333C7.46957 1.33333 6.96086 1.54405 6.58579 1.91912C6.21071 2.29419 6 2.8029 6 3.33333V8C6 8.53043 6.21071 9.03914 6.58579 9.41421C6.96086 9.78929 7.46957 10 8 10C8.53043 10 9.03914 9.78929 9.41421 9.41421C9.78929 9.03914 10 8.53043 10 8V3.33333C10 2.8029 9.78929 2.29419 9.41421 1.91912C9.03914 1.54405 8.53043 1.33333 8 1.33333Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 856 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M5.33333 14H10.6667M8 11.3333V14M2.66667 2.66667H13.3333C13.687 2.66667 14.0261 2.80714 14.2761 3.05719C14.5262 3.30724 14.6667 3.64638 14.6667 4V10C14.6667 10.3536 14.5262 10.6928 14.2761 10.9428C14.0261 11.1929 13.687 11.3333 13.3333 11.3333H2.66667C2.31304 11.3333 1.97391 11.1929 1.72386 10.9428C1.47381 10.6928 1.33333 10.3536 1.33333 10V4C1.33333 3.64638 1.47381 3.30724 1.72386 3.05719C1.97391 2.80714 2.31304 2.66667 2.66667 2.66667Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M14 8.52667C13.8828 9.79559 13.4066 11.0049 12.6277 12.0136C11.8489 13.0222 10.8001 13.7881 9.60331 14.2216C8.40652 14.6551 7.11116 14.7384 5.86854 14.4617C4.62591 14.1849 3.48765 13.5596 2.58652 12.6585C1.68538 11.7574 1.06015 10.6191 0.783359 9.37649C0.506571 8.13387 0.589915 6.83851 1.02339 5.64172C1.45687 4.44493 2.22275 3.39615 3.23142 2.61732C4.24009 1.83849 5.44941 1.36227 6.71833 1.24503C5.97543 2.25034 5.61787 3.48847 5.71073 4.73497C5.8036 5.98147 6.34073 7.15267 7.22402 8.03597C8.10732 8.91927 9.27852 9.4564 10.525 9.54926C11.7715 9.64213 13.0097 9.28457 14.015 8.54167L14 8.52667Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 898 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M14.2933 7.36667L8.16667 13.4933C7.4161 14.2439 6.39812 14.6656 5.33667 14.6656C4.27521 14.6656 3.25723 14.2439 2.50667 13.4933C1.7561 12.7428 1.33444 11.7248 1.33444 10.6633C1.33444 9.60188 1.7561 8.5839 2.50667 7.83333L8.22 2.12C8.72037 1.61874 9.39938 1.33679 10.1076 1.33616C10.8159 1.33553 11.4954 1.61629 11.9967 2.11667C12.4979 2.61704 12.7799 3.29605 12.7805 4.00431C12.7811 4.71257 12.5004 5.39207 12 5.89333L6.27333 11.6067C6.02315 11.8569 5.68382 11.9974 5.33 11.9974C4.97618 11.9974 4.63685 11.8569 4.38667 11.6067C4.13648 11.3565 3.99593 11.0172 3.99593 10.6633C3.99593 10.3095 4.13648 9.97019 4.38667 9.72L10.0467 4.06667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 935 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/pencil">
|
||||
<path id="Vector" d="M14.1667 2.5C14.3789 2.2492 14.6414 2.04505 14.9375 1.90057C15.2336 1.75609 15.5568 1.67445 15.8864 1.66085C16.216 1.64725 16.5449 1.70198 16.852 1.82157C17.1591 1.94116 17.4378 2.12298 17.6703 2.35543C17.9027 2.58789 18.0838 2.86589 18.2021 3.1717C18.3204 3.47752 18.3732 3.80445 18.3572 4.13165C18.3412 4.45885 18.2567 4.77915 18.1091 5.07214C17.9615 5.36512 17.7541 5.62436 17.5 5.83333L6.25 17.0833L1.66667 18.3333L2.91667 13.75L14.1667 2.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 752 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/plug">
|
||||
<path id="Vector" d="M7 2.5V6M13 2.5V6" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M15 6H5V9.5C5 12.26 7.24 14.5 10 14.5C12.76 14.5 15 12.26 15 9.5V6Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_3" d="M10 14.5V17.5" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 622 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/plus">
|
||||
<path id="Vector" d="M4.16667 10H15.8333M10 4.16667V15.8333" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M8.66667 8.66667L12.6667 12.6667M2 2L6.71333 13.3133L8.38667 8.38667L13.3133 6.71333L2 2Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18.7368" height="18.7368" viewBox="0 0 18.7368 18.7368" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/refresh">
|
||||
<path id="Vector" d="M2.34211 9.36842C2.34211 7.50493 3.08238 5.71776 4.40007 4.40007C5.71776 3.08238 7.50493 2.34211 9.36842 2.34211C11.3327 2.3495 13.2181 3.11596 14.6304 4.48123L16.3947 6.24561M12.4912 6.24561H16.3947V2.34211M16.3947 9.36842C16.3947 11.2319 15.6545 13.0191 14.3368 14.3368C13.0191 15.6545 11.2319 16.3947 9.36842 16.3947C7.40414 16.3873 5.51876 15.6209 4.10649 14.2556L2.34211 12.4912M2.34211 16.3947V12.4912H6.24561" stroke="#6B675E" stroke-width="1.40526" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 747 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/rocket">
|
||||
<path id="Vector" d="M6 9.5C6 15 10 17.5 10 17.5C10 17.5 14 15 14 9.5C14 6 12 3.5 10 2.5C8 3.5 6 6 6 9.5Z" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M6 12.5L3.5 14.5V11.5L6 9.5M14 12.5L16.5 14.5V11.5L14 9.5" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 558 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M14 14L11.1333 11.1333" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 605 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/shield-check">
|
||||
<path id="Vector" d="M11.6667 7.58333C11.6667 10.5 9.625 11.9583 7.19833 12.8042C7.07126 12.8472 6.93323 12.8452 6.8075 12.7983C4.375 11.9583 2.33333 10.5 2.33333 7.58333V3.5C2.33333 3.34529 2.39479 3.19692 2.50419 3.08752C2.61358 2.97813 2.76196 2.91667 2.91667 2.91667C4.08333 2.91667 5.54167 2.21667 6.55667 1.33C6.68025 1.22442 6.83746 1.1664 7 1.1664C7.16254 1.1664 7.31975 1.22442 7.44333 1.33C8.46417 2.2225 9.91667 2.91667 11.0833 2.91667C11.238 2.91667 11.3864 2.97813 11.4958 3.08752C11.6052 3.19692 11.6667 3.34529 11.6667 3.5V7.58333Z" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M5.25 7L6.41667 8.16667L8.75 5.83333" stroke="#1F6B42" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 984 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/shield">
|
||||
<path id="Vector" d="M16.6667 10.8333C16.6667 15 13.75 17.0833 10.2833 18.2917C10.1018 18.3532 9.90461 18.3502 9.725 18.2833C6.25 17.0833 3.33333 15 3.33333 10.8333V5C3.33333 4.77899 3.42113 4.56703 3.57741 4.41074C3.73369 4.25446 3.94565 4.16667 4.16667 4.16667C5.83333 4.16667 7.91667 3.16667 9.36667 1.9C9.54321 1.74917 9.76779 1.66629 10 1.66629C10.2322 1.66629 10.4568 1.74917 10.6333 1.9C12.0917 3.175 14.1667 4.16667 15.8333 4.16667C16.0543 4.16667 16.2663 4.25446 16.4226 4.41074C16.5789 4.56703 16.6667 4.77899 16.6667 5V10.8333Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 824 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M6 2V14M3.33333 2H12.6667C13.0203 2 13.3594 2.14048 13.6095 2.39052C13.8595 2.64057 14 2.97971 14 3.33333V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V3.33333C2 2.97971 2.14048 2.64057 2.39052 2.39052C2.64057 2.14048 2.97971 2 3.33333 2Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 668 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16.1667" height="16" viewBox="0 0 16.1667 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M2.75 14V9.33333M2.75 6.66667V2M8.08333 14V8M8.08333 5.33333V2M13.4167 14V10.6667M13.4167 8V2M0.75 9.33333H4.75M6.08333 5.33333H10.0833M11.4167 10.6667H15.4167" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M8 12H8.00667M4.66667 1.33333H11.3333C11.687 1.33333 12.0261 1.47381 12.2761 1.72386C12.5262 1.97391 12.6667 2.31304 12.6667 2.66667V13.3333C12.6667 13.687 12.5262 14.0261 12.2761 14.2761C12.0261 14.5262 11.687 14.6667 11.3333 14.6667H4.66667C4.31304 14.6667 3.97391 14.5262 3.72386 14.2761C3.47381 14.0261 3.33333 13.687 3.33333 13.3333V2.66667C3.33333 2.31304 3.47381 1.97391 3.72386 1.72386C3.97391 1.47381 4.31304 1.33333 4.66667 1.33333Z" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 742 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/sparkles">
|
||||
<path id="Vector" d="M10 2.5L11.5833 6.75L15.8333 8.33333L11.5833 9.91667L10 14.1667L8.41667 9.91667L4.16667 8.33333L8.41667 6.75L10 2.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M15.8333 12.5L16.5 14.3333L18.3333 15L16.5 15.6667L15.8333 17.5L15.1667 15.6667L13.3333 15L15.1667 14.3333L15.8333 12.5Z" stroke="#6B675E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 655 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Leading icon">
|
||||
<path id="Vector" d="M14 5.33333V2H10.6667M14 2L10 6M2 5.33333V2H5.33333M2 2L7.22 7.22C7.47076 7.471 7.66891 7.76951 7.80284 8.09805C7.93678 8.42659 8.00381 8.77856 8 9.13333V14.6667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M4.66667 3.33333H11.3333C11.687 3.33333 12.0261 3.47381 12.2761 3.72386C12.5262 3.97391 12.6667 4.31304 12.6667 4.66667V11.3333C12.6667 11.687 12.5262 12.0261 12.2761 12.2761C12.0261 12.5262 11.687 12.6667 11.3333 12.6667H4.66667C4.31304 12.6667 3.97391 12.5262 3.72386 12.2761C3.47381 12.0261 3.33333 11.687 3.33333 11.3333V4.66667C3.33333 4.31304 3.47381 3.97391 3.72386 3.72386C3.97391 3.47381 4.31304 3.33333 4.66667 3.33333Z" stroke="#F2F1EC" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 729 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M8 11.3333C9.84095 11.3333 11.3333 9.84095 11.3333 8C11.3333 6.15905 9.84095 4.66667 8 4.66667C6.15905 4.66667 4.66667 6.15905 4.66667 8C4.66667 9.84095 6.15905 11.3333 8 11.3333ZM8 1.33333V2.66667M8 13.3333V14.6667M2.28667 2.28667L3.23333 3.23333M12.7667 12.7667L13.7133 13.7133M1.33333 8H2.66667M13.3333 8H14.6667M2.28667 13.7133L3.23333 12.7667M12.7667 3.23333L13.7133 2.28667" stroke="#14120B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 679 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/undo">
|
||||
<path id="Vector" d="M2.5 5.83333V10.8333H7.5" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M17.5 14.1667C17.5 12.1775 16.7098 10.2699 15.3033 8.86337C13.8968 7.45684 11.9891 6.66667 10 6.66667C8.15427 6.66855 6.37404 7.35097 5 8.58333L2.5 10.8333" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/user">
|
||||
<path id="Vector" d="M15.8333 17.5V15.8333C15.8333 14.9493 15.4821 14.1014 14.857 13.4763C14.2319 12.8512 13.3841 12.5 12.5 12.5H7.5C6.61595 12.5 5.7681 12.8512 5.14298 13.4763C4.51786 14.1014 4.16667 14.9493 4.16667 15.8333V17.5" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M10 9.16667C11.8409 9.16667 13.3333 7.67428 13.3333 5.83333C13.3333 3.99238 11.8409 2.5 10 2.5C8.15905 2.5 6.66667 3.99238 6.66667 5.83333C6.66667 7.67428 8.15905 9.16667 10 9.16667Z" stroke="#E8590C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 805 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon/x">
|
||||
<path id="Vector" d="M15 5L5 15M5 5L15 15" stroke="#A12A2A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,10 @@
|
||||
Static assets exported from the Figma file "Wursor - weown.net" (marketing site page).
|
||||
Do not redraw or edit them. Re-export from Figma if the design changes.
|
||||
|
||||
| File | Figma node |
|
||||
|---|---|
|
||||
| brand-mark-light.svg / brand-mark-dark.svg | Brand/Mark (28:30), Nav Bar light (64:1426) and dark (136:72493) |
|
||||
| avatar-mark.svg / avatar-mark-dark.svg | Mark inside the Wursor Avatar (28:98), light and dark |
|
||||
| backdrop.svg | Backdrop "Size=Wide" (63:1707); the same warm backdrop is used in light and dark |
|
||||
|
||||
Icons are in ../icons. They are drawn with CSS masks so they take the token colour for the current theme.
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="10.6944" height="10.6944" viewBox="0 0 10.6944 10.6944" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Mark">
|
||||
<path id="Vector" d="M2.00521 6.90683C2.00521 5.06115 3.50154 3.56481 5.34722 3.56481C7.19291 3.56481 8.68924 5.06115 8.68924 6.90683" stroke="#14120B" stroke-width="2.48264" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="ember" cx="5.34722" cy="6.90683" r="0.891204" fill="#FF7A33"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 511 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Mark">
|
||||
<path id="Vector" d="M2.625 9.04167C2.625 6.6255 4.58383 4.66667 7 4.66667C9.41617 4.66667 11.375 6.6255 11.375 9.04167" stroke="#F2F1EC" stroke-width="3.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="ember" cx="7" cy="9.04167" r="1.16667" fill="#E8590C"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1,48 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="1344" height="720" viewBox="0 0 1344 720" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Backdrop">
|
||||
<g clip-path="url(#clip0_0_834)">
|
||||
<rect id="Base" width="1344" height="720" fill="url(#paint0_linear_0_834)"/>
|
||||
<g id="Blob 1" filter="url(#filter0_f_0_834)">
|
||||
<ellipse cx="201.6" cy="540" rx="369.6" ry="324" fill="#F28C54" fill-opacity="0.5"/>
|
||||
</g>
|
||||
<g id="Blob 2" filter="url(#filter1_f_0_834)">
|
||||
<ellipse cx="1048.32" cy="144" rx="336" ry="288" fill="#FFEDCC" fill-opacity="0.9"/>
|
||||
</g>
|
||||
<g id="Blob 3" filter="url(#filter2_f_0_834)">
|
||||
<ellipse cx="739.2" cy="684" rx="302.4" ry="216" fill="#FCCCA8" fill-opacity="0.9"/>
|
||||
</g>
|
||||
<g id="Blob 4" filter="url(#filter3_f_0_834)">
|
||||
<ellipse cx="67.2" cy="72" rx="235.2" ry="180" fill="#FFE0C2" fill-opacity="0.9"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_0_834" x="-337" y="47" width="1077.2" height="986" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="84.5" result="effect1_foregroundBlur_0_834"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_0_834" x="543.32" y="-313" width="1010" height="914" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="84.5" result="effect1_foregroundBlur_0_834"/>
|
||||
</filter>
|
||||
<filter id="filter2_f_0_834" x="267.8" y="299" width="942.8" height="770" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="84.5" result="effect1_foregroundBlur_0_834"/>
|
||||
</filter>
|
||||
<filter id="filter3_f_0_834" x="-337" y="-277" width="808.4" height="698" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="84.5" result="effect1_foregroundBlur_0_834"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_0_834" x1="672" y1="0" x2="672" y2="720" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FAEDE0"/>
|
||||
<stop offset="1" stop-color="#F5D6BD"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_0_834">
|
||||
<path d="M0 24C0 10.7452 10.7452 0 24 0H1320C1333.25 0 1344 10.7452 1344 24V696C1344 709.255 1333.25 720 1320 720H24C10.7452 720 0 709.255 0 696V24Z" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Brand/Mark">
|
||||
<path id="Vector" d="M4.5 15.5C4.5 11.358 7.858 8 12 8C16.142 8 19.5 11.358 19.5 15.5" stroke="#F2F1EC" stroke-width="3.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="ember" cx="12" cy="15.5" r="2" fill="#FF7A33"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Brand/Mark">
|
||||
<path id="Vector" d="M4.5 15.5C4.5 11.358 7.858 8 12 8C16.142 8 19.5 11.358 19.5 15.5" stroke="#14120B" stroke-width="3.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle id="ember" cx="12" cy="15.5" r="2" fill="#E8590C"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ApproveBar } from './components/ApproveBar.tsx';
|
||||
import { ChatPanel } from './components/ChatPanel.tsx';
|
||||
@@ -19,7 +21,7 @@ async function signUp(email: string, password: string): Promise<string> {
|
||||
return body.sessionToken;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
export function LegacyApp() {
|
||||
const [sessionToken, setSessionToken] = useState<string | null>(null);
|
||||
const [applied, setApplied] = useState(false);
|
||||
const chat = useChat(sessionToken);
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import './styles/global.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||