@pylonsync/create-pylon 0.3.344 → 0.3.346
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +35 -0
- package/package.json +1 -1
- package/templates/crm/AGENTS.md +116 -0
- package/templates/crm/CLAUDE.md +7 -0
- package/templates/crm/README.md +96 -0
- package/templates/crm/app/companies/companies-view.tsx +129 -0
- package/templates/crm/app/companies/page.tsx +17 -0
- package/templates/crm/app/contacts/contacts-view.tsx +139 -0
- package/templates/crm/app/contacts/page.tsx +17 -0
- package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
- package/templates/crm/app/deals/[id]/page.tsx +23 -0
- package/templates/crm/app/error.tsx +43 -0
- package/templates/crm/app/globals.css +201 -0
- package/templates/crm/app/layout.tsx +31 -0
- package/templates/crm/app/login/auth-form.tsx +122 -0
- package/templates/crm/app/login/page.tsx +38 -0
- package/templates/crm/app/not-found.tsx +29 -0
- package/templates/crm/app/page.tsx +38 -0
- package/templates/crm/app/pipeline-view.tsx +99 -0
- package/templates/crm/app/robots.ts +12 -0
- package/templates/crm/app/sitemap.ts +27 -0
- package/templates/crm/app/workspace.tsx +231 -0
- package/templates/crm/app.ts +171 -0
- package/templates/crm/bunfig +4 -0
- package/templates/crm/components/activity-timeline.tsx +133 -0
- package/templates/crm/components/avatar.tsx +40 -0
- package/templates/crm/components/board-skeleton.tsx +44 -0
- package/templates/crm/components/command-palette.tsx +162 -0
- package/templates/crm/components/data-table.tsx +90 -0
- package/templates/crm/components/deal-card.tsx +91 -0
- package/templates/crm/components/deal-dialog.tsx +169 -0
- package/templates/crm/components/empty-state.tsx +43 -0
- package/templates/crm/components/kbd.tsx +23 -0
- package/templates/crm/components/metrics-bar.tsx +46 -0
- package/templates/crm/components/page-header.tsx +36 -0
- package/templates/crm/components/pipeline-board.tsx +105 -0
- package/templates/crm/components/record-dialog.tsx +126 -0
- package/templates/crm/components/sidebar.tsx +112 -0
- package/templates/crm/components/stage-badge.tsx +33 -0
- package/templates/crm/components/ui/badge.tsx +37 -0
- package/templates/crm/components/ui/button.tsx +56 -0
- package/templates/crm/components/ui/card.tsx +90 -0
- package/templates/crm/components/ui/dialog.tsx +110 -0
- package/templates/crm/components/ui/input.tsx +30 -0
- package/templates/crm/components/ui/label.tsx +21 -0
- package/templates/crm/components/ui/select.tsx +39 -0
- package/templates/crm/components/ui/table.tsx +124 -0
- package/templates/crm/components/ui/textarea.tsx +24 -0
- package/templates/crm/components.json +20 -0
- package/templates/crm/functions/createDeal.ts +59 -0
- package/templates/crm/functions/logActivity.ts +49 -0
- package/templates/crm/functions/moveDeal.ts +53 -0
- package/templates/crm/functions/seedWorkspace.ts +63 -0
- package/templates/crm/gitignore +10 -0
- package/templates/crm/lib/pipeline.ts +221 -0
- package/templates/crm/lib/search.ts +78 -0
- package/templates/crm/lib/seed.ts +130 -0
- package/templates/crm/lib/utils.ts +10 -0
- package/templates/crm/package.json +39 -0
- package/templates/crm/tests/components.test.tsx +245 -0
- package/templates/crm/tests/pipeline.test.ts +219 -0
- package/templates/crm/tests/search.test.ts +83 -0
- package/templates/crm/tests/setup.ts +5 -0
- package/templates/crm/tsconfig.json +33 -0
- package/templates/helpdesk/AGENTS.md +116 -0
- package/templates/helpdesk/CLAUDE.md +7 -0
- package/templates/helpdesk/README.md +90 -0
- package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
- package/templates/helpdesk/app/customers/page.tsx +17 -0
- package/templates/helpdesk/app/error.tsx +43 -0
- package/templates/helpdesk/app/globals.css +201 -0
- package/templates/helpdesk/app/inbox-view.tsx +147 -0
- package/templates/helpdesk/app/layout.tsx +31 -0
- package/templates/helpdesk/app/login/auth-form.tsx +122 -0
- package/templates/helpdesk/app/login/page.tsx +38 -0
- package/templates/helpdesk/app/not-found.tsx +29 -0
- package/templates/helpdesk/app/page.tsx +38 -0
- package/templates/helpdesk/app/robots.ts +12 -0
- package/templates/helpdesk/app/sitemap.ts +27 -0
- package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
- package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
- package/templates/helpdesk/app/workspace.tsx +210 -0
- package/templates/helpdesk/app.ts +142 -0
- package/templates/helpdesk/bunfig +4 -0
- package/templates/helpdesk/components/avatar.tsx +40 -0
- package/templates/helpdesk/components/command-palette.tsx +162 -0
- package/templates/helpdesk/components/data-table.tsx +90 -0
- package/templates/helpdesk/components/empty-state.tsx +43 -0
- package/templates/helpdesk/components/kbd.tsx +23 -0
- package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
- package/templates/helpdesk/components/page-header.tsx +36 -0
- package/templates/helpdesk/components/priority-badge.tsx +61 -0
- package/templates/helpdesk/components/record-dialog.tsx +126 -0
- package/templates/helpdesk/components/sidebar.tsx +111 -0
- package/templates/helpdesk/components/sla-indicator.tsx +47 -0
- package/templates/helpdesk/components/thread.tsx +143 -0
- package/templates/helpdesk/components/ticket-list.tsx +78 -0
- package/templates/helpdesk/components/ui/badge.tsx +37 -0
- package/templates/helpdesk/components/ui/button.tsx +56 -0
- package/templates/helpdesk/components/ui/card.tsx +90 -0
- package/templates/helpdesk/components/ui/dialog.tsx +110 -0
- package/templates/helpdesk/components/ui/input.tsx +30 -0
- package/templates/helpdesk/components/ui/label.tsx +21 -0
- package/templates/helpdesk/components/ui/select.tsx +39 -0
- package/templates/helpdesk/components/ui/table.tsx +124 -0
- package/templates/helpdesk/components/ui/textarea.tsx +24 -0
- package/templates/helpdesk/components.json +20 -0
- package/templates/helpdesk/functions/createTicket.ts +62 -0
- package/templates/helpdesk/functions/replyToTicket.ts +55 -0
- package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
- package/templates/helpdesk/functions/setTicketState.ts +58 -0
- package/templates/helpdesk/gitignore +10 -0
- package/templates/helpdesk/lib/format.ts +53 -0
- package/templates/helpdesk/lib/search.ts +78 -0
- package/templates/helpdesk/lib/seed.ts +185 -0
- package/templates/helpdesk/lib/tickets.ts +184 -0
- package/templates/helpdesk/lib/utils.ts +10 -0
- package/templates/helpdesk/package.json +39 -0
- package/templates/helpdesk/tests/components.test.tsx +202 -0
- package/templates/helpdesk/tests/search.test.ts +83 -0
- package/templates/helpdesk/tests/setup.ts +5 -0
- package/templates/helpdesk/tests/tickets.test.ts +215 -0
- package/templates/helpdesk/tsconfig.json +33 -0
- package/templates/inventory/AGENTS.md +116 -0
- package/templates/inventory/CLAUDE.md +7 -0
- package/templates/inventory/README.md +103 -0
- package/templates/inventory/app/error.tsx +43 -0
- package/templates/inventory/app/globals.css +201 -0
- package/templates/inventory/app/layout.tsx +31 -0
- package/templates/inventory/app/login/auth-form.tsx +122 -0
- package/templates/inventory/app/login/page.tsx +38 -0
- package/templates/inventory/app/movements/movements-view.tsx +103 -0
- package/templates/inventory/app/movements/page.tsx +17 -0
- package/templates/inventory/app/not-found.tsx +29 -0
- package/templates/inventory/app/page.tsx +34 -0
- package/templates/inventory/app/products/[id]/page.tsx +23 -0
- package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
- package/templates/inventory/app/products-view.tsx +213 -0
- package/templates/inventory/app/robots.ts +12 -0
- package/templates/inventory/app/sitemap.ts +27 -0
- package/templates/inventory/app/workspace.tsx +172 -0
- package/templates/inventory/app.ts +154 -0
- package/templates/inventory/bunfig +4 -0
- package/templates/inventory/components/avatar.tsx +40 -0
- package/templates/inventory/components/command-palette.tsx +162 -0
- package/templates/inventory/components/data-table.tsx +90 -0
- package/templates/inventory/components/empty-state.tsx +43 -0
- package/templates/inventory/components/kbd.tsx +23 -0
- package/templates/inventory/components/movement-dialog.tsx +182 -0
- package/templates/inventory/components/page-header.tsx +36 -0
- package/templates/inventory/components/record-dialog.tsx +126 -0
- package/templates/inventory/components/sidebar.tsx +111 -0
- package/templates/inventory/components/stock-level.tsx +47 -0
- package/templates/inventory/components/summary-bar.tsx +56 -0
- package/templates/inventory/components/ui/badge.tsx +37 -0
- package/templates/inventory/components/ui/button.tsx +56 -0
- package/templates/inventory/components/ui/card.tsx +90 -0
- package/templates/inventory/components/ui/dialog.tsx +110 -0
- package/templates/inventory/components/ui/input.tsx +30 -0
- package/templates/inventory/components/ui/label.tsx +21 -0
- package/templates/inventory/components/ui/select.tsx +39 -0
- package/templates/inventory/components/ui/table.tsx +124 -0
- package/templates/inventory/components/ui/textarea.tsx +24 -0
- package/templates/inventory/components.json +20 -0
- package/templates/inventory/functions/recordMovement.ts +72 -0
- package/templates/inventory/functions/seedWorkspace.ts +40 -0
- package/templates/inventory/gitignore +10 -0
- package/templates/inventory/lib/format.ts +53 -0
- package/templates/inventory/lib/search.ts +78 -0
- package/templates/inventory/lib/seed.ts +102 -0
- package/templates/inventory/lib/stock.ts +207 -0
- package/templates/inventory/lib/utils.ts +10 -0
- package/templates/inventory/package.json +39 -0
- package/templates/inventory/tests/components.test.tsx +155 -0
- package/templates/inventory/tests/search.test.ts +83 -0
- package/templates/inventory/tests/setup.ts +5 -0
- package/templates/inventory/tests/stock.test.ts +255 -0
- package/templates/inventory/tsconfig.json +33 -0
- package/templates/invoices/AGENTS.md +116 -0
- package/templates/invoices/CLAUDE.md +7 -0
- package/templates/invoices/README.md +99 -0
- package/templates/invoices/app/clients/clients-view.tsx +130 -0
- package/templates/invoices/app/clients/page.tsx +17 -0
- package/templates/invoices/app/error.tsx +43 -0
- package/templates/invoices/app/globals.css +201 -0
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
- package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
- package/templates/invoices/app/invoices-view.tsx +183 -0
- package/templates/invoices/app/layout.tsx +31 -0
- package/templates/invoices/app/login/auth-form.tsx +122 -0
- package/templates/invoices/app/login/page.tsx +38 -0
- package/templates/invoices/app/not-found.tsx +29 -0
- package/templates/invoices/app/page.tsx +36 -0
- package/templates/invoices/app/robots.ts +12 -0
- package/templates/invoices/app/sitemap.ts +27 -0
- package/templates/invoices/app/workspace.tsx +174 -0
- package/templates/invoices/app.ts +169 -0
- package/templates/invoices/bunfig +4 -0
- package/templates/invoices/components/avatar.tsx +40 -0
- package/templates/invoices/components/command-palette.tsx +162 -0
- package/templates/invoices/components/data-table.tsx +90 -0
- package/templates/invoices/components/empty-state.tsx +43 -0
- package/templates/invoices/components/kbd.tsx +23 -0
- package/templates/invoices/components/line-items.tsx +172 -0
- package/templates/invoices/components/page-header.tsx +36 -0
- package/templates/invoices/components/payment-dialog.tsx +132 -0
- package/templates/invoices/components/record-dialog.tsx +126 -0
- package/templates/invoices/components/sidebar.tsx +111 -0
- package/templates/invoices/components/status-badge.tsx +35 -0
- package/templates/invoices/components/summary-bar.tsx +65 -0
- package/templates/invoices/components/totals-panel.tsx +70 -0
- package/templates/invoices/components/ui/badge.tsx +37 -0
- package/templates/invoices/components/ui/button.tsx +56 -0
- package/templates/invoices/components/ui/card.tsx +90 -0
- package/templates/invoices/components/ui/dialog.tsx +110 -0
- package/templates/invoices/components/ui/input.tsx +30 -0
- package/templates/invoices/components/ui/label.tsx +21 -0
- package/templates/invoices/components/ui/select.tsx +39 -0
- package/templates/invoices/components/ui/table.tsx +124 -0
- package/templates/invoices/components/ui/textarea.tsx +24 -0
- package/templates/invoices/components.json +20 -0
- package/templates/invoices/functions/createInvoice.ts +49 -0
- package/templates/invoices/functions/recordPayment.ts +72 -0
- package/templates/invoices/functions/seedWorkspace.ts +57 -0
- package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
- package/templates/invoices/gitignore +10 -0
- package/templates/invoices/lib/billing.ts +259 -0
- package/templates/invoices/lib/format.ts +53 -0
- package/templates/invoices/lib/search.ts +78 -0
- package/templates/invoices/lib/seed.ts +190 -0
- package/templates/invoices/lib/utils.ts +10 -0
- package/templates/invoices/package.json +39 -0
- package/templates/invoices/tests/billing.test.ts +283 -0
- package/templates/invoices/tests/components.test.tsx +172 -0
- package/templates/invoices/tests/search.test.ts +83 -0
- package/templates/invoices/tests/setup.ts +5 -0
- package/templates/invoices/tsconfig.json +33 -0
- package/templates/projects/AGENTS.md +116 -0
- package/templates/projects/CLAUDE.md +7 -0
- package/templates/projects/README.md +96 -0
- package/templates/projects/app/clients/clients-view.tsx +126 -0
- package/templates/projects/app/clients/page.tsx +17 -0
- package/templates/projects/app/error.tsx +43 -0
- package/templates/projects/app/globals.css +201 -0
- package/templates/projects/app/layout.tsx +31 -0
- package/templates/projects/app/login/auth-form.tsx +122 -0
- package/templates/projects/app/login/page.tsx +38 -0
- package/templates/projects/app/not-found.tsx +29 -0
- package/templates/projects/app/page.tsx +30 -0
- package/templates/projects/app/projects/[id]/page.tsx +23 -0
- package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
- package/templates/projects/app/projects-view.tsx +181 -0
- package/templates/projects/app/robots.ts +12 -0
- package/templates/projects/app/sitemap.ts +27 -0
- package/templates/projects/app/workspace.tsx +190 -0
- package/templates/projects/app.ts +186 -0
- package/templates/projects/bunfig +4 -0
- package/templates/projects/components/avatar.tsx +40 -0
- package/templates/projects/components/board-skeleton.tsx +37 -0
- package/templates/projects/components/budget-bar.tsx +61 -0
- package/templates/projects/components/command-palette.tsx +162 -0
- package/templates/projects/components/data-table.tsx +90 -0
- package/templates/projects/components/empty-state.tsx +43 -0
- package/templates/projects/components/kbd.tsx +23 -0
- package/templates/projects/components/page-header.tsx +36 -0
- package/templates/projects/components/record-dialog.tsx +126 -0
- package/templates/projects/components/sidebar.tsx +111 -0
- package/templates/projects/components/task-board.tsx +109 -0
- package/templates/projects/components/task-card.tsx +77 -0
- package/templates/projects/components/time-dialog.tsx +114 -0
- package/templates/projects/components/ui/badge.tsx +37 -0
- package/templates/projects/components/ui/button.tsx +56 -0
- package/templates/projects/components/ui/card.tsx +90 -0
- package/templates/projects/components/ui/dialog.tsx +110 -0
- package/templates/projects/components/ui/input.tsx +30 -0
- package/templates/projects/components/ui/label.tsx +21 -0
- package/templates/projects/components/ui/select.tsx +39 -0
- package/templates/projects/components/ui/table.tsx +124 -0
- package/templates/projects/components/ui/textarea.tsx +24 -0
- package/templates/projects/components.json +20 -0
- package/templates/projects/functions/logTime.ts +49 -0
- package/templates/projects/functions/moveTask.ts +40 -0
- package/templates/projects/functions/seedWorkspace.ts +59 -0
- package/templates/projects/gitignore +10 -0
- package/templates/projects/lib/format.ts +53 -0
- package/templates/projects/lib/search.ts +78 -0
- package/templates/projects/lib/seed.ts +140 -0
- package/templates/projects/lib/utils.ts +10 -0
- package/templates/projects/lib/work.ts +233 -0
- package/templates/projects/package.json +39 -0
- package/templates/projects/tests/components.test.tsx +200 -0
- package/templates/projects/tests/search.test.ts +83 -0
- package/templates/projects/tests/setup.ts +5 -0
- package/templates/projects/tests/work.test.ts +238 -0
- package/templates/projects/tsconfig.json +33 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# AGENTS.md: working in a Pylon project
|
|
2
|
+
|
|
3
|
+
Pylon serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from one Rust process on one port. Treat this app as production infrastructure: it supports real auth, SQLite or Postgres, row-level policies, jobs, search, and one-command deploys. Declare entities, policies, and server functions in TypeScript; the binary handles the runtime. Read the full API reference at https://docs.pylonsync.com/llms-full.txt before guessing an API name.
|
|
4
|
+
|
|
5
|
+
## Directory conventions
|
|
6
|
+
|
|
7
|
+
**Unified SSR app:**
|
|
8
|
+
- `app.ts`: data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
|
|
9
|
+
- `app/`: file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
|
|
10
|
+
- `app/globals.css`: Tailwind v4 entrypoint (auto-compiled and injected).
|
|
11
|
+
- `functions/`: server functions, one per file, `default`-exported.
|
|
12
|
+
- `.pylon/`: local dev state (SQLite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
|
|
13
|
+
|
|
14
|
+
**Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated; do not hand-edit.
|
|
15
|
+
|
|
16
|
+
## The core authoring loop
|
|
17
|
+
|
|
18
|
+
1. **Define an entity:** `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update; use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
|
|
19
|
+
2. **Write a policy:** `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). Omitted actions deny by default. `pylon lint` flags wide-open development policies such as `allow*: "true"`; tighten them before shipping.
|
|
20
|
+
3. **Author a function** in `functions/<name>.ts`: `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
|
|
21
|
+
4. **Read it on the client:** `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
|
|
22
|
+
|
|
23
|
+
## Key gotchas
|
|
24
|
+
|
|
25
|
+
- **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access; enforce trust with `ctx.auth` checks inside the handler, not policies.
|
|
26
|
+
- **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps`; they're server-only and stripped from hydration, so reading them in the render would mismatch.
|
|
27
|
+
- **Anonymous output caching is opt-in and conditional.** `export const revalidate = 60` makes a page CDN-cacheable (`public, s-maxage=60`) only when the render is auth-independent: it must not read `props.auth`, set a cookie, or run with strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. When any condition fails, the page is `no-cache`. Eligible renders also use the origin disk cache at `.pylon/.cache/ssr`: a cookie-less GET without a query string is served from disk for the TTL and rerendered when stale. The cache is namespaced per deploy, cleared by each build, disabled in `pylon dev`, and invalidated by the `revalidate` TTL or the next deploy.
|
|
28
|
+
- **No-JS forms use `route.ts` and `<Form>`.** Add `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` from `@pylonsync/react` with plain `<input name=...>`. It uses native POST → handler → redirect without JavaScript and no-reload enhancement with JavaScript. The handler's `db` is read+write under the mutation trust model, so gate it on `auth`. CSRF protection is automatic through the Origin gate and SameSite=Lax. Multipart uploads are not supported yet; use URL-encoded forms and `/api/files`.
|
|
29
|
+
- **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends; a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
|
|
30
|
+
- **`export const streaming = true` streams a page's inner `<Suspense>` boundaries.** Without it or a `loading.tsx`, the page is buffered until all suspended children resolve. With it, the shell and fallbacks flush immediately, then each boundary streams its content. Streaming commits the HTTP head before suspended subtrees finish, so the page is never CDN- or disk-cacheable; do not combine it with `export const revalidate`. Calls to `response.setStatus`, `setCookie`, `redirect`, or `notFound` only take effect during the synchronous shell render. A call from a suspended subtree is dropped and logged. An error from a deep `<Suspense>` child resolves through the nearest `error.tsx` at HTTP 200 rather than 5xx. Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
|
|
31
|
+
- **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
|
|
32
|
+
- **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
|
|
33
|
+
- **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
|
|
34
|
+
- **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
|
|
35
|
+
- **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
|
|
36
|
+
- **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
|
|
37
|
+
- **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
|
|
38
|
+
- **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
|
|
39
|
+
|
|
40
|
+
## Testing
|
|
41
|
+
|
|
42
|
+
`pylon test` discovers every `*.test.ts` / `*.test.tsx` file under `tests/` (or `functions/`) and runs it with **Bun's test runner** (`import { test, expect } from "bun:test"`). Run the suite with `pylon test` (or `npm test`); filter with `pylon test <substring>`. This template ships `bunfig.toml` + `tests/setup.ts` (registers happy-dom) so component tests render out of the box, plus starter tests under `tests/`; replace them with your own.
|
|
43
|
+
|
|
44
|
+
**Tier 1: pure logic (start here).** Keep access and plan gating, pricing, credit math, validation, and formatting in pure functions under `lib/`, and test them exhaustively. These tests need no server and run instantly. Keep `query`, `mutation`, and `action` handlers as thin wrappers so their decision logic remains testable without a running app.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { expect, test } from "bun:test";
|
|
48
|
+
import { productBySlug } from "../lib/site.config";
|
|
49
|
+
|
|
50
|
+
test("unknown slug → undefined", () => {
|
|
51
|
+
expect(productBySlug("nope")).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Tier 2: React components.** `@testing-library/react` and happy-dom are already wired through `tests/setup.ts`. Render and assert. The template uses the classic JSX transform, so add `import React from "react"` in `.tsx` tests. For a component that reads Pylon data hooks, mock the boundary, then dynamically import the component so the mock is in place first:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { test, expect, mock } from "bun:test";
|
|
59
|
+
import React from "react";
|
|
60
|
+
import { render, screen } from "@testing-library/react";
|
|
61
|
+
|
|
62
|
+
mock.module("@pylonsync/react", () => ({
|
|
63
|
+
db: { useQuery: () => ({ data: [{ id: "1", name: "Acme" }], loading: false }) },
|
|
64
|
+
}));
|
|
65
|
+
const { OrgList } = await import("../app/orgs/org-list"); // your component
|
|
66
|
+
|
|
67
|
+
test("renders orgs from the query", () => {
|
|
68
|
+
render(<OrgList />);
|
|
69
|
+
expect(screen.getByText("Acme")).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Tier 3: functions over HTTP.** Use this tier when pure logic tests cannot cover the behavior. A handler's policies, `ctx.db` calls, and auth run in the app. Start `pylon dev` in another terminal and call the API. `resetDb()` from `@pylonsync/functions` clears the in-memory database between cases; it does nothing when the server is down and refuses to run in production.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { afterEach, expect, test } from "bun:test";
|
|
77
|
+
import { resetDb } from "@pylonsync/functions";
|
|
78
|
+
|
|
79
|
+
const BASE = "http://localhost:4321";
|
|
80
|
+
afterEach(() => resetDb(BASE)); // or installTestIsolation(BASE) once at top-of-file
|
|
81
|
+
|
|
82
|
+
test("createThing then read it back", async () => {
|
|
83
|
+
const t = await fetch(`${BASE}/api/fn/createThing`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ name: "hello" }),
|
|
87
|
+
}).then((r) => r.json());
|
|
88
|
+
const rows = await fetch(`${BASE}/api/entities/Thing`).then((r) => r.json());
|
|
89
|
+
expect(rows.some((r: { id: string }) => r.id === t.id)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`pylon test:security` is a separate adversarial probe; it hits a running app and reports auth/policy holes (run `pylon dev`, then `pylon test:security`).
|
|
94
|
+
|
|
95
|
+
## Use the CLI
|
|
96
|
+
|
|
97
|
+
| Need | Command |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Run the app (SSR + API, hot reload, one port `:4321`) | `pylon dev` (or `npm run dev`) |
|
|
100
|
+
| Regenerate manifest + typed client | `pylon codegen` (Swift client: `pylon codegen client --target swift`) |
|
|
101
|
+
| Validate / diff / push schema | `pylon schema check` \| `diff` \| `push` |
|
|
102
|
+
| Migrations | `pylon migrate create <name>` \| `plan` \| `apply` |
|
|
103
|
+
| Lint policies (PYL001–PYL004) | `pylon lint --strict` |
|
|
104
|
+
| Tests | `pylon test` |
|
|
105
|
+
| Adversarial security probe | `pylon test:security` |
|
|
106
|
+
| Inspect cloud request logs (agent-safe) | `pylon logs --json --limit 50` |
|
|
107
|
+
| Inspect data / entities | `pylon data entities` \| `pylon data list <Entity>` |
|
|
108
|
+
| Call a function | `pylon fn <name> key=value` |
|
|
109
|
+
| Health snapshot | `pylon status` |
|
|
110
|
+
| Build for prod | `pylon build` |
|
|
111
|
+
| Deploy (Pylon Cloud by default) | `pylon deploy` |
|
|
112
|
+
| Look up an error code | `pylon explain <CODE>` |
|
|
113
|
+
|
|
114
|
+
`--json` works on every command for machine-readable output. Prefer one-shot/agent-safe flags (`pylon logs --limit N`, not a blocking `--follow`).
|
|
115
|
+
|
|
116
|
+
For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: **https://docs.pylonsync.com/llms-full.txt**.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
Stock control for a small business — products, an append-only movement ledger,
|
|
4
|
+
and the levels derived from it. One [Pylon](https://pylonsync.com) app: SSR
|
|
5
|
+
frontend, API, auth, and realtime sync from one process on one port.
|
|
6
|
+
|
|
7
|
+
## Develop
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
__RUN_DEV__
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Open http://localhost:4321 and create an account. The first sign-in seeds a
|
|
14
|
+
realistic shelf — including one line out of stock and one below its reorder
|
|
15
|
+
point — so those states are visible rather than theoretical. Delete
|
|
16
|
+
`functions/seedWorkspace.ts` and `lib/seed.ts` once you stock real products.
|
|
17
|
+
|
|
18
|
+
## The one design decision that matters
|
|
19
|
+
|
|
20
|
+
**There is no `quantity` column.** On-hand is the SUM of a product\'s movements.
|
|
21
|
+
|
|
22
|
+
A mutable counter is the classic inventory bug: two people receive the same
|
|
23
|
+
delivery, both read 10, both write 15, and five units vanish with nothing to
|
|
24
|
+
audit. Appending `+5` twice gives 20 and shows exactly who did it.
|
|
25
|
+
|
|
26
|
+
It also makes every count explainable. "Why do we have 3?" is answerable from
|
|
27
|
+
the same rows that produced the number — the product page runs the balance
|
|
28
|
+
backwards so each movement shows what the shelf held right after it.
|
|
29
|
+
|
|
30
|
+
The ledger is **insert-only at the policy level**, not by convention:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
allowUpdate: "false",
|
|
34
|
+
allowDelete: "false",
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Editing a movement would silently rewrite a past valuation; deleting one would
|
|
38
|
+
make the current level unexplainable. A correction is a new movement in the
|
|
39
|
+
opposite direction — the way paper stock books have always worked.
|
|
40
|
+
|
|
41
|
+
## What else is interesting
|
|
42
|
+
|
|
43
|
+
**The reason has to agree with the direction.** A "Sold" that adds stock is a
|
|
44
|
+
mis-click, and `recordMovement` refuses it. The whole value of storing a reason
|
|
45
|
+
is being able to trust it later.
|
|
46
|
+
|
|
47
|
+
**You type positive numbers.** The sign comes from the reason. Asking someone to
|
|
48
|
+
type `-3` for a sale is how you get a `+3` sale and a shelf that disagrees with
|
|
49
|
+
the system. A stock count is the exception: you type what you counted, and the
|
|
50
|
+
delta is computed from the current level.
|
|
51
|
+
|
|
52
|
+
**Negative stock is refused.** It\'s always a miscount or a mistake, and
|
|
53
|
+
recording it turns one error into a valuation that quietly lies. Correct it with
|
|
54
|
+
a stock count instead.
|
|
55
|
+
|
|
56
|
+
**It\'s live.** A movement recorded at the back door updates the level on the
|
|
57
|
+
shop floor\'s screen immediately, so nobody sells what was just damaged.
|
|
58
|
+
|
|
59
|
+
## Layout
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
app.ts entities + policies (note the append-only ledger)
|
|
63
|
+
app/
|
|
64
|
+
page.tsx "/" — the stock list
|
|
65
|
+
products-view.tsx list container
|
|
66
|
+
workspace.tsx app shell — the ONLY place that touches `db`
|
|
67
|
+
products/[id]/ product detail + running balance history
|
|
68
|
+
movements/ the full ledger
|
|
69
|
+
login/ email/password auth
|
|
70
|
+
components/ presentational — props in, callbacks out
|
|
71
|
+
ui/ shadcn primitives
|
|
72
|
+
lib/stock.ts levels, valuation, reorder state — pure
|
|
73
|
+
lib/format.ts time + initials — pure
|
|
74
|
+
functions/ server functions
|
|
75
|
+
tests/ pure logic + component tests
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Money is integer cents; quantities are whole units. You cannot hold half a
|
|
79
|
+
physical thing, and allowing it hides unit-of-measure mistakes.
|
|
80
|
+
|
|
81
|
+
## Access
|
|
82
|
+
|
|
83
|
+
Every entity is readable by any **signed-in** user and by nobody else. Movements
|
|
84
|
+
are additionally insert-only for everyone, including you.
|
|
85
|
+
|
|
86
|
+
## Grow it
|
|
87
|
+
|
|
88
|
+
- **Add a reason:** `REASONS` in `lib/stock.ts`, with its allowed direction. The
|
|
89
|
+
dialog, the validator, and the ledger view all read from it.
|
|
90
|
+
- **Multiple locations:** add a `locationId` to `Movement` and group the sum by
|
|
91
|
+
it — the model already supports it without a schema migration on levels,
|
|
92
|
+
because there are no levels to migrate.
|
|
93
|
+
- **Purchase orders:** a `PurchaseOrder` entity whose receipt writes movements.
|
|
94
|
+
|
|
95
|
+
## Deploy
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pylon lint --strict
|
|
99
|
+
pylon test
|
|
100
|
+
pylon deploy
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Docs: https://docs.pylonsync.com
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type ErrorBoundaryProps } from "@pylonsync/react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
// `app/error.tsx` → the error boundary for this segment. It catches a throw
|
|
6
|
+
// in any page/layout below it and renders at HTTP 500. It's HYDRATED, so
|
|
7
|
+
// this is a real interactive client component: `reset()` re-attempts the
|
|
8
|
+
// route, and useState/onClick work. The thrown error reaches the client as
|
|
9
|
+
// `{ message, digest }` only — the stack stays in the dev overlay
|
|
10
|
+
// (PYLON_DEV_MODE) and the server logs, never in the page.
|
|
11
|
+
export default function Error({ error, reset }: ErrorBoundaryProps) {
|
|
12
|
+
const [tries, setTries] = React.useState(0);
|
|
13
|
+
return (
|
|
14
|
+
<div className="space-y-6">
|
|
15
|
+
<section>
|
|
16
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
17
|
+
Something went wrong
|
|
18
|
+
</h1>
|
|
19
|
+
<p className="mt-2 text-muted-foreground">{error.message}</p>
|
|
20
|
+
{error.digest ? (
|
|
21
|
+
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
22
|
+
Reference: <code>{error.digest}</code>
|
|
23
|
+
</p>
|
|
24
|
+
) : null}
|
|
25
|
+
</section>
|
|
26
|
+
<div className="flex items-center gap-3">
|
|
27
|
+
<Button
|
|
28
|
+
onClick={() => {
|
|
29
|
+
setTries((n) => n + 1);
|
|
30
|
+
reset();
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
Try again
|
|
34
|
+
</Button>
|
|
35
|
+
{tries > 0 ? (
|
|
36
|
+
<span className="text-sm text-muted-foreground">
|
|
37
|
+
Retried {tries} {tries === 1 ? "time" : "times"}
|
|
38
|
+
</span>
|
|
39
|
+
) : null}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "tw-animate-css";
|
|
3
|
+
|
|
4
|
+
@source "../app/**/*.{tsx,ts,jsx,js}";
|
|
5
|
+
@source "../components/**/*.{tsx,ts,jsx,js}";
|
|
6
|
+
|
|
7
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
8
|
+
|
|
9
|
+
/* ---------------------------------------------------------------------------
|
|
10
|
+
Design tokens.
|
|
11
|
+
|
|
12
|
+
Tuned for a dense internal tool rather than a marketing page: a near-neutral
|
|
13
|
+
surface ramp, hairline borders instead of shadows, and one accent used
|
|
14
|
+
sparingly so status colour still means something. Dark is the primary theme —
|
|
15
|
+
`app/layout.tsx` sets `class="dark"` — and light is a real, tested
|
|
16
|
+
alternative, not an afterthought.
|
|
17
|
+
|
|
18
|
+
These are the same variable names shadcn/ui generates against, so
|
|
19
|
+
`npx shadcn@latest add <component>` drops in already themed.
|
|
20
|
+
--------------------------------------------------------------------------- */
|
|
21
|
+
:root {
|
|
22
|
+
--radius: 0.5rem;
|
|
23
|
+
|
|
24
|
+
--background: oklch(1 0 0);
|
|
25
|
+
--foreground: oklch(0.21 0.006 285.9);
|
|
26
|
+
--card: oklch(1 0 0);
|
|
27
|
+
--card-foreground: var(--foreground);
|
|
28
|
+
--popover: oklch(1 0 0);
|
|
29
|
+
--popover-foreground: var(--foreground);
|
|
30
|
+
|
|
31
|
+
--primary: oklch(0.55 0.204 274.5);
|
|
32
|
+
--primary-foreground: oklch(1 0 0);
|
|
33
|
+
--secondary: oklch(0.968 0.002 286.4);
|
|
34
|
+
--secondary-foreground: oklch(0.27 0.006 286);
|
|
35
|
+
--muted: oklch(0.972 0.002 286.4);
|
|
36
|
+
--muted-foreground: oklch(0.53 0.013 286);
|
|
37
|
+
--accent: oklch(0.96 0.003 286.4);
|
|
38
|
+
--accent-foreground: oklch(0.27 0.006 286);
|
|
39
|
+
--destructive: oklch(0.58 0.216 27.3);
|
|
40
|
+
|
|
41
|
+
--border: oklch(0.918 0.004 286.3);
|
|
42
|
+
--input: oklch(0.918 0.004 286.3);
|
|
43
|
+
--ring: oklch(0.55 0.204 274.5);
|
|
44
|
+
|
|
45
|
+
/* Surfaces above the page: sidebar, popovers, hover rows. */
|
|
46
|
+
--surface-1: oklch(0.985 0.001 286.4);
|
|
47
|
+
--surface-2: oklch(0.968 0.002 286.4);
|
|
48
|
+
|
|
49
|
+
/* Pipeline + status. Muted on purpose — a board of saturated chips is
|
|
50
|
+
unreadable, and the eye should land on the one that's actually urgent. */
|
|
51
|
+
--stage-lead: oklch(0.62 0.03 264);
|
|
52
|
+
--stage-qualified: oklch(0.6 0.13 240);
|
|
53
|
+
--stage-proposal: oklch(0.68 0.15 78);
|
|
54
|
+
--stage-won: oklch(0.6 0.15 155);
|
|
55
|
+
--stage-lost: oklch(0.6 0.11 25);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.dark {
|
|
59
|
+
--background: oklch(0.165 0.004 285.9);
|
|
60
|
+
--foreground: oklch(0.93 0.002 286.3);
|
|
61
|
+
--card: oklch(0.196 0.005 285.9);
|
|
62
|
+
--card-foreground: var(--foreground);
|
|
63
|
+
--popover: oklch(0.216 0.006 285.9);
|
|
64
|
+
--popover-foreground: var(--foreground);
|
|
65
|
+
|
|
66
|
+
--primary: oklch(0.62 0.19 274.5);
|
|
67
|
+
--primary-foreground: oklch(1 0 0);
|
|
68
|
+
--secondary: oklch(0.25 0.006 286);
|
|
69
|
+
--secondary-foreground: oklch(0.93 0.002 286.3);
|
|
70
|
+
--muted: oklch(0.25 0.006 286);
|
|
71
|
+
--muted-foreground: oklch(0.65 0.012 286);
|
|
72
|
+
--accent: oklch(0.26 0.006 286);
|
|
73
|
+
--accent-foreground: oklch(0.93 0.002 286.3);
|
|
74
|
+
--destructive: oklch(0.65 0.19 25);
|
|
75
|
+
|
|
76
|
+
--border: oklch(1 0 0 / 9%);
|
|
77
|
+
--input: oklch(1 0 0 / 12%);
|
|
78
|
+
--ring: oklch(0.62 0.19 274.5);
|
|
79
|
+
|
|
80
|
+
--surface-1: oklch(0.186 0.004 285.9);
|
|
81
|
+
--surface-2: oklch(0.216 0.006 285.9);
|
|
82
|
+
|
|
83
|
+
--stage-lead: oklch(0.68 0.03 264);
|
|
84
|
+
--stage-qualified: oklch(0.68 0.13 240);
|
|
85
|
+
--stage-proposal: oklch(0.75 0.14 78);
|
|
86
|
+
--stage-won: oklch(0.7 0.15 155);
|
|
87
|
+
--stage-lost: oklch(0.68 0.14 25);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@theme inline {
|
|
91
|
+
--radius-sm: calc(var(--radius) - 3px);
|
|
92
|
+
--radius-md: calc(var(--radius) - 1px);
|
|
93
|
+
--radius-lg: var(--radius);
|
|
94
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
95
|
+
|
|
96
|
+
--color-background: var(--background);
|
|
97
|
+
--color-foreground: var(--foreground);
|
|
98
|
+
--color-card: var(--card);
|
|
99
|
+
--color-card-foreground: var(--card-foreground);
|
|
100
|
+
--color-popover: var(--popover);
|
|
101
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
102
|
+
--color-primary: var(--primary);
|
|
103
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
104
|
+
--color-secondary: var(--secondary);
|
|
105
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
106
|
+
--color-muted: var(--muted);
|
|
107
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
108
|
+
--color-accent: var(--accent);
|
|
109
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
110
|
+
--color-destructive: var(--destructive);
|
|
111
|
+
--color-border: var(--border);
|
|
112
|
+
--color-input: var(--input);
|
|
113
|
+
--color-ring: var(--ring);
|
|
114
|
+
|
|
115
|
+
--color-surface-1: var(--surface-1);
|
|
116
|
+
--color-surface-2: var(--surface-2);
|
|
117
|
+
|
|
118
|
+
--color-stage-lead: var(--stage-lead);
|
|
119
|
+
--color-stage-qualified: var(--stage-qualified);
|
|
120
|
+
--color-stage-proposal: var(--stage-proposal);
|
|
121
|
+
--color-stage-won: var(--stage-won);
|
|
122
|
+
--color-stage-lost: var(--stage-lost);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@layer base {
|
|
126
|
+
*,
|
|
127
|
+
::after,
|
|
128
|
+
::before,
|
|
129
|
+
::backdrop,
|
|
130
|
+
::file-selector-button {
|
|
131
|
+
border-color: var(--color-border, currentColor);
|
|
132
|
+
outline-color: var(--color-ring);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
html {
|
|
136
|
+
/* An internal tool is a fixed shell with scrolling panes, not a scrolling
|
|
137
|
+
document — this keeps the sidebar and header from drifting. */
|
|
138
|
+
height: 100%;
|
|
139
|
+
-webkit-tap-highlight-color: transparent;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
body {
|
|
143
|
+
height: 100%;
|
|
144
|
+
background-color: var(--color-background);
|
|
145
|
+
color: var(--color-foreground);
|
|
146
|
+
/* 13px is the density these screens are designed at; the slight negative
|
|
147
|
+
tracking is what keeps Inter from looking loose at that size. */
|
|
148
|
+
font-size: 13px;
|
|
149
|
+
line-height: 1.5;
|
|
150
|
+
letter-spacing: -0.006em;
|
|
151
|
+
-webkit-font-smoothing: antialiased;
|
|
152
|
+
text-rendering: optimizeLegibility;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
button {
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Numbers that sit in columns must not jitter as they change. */
|
|
160
|
+
.tabular,
|
|
161
|
+
table {
|
|
162
|
+
font-variant-numeric: tabular-nums;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* Thin, self-effacing scrollbars — the board has several at once. */
|
|
166
|
+
* {
|
|
167
|
+
scrollbar-width: thin;
|
|
168
|
+
scrollbar-color: color-mix(in oklab, var(--color-foreground) 18%, transparent)
|
|
169
|
+
transparent;
|
|
170
|
+
}
|
|
171
|
+
::-webkit-scrollbar {
|
|
172
|
+
width: 10px;
|
|
173
|
+
height: 10px;
|
|
174
|
+
}
|
|
175
|
+
::-webkit-scrollbar-thumb {
|
|
176
|
+
background-color: color-mix(in oklab, var(--color-foreground) 16%, transparent);
|
|
177
|
+
border: 3px solid transparent;
|
|
178
|
+
background-clip: content-box;
|
|
179
|
+
border-radius: 999px;
|
|
180
|
+
}
|
|
181
|
+
::-webkit-scrollbar-thumb:hover {
|
|
182
|
+
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
|
|
183
|
+
}
|
|
184
|
+
::-webkit-scrollbar-corner {
|
|
185
|
+
background: transparent;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* Only show focus rings for keyboard users; a mouse click shouldn't ring. */
|
|
189
|
+
:focus-visible {
|
|
190
|
+
outline: 2px solid var(--color-ring);
|
|
191
|
+
outline-offset: 1px;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@layer components {
|
|
196
|
+
/* A hairline that reads at 1px on any DPR without the doubling you get from
|
|
197
|
+
a plain border on retina. */
|
|
198
|
+
.hairline {
|
|
199
|
+
box-shadow: inset 0 -1px 0 0 var(--color-border);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface LayoutProps {
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The document shell. The app chrome — sidebar, command palette, shortcuts —
|
|
9
|
+
* lives in `app/workspace.tsx` instead, because it needs the synced data; this
|
|
10
|
+
* layer only sets up the page.
|
|
11
|
+
*
|
|
12
|
+
* `class="dark"` makes dark the default theme. The light tokens in globals.css
|
|
13
|
+
* are complete and tested, so removing the class flips the whole app; swap it
|
|
14
|
+
* for a stored preference when you want a toggle.
|
|
15
|
+
*/
|
|
16
|
+
export default function RootLayout({ children }: LayoutProps) {
|
|
17
|
+
return (
|
|
18
|
+
<html lang="en" className="dark">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charSet="utf-8" />
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
22
|
+
<title>__APP_NAME__</title>
|
|
23
|
+
{/* Tailwind is compiled by Pylon from app/globals.css and the
|
|
24
|
+
stylesheet link is injected here automatically. Inter is declared in
|
|
25
|
+
app.ts (fonts: [...]) and self-hosted by the build — @font-face, the
|
|
26
|
+
preload link, and a size-adjusted fallback are injected too. */}
|
|
27
|
+
</head>
|
|
28
|
+
<body className="h-full">{children}</body>
|
|
29
|
+
</html>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ApiError,
|
|
6
|
+
passwordLogin,
|
|
7
|
+
passwordRegister,
|
|
8
|
+
persistSession,
|
|
9
|
+
} from "@pylonsync/client";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Input } from "@/components/ui/input";
|
|
12
|
+
import { Label } from "@/components/ui/label";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One form, two modes. Calls the built-in auth API (`/api/auth/password/*`),
|
|
16
|
+
* then `persistSession` writes the token so the sync engine and `callFn`
|
|
17
|
+
* authenticate as this user on the next load.
|
|
18
|
+
*
|
|
19
|
+
* The navigation is a full `assign`, not a client route change, so the SSR
|
|
20
|
+
* runtime re-resolves auth from the HttpOnly cookie and the first paint of the
|
|
21
|
+
* workspace is already signed in.
|
|
22
|
+
*/
|
|
23
|
+
export function AuthForm() {
|
|
24
|
+
const [mode, setMode] = useState<"login" | "signup">("login");
|
|
25
|
+
const [email, setEmail] = useState("");
|
|
26
|
+
const [password, setPassword] = useState("");
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
const [pending, setPending] = useState(false);
|
|
29
|
+
|
|
30
|
+
async function onSubmit(event: React.FormEvent) {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
setError(null);
|
|
33
|
+
setPending(true);
|
|
34
|
+
try {
|
|
35
|
+
const session =
|
|
36
|
+
mode === "login"
|
|
37
|
+
? await passwordLogin({ email, password })
|
|
38
|
+
: await passwordRegister({ email, password });
|
|
39
|
+
persistSession(session);
|
|
40
|
+
window.location.assign("/");
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setError(messageFor(err));
|
|
43
|
+
setPending(false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="space-y-5">
|
|
49
|
+
<form onSubmit={onSubmit} className="space-y-3.5">
|
|
50
|
+
<div className="space-y-1.5">
|
|
51
|
+
<Label htmlFor="email">Email</Label>
|
|
52
|
+
<Input
|
|
53
|
+
id="email"
|
|
54
|
+
type="email"
|
|
55
|
+
value={email}
|
|
56
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
57
|
+
required
|
|
58
|
+
autoComplete="email"
|
|
59
|
+
placeholder="you@company.com"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
<div className="space-y-1.5">
|
|
63
|
+
<Label htmlFor="password">Password</Label>
|
|
64
|
+
<Input
|
|
65
|
+
id="password"
|
|
66
|
+
type="password"
|
|
67
|
+
value={password}
|
|
68
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
69
|
+
required
|
|
70
|
+
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
|
71
|
+
placeholder={mode === "login" ? "Your password" : "At least 10 characters"}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
{error ? (
|
|
75
|
+
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-[12px] leading-snug text-destructive">
|
|
76
|
+
{error}
|
|
77
|
+
</p>
|
|
78
|
+
) : null}
|
|
79
|
+
<Button type="submit" disabled={pending} className="w-full">
|
|
80
|
+
{pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
|
|
81
|
+
</Button>
|
|
82
|
+
</form>
|
|
83
|
+
|
|
84
|
+
<p className="text-center text-[12px] text-muted-foreground">
|
|
85
|
+
{mode === "login" ? "First time here?" : "Already have an account?"}{" "}
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => {
|
|
89
|
+
setMode(mode === "login" ? "signup" : "login");
|
|
90
|
+
setError(null);
|
|
91
|
+
}}
|
|
92
|
+
className="font-medium text-foreground underline underline-offset-2"
|
|
93
|
+
>
|
|
94
|
+
{mode === "login" ? "Create an account" : "Sign in"}
|
|
95
|
+
</button>
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `ApiError` carries a stable `.code`, so branch on the code rather than the
|
|
102
|
+
// message — the copy here is ours, the message is the framework's.
|
|
103
|
+
function messageFor(err: unknown): string {
|
|
104
|
+
if (err instanceof ApiError) {
|
|
105
|
+
switch (err.code) {
|
|
106
|
+
case "INVALID_CREDENTIALS":
|
|
107
|
+
return "Wrong email or password.";
|
|
108
|
+
case "USER_EXISTS":
|
|
109
|
+
return "That email is already registered — sign in instead.";
|
|
110
|
+
case "WEAK_PASSWORD":
|
|
111
|
+
return "Pick a longer password — at least 10 characters.";
|
|
112
|
+
case "PWNED_PASSWORD":
|
|
113
|
+
return "That password has appeared in a known breach. Choose another.";
|
|
114
|
+
case "RATE_LIMITED":
|
|
115
|
+
return "Too many attempts — try again in a minute.";
|
|
116
|
+
default:
|
|
117
|
+
return err.message;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (err instanceof Error) return err.message;
|
|
121
|
+
return "Something went wrong. Try again.";
|
|
122
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Metadata, PageProps } from "@pylonsync/react";
|
|
3
|
+
import { AuthForm } from "./auth-form";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Sign in",
|
|
7
|
+
robots: "noindex",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function LoginPage({ auth, response }: PageProps) {
|
|
11
|
+
// Already signed in? Nothing to do here.
|
|
12
|
+
if (auth.user_id && !auth.user_id.startsWith("guest_")) {
|
|
13
|
+
response.redirect("/");
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<main className="flex min-h-screen items-center justify-center px-6">
|
|
19
|
+
<div className="w-full max-w-[320px]">
|
|
20
|
+
<div className="mb-7 flex items-center gap-2">
|
|
21
|
+
<span className="flex size-7 items-center justify-center rounded-md bg-primary text-[12px] font-bold text-primary-foreground">
|
|
22
|
+
C
|
|
23
|
+
</span>
|
|
24
|
+
<span className="text-[15px] font-semibold tracking-tight">CRM</span>
|
|
25
|
+
</div>
|
|
26
|
+
<h1 className="text-[19px] font-semibold tracking-tight">
|
|
27
|
+
Sign in to your workspace
|
|
28
|
+
</h1>
|
|
29
|
+
<p className="mt-1 text-[13px] text-muted-foreground">
|
|
30
|
+
Your team shares one pipeline. Anyone with an account sees it.
|
|
31
|
+
</p>
|
|
32
|
+
<div className="mt-6">
|
|
33
|
+
<AuthForm />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</main>
|
|
37
|
+
);
|
|
38
|
+
}
|