saasaloy 0.5.2 → 0.6.1

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.
Files changed (84) hide show
  1. package/README.md +7 -7
  2. package/dist/index.js +637 -522
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/schemas/registry-item.schema.json +31 -1
  6. package/templates/base/AGENTS.md +204 -89
  7. package/templates/base/README.md +20 -8
  8. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +38 -17
  9. package/templates/base/_agents/skills/saasaloy-env/SKILL.md +130 -0
  10. package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +63 -46
  11. package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +1 -1
  12. package/templates/base/_gitignore +0 -3
  13. package/templates/base/_saasaloy-base.json +8 -1
  14. package/templates/base/{DESIGN.md → apps/web/DESIGN.md} +36 -12
  15. package/templates/base/apps/web/astro.config.mjs +36 -6
  16. package/templates/base/apps/web/package.json +2 -1
  17. package/templates/base/apps/web/src/env.ts +41 -0
  18. package/templates/base/apps/web/src/layouts/Layout.astro +39 -11
  19. package/templates/base/apps/web/src/pages/index.astro +33 -24
  20. package/templates/base/apps/web/src/pages/privacy.astro +10 -2
  21. package/templates/base/apps/web/src/pages/terms.astro +10 -2
  22. package/templates/base/oxlint.config.mjs +125 -4
  23. package/templates/base/package.json +7 -3
  24. package/templates/base/packages/env/_env.example +54 -0
  25. package/templates/base/packages/env/package.json +35 -0
  26. package/templates/base/packages/env/scripts/env-setup.ts +87 -0
  27. package/templates/base/packages/env/src/check.test.ts +41 -0
  28. package/templates/base/packages/env/src/check.ts +50 -0
  29. package/templates/base/packages/env/src/define.test.ts +206 -0
  30. package/templates/base/packages/env/src/define.ts +315 -0
  31. package/templates/base/packages/env/src/deploy.test.ts +438 -0
  32. package/templates/base/packages/env/src/deploy.ts +518 -0
  33. package/templates/base/packages/env/src/dotenv.test.ts +179 -0
  34. package/templates/base/packages/env/src/dotenv.ts +173 -0
  35. package/templates/base/packages/env/src/env-file.ts +51 -0
  36. package/templates/base/packages/env/src/error.ts +38 -0
  37. package/templates/base/packages/env/src/example.test.ts +86 -0
  38. package/templates/base/packages/env/src/example.ts +90 -0
  39. package/templates/base/packages/env/src/index.ts +15 -0
  40. package/templates/base/packages/env/src/schema.ts +63 -0
  41. package/templates/base/packages/env/src/services.test.ts +83 -0
  42. package/templates/base/packages/env/src/services.ts +110 -0
  43. package/templates/base/packages/env/src/setup.test.ts +254 -0
  44. package/templates/base/packages/env/src/setup.ts +177 -0
  45. package/templates/base/packages/env/src/source.ts +20 -0
  46. package/templates/base/packages/env/src/sources.test.ts +137 -0
  47. package/templates/base/packages/env/src/sources.ts +93 -0
  48. package/templates/base/packages/env/src/zod.ts +8 -0
  49. package/templates/base/packages/env/tsconfig.json +13 -0
  50. package/templates/base/packages/tsconfig/base.json +7 -0
  51. package/templates/base/packages/ui/components.json +1 -1
  52. package/templates/base/packages/ui/package.json +4 -2
  53. package/templates/base/packages/ui/src/blocks/audience.tsx +73 -0
  54. package/templates/base/packages/ui/src/blocks/cta.tsx +41 -30
  55. package/templates/base/packages/ui/src/blocks/error-state.tsx +2 -0
  56. package/templates/base/packages/ui/src/blocks/faq.tsx +64 -36
  57. package/templates/base/packages/ui/src/blocks/feature-grid.tsx +48 -146
  58. package/templates/base/packages/ui/src/blocks/footer.tsx +129 -70
  59. package/templates/base/packages/ui/src/blocks/hero.tsx +183 -26
  60. package/templates/base/packages/ui/src/blocks/how-it-works.tsx +75 -0
  61. package/templates/base/packages/ui/src/blocks/logo-cloud.tsx +118 -0
  62. package/templates/base/packages/ui/src/blocks/navbar.tsx +204 -88
  63. package/templates/base/packages/ui/src/blocks/pain-points.tsx +131 -0
  64. package/templates/base/packages/ui/src/blocks/pricing-table.tsx +112 -94
  65. package/templates/base/packages/ui/src/blocks/showcase.tsx +185 -0
  66. package/templates/base/packages/ui/src/blocks/testimonials.tsx +85 -0
  67. package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +16 -12
  68. package/templates/base/packages/ui/src/components/badge.tsx +1 -1
  69. package/templates/base/packages/ui/src/components/button.tsx +2 -2
  70. package/templates/base/packages/ui/src/components/tabs.tsx +1 -1
  71. package/templates/base/packages/ui/src/containers/README.md +6 -5
  72. package/templates/base/packages/ui/src/content/landing.ts +590 -125
  73. package/templates/base/packages/ui/src/index.ts +6 -2
  74. package/templates/base/packages/ui/src/lib/external-link.ts +36 -0
  75. package/templates/base/packages/ui/src/lib/icons.ts +131 -0
  76. package/templates/base/packages/ui/src/lib/inline-code.ts +29 -0
  77. package/templates/base/packages/ui/src/lib/theme.ts +140 -56
  78. package/templates/base/packages/ui/src/styles/globals.css +47 -125
  79. package/templates/base/packages/ui/src/styles/web.css +359 -0
  80. package/templates/base/pnpm-workspace.yaml +0 -6
  81. package/templates/base/saasaloy.json +1 -0
  82. package/templates/base/stylelint.config.js +3 -3
  83. package/templates/base/turbo.json +13 -0
  84. package/templates/base/vitest.config.ts +21 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saasaloy",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Composable SaaS accelerator kit.",
5
5
  "keywords": [
6
6
  "saas",
@@ -117,9 +117,39 @@
117
117
  "type": "string"
118
118
  }
119
119
  },
120
+ "envServices": {
121
+ "type": "object",
122
+ "description": "Which services read each of this module's `envVars` — a service being a workspace that reads a `.env` (`api`, `web`, `admin`), or `deploy`, the Cloudflare credentials `pnpm deploy:production` loads with no file. `default` covers every key the module declares; a key named alongside it overrides that list for itself. `saasaloy add` groups the key list in `packages/env/.env.example` under a `# @services` line per distinct set. Omit it and the `PUBLIC_` prefix rule decides instead, with a warning naming the module: a prefixed key goes to every client service, everything else to the api.",
123
+ "properties": {
124
+ "default": {
125
+ "type": "array",
126
+ "uniqueItems": true,
127
+ "minItems": 1,
128
+ "items": {
129
+ "type": "string",
130
+ "enum": ["api", "web", "admin", "deploy"]
131
+ }
132
+ }
133
+ },
134
+ "propertyNames": {
135
+ "pattern": "^(default|[A-Z][A-Z0-9_]*)$"
136
+ },
137
+ "additionalProperties": {
138
+ "type": "array",
139
+ "uniqueItems": true,
140
+ "minItems": 1,
141
+ "items": { "type": "string", "enum": ["api", "web", "admin", "deploy"] }
142
+ }
143
+ },
144
+ "envOptional": {
145
+ "type": "array",
146
+ "description": "Keys from `envVars` that may stay empty. `saasaloy add` writes a `# Blank on purpose` line above each one in `packages/env/.env.example`, which is what stops `pnpm env:setup` failing on a key the capability's schema gives a default or marks optional. Every name here must also appear in `envVars`.",
147
+ "uniqueItems": true,
148
+ "items": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }
149
+ },
120
150
  "devVars": {
121
151
  "type": "object",
122
- "description": "Local-development values for a subset of `envVars`: NAME → the value `saasaloy add` pre-fills into the generated `apps/api/.dev.vars.example`. Only for a value that is the same on every machine and is not a secret (a loopback URL, a fixed port). Every key here must also appear in `envVars`; a key absent here is written with an empty value for the owner to fill in.",
152
+ "description": "Local-development values for a subset of `envVars`: NAME → the value `saasaloy add` pre-fills into the generated `packages/env/.env.example`. Only for a value that is the same on every machine and is not a secret (a loopback URL, a fixed port). Every key here must also appear in `envVars`; a key absent here is written with an empty value for the owner to fill in.",
123
153
  "propertyNames": {
124
154
  "pattern": "^[A-Z][A-Z0-9_]*$"
125
155
  },
@@ -10,9 +10,6 @@ This is a **pnpm workspace monorepo** managed by **Turborepo**.
10
10
  - **Root**: Configuration files, shared tooling
11
11
  - **`apps/*`**: Applications (Next.js, Astro apps - currently empty, will be added)
12
12
  - **`packages/*`**: Shared packages
13
- - **`infra`**: Centralized Cloudflare deployment (Pulumi), if the `infra` capability is installed —
14
- a root-level workspace, not nested under `apps/*` or `packages/*` (it's neither an app nor a
15
- shared package)
16
13
 
17
14
  ### Dev Port Map
18
15
 
@@ -25,7 +22,7 @@ drifting port turns into an unexplained CORS rejection.
25
22
  | `apps/web` (Astro) | 3000 | `http://localhost:3000` | `apps/web/astro.config.mjs` (`server.port`) |
26
23
  | `apps/admin` (TanStack Start) | 3001 | `http://localhost:3001` | `apps/admin/vite.config.ts` (`server.port`, `strictPort`) |
27
24
  | `apps/api` (Hono on Workers) | 4000 | `http://localhost:4000` | `apps/api/wrangler.jsonc` (`dev.port`) |
28
- | Postgres (local, `database-postgres`) | 5432 | `postgres://postgres:postgres@127.0.0.1:5432/app` | `DATABASE_URL` in `.env` |
25
+ | Postgres (local, `database-postgres`) | 5432 | `postgres://postgres:postgres@127.0.0.1:5432/app` | `DATABASE_URL` in `packages/env/.env.example` |
29
26
 
30
27
  The `apps/admin` and `apps/api` rows apply once you run `saasaloy add admin` or
31
28
  `saasaloy add api`.
@@ -36,8 +33,9 @@ Rules for a new app or service:
36
33
  from 4000. Add a row to this table in the same change.
37
34
  - **Set `strictPort` (or the framework's equivalent).** A busy port must fail loudly. A
38
35
  silent `+1` moves the app to an origin nothing allows.
39
- - **Name the origin, don't infer it.** A browser-side caller reads `PUBLIC_API_URL` and
40
- falls back to `http://localhost:4000`; a server-side caller reads its own env var.
36
+ - **Name the origin, don't infer it.** A browser-side caller reads `PUBLIC_API_URL`
37
+ through `webEnv()` or `adminEnv()`, with no fallback — an unset key fails the build
38
+ naming the key. A server-side caller reads its own key through `apiEnv(c.env)`.
41
39
  - **Update both allow-lists when you add a browser origin**: `DEV_ORIGINS` in
42
40
  `apps/api/src/index.ts` and `DEV_ORIGINS` in `packages/auth/src/auth.ts`.
43
41
 
@@ -90,16 +88,69 @@ without one is silently skipped by `turbo run clean` and leaves stale build outp
90
88
  and expecting it to mount: nothing globs that folder, and the entry file's `AppType` is
91
89
  what the typed `hc` client reads.
92
90
 
91
+ ### The `@repo/env` Environment Layer
92
+
93
+ `packages/env` owns every environment value this project reads. One tracked key list, one
94
+ command that distributes it, one typed gate in front of the capabilities.
95
+
96
+ - **`packages/env/.env.example` is the key list**, and it is the only example file. It is
97
+ tracked, and `saasaloy add` regenerates it from the installed modules' own descriptions.
98
+ A value you type in it survives that, and nothing is ever removed.
99
+ - **`packages/env/.env` holds the values**, and it is gitignored. `saasaloy env` prompts
100
+ for each unset key and writes it.
101
+ - **`pnpm env:setup` writes every service's own `.env`** from those two files. A *service*
102
+ is a workspace that reads a `.env` — `api`, `web`, `admin` — and
103
+ `packages/env/src/services.ts` is the table. The `deploy` row has no file: it holds the
104
+ Cloudflare credentials a deploy loads in memory.
105
+ - **`pnpm deploy:production` deploys every Worker.** It checks every service's production
106
+ values first, then runs `turbo run deploy`, where each deployable workspace runs
107
+ `env-deploy <service>`: build with the production values in the environment, then
108
+ `wrangler deploy --secrets-file`. The `local` source reads production values from the
109
+ gitignored `packages/env/.env.prod`. Nothing writes a production value under `apps/`.
110
+ - **`createEnv` is the gate.** `apiEnv(c.env)` on the server, `webEnv()` in a frontend.
111
+ It validates on the first call and throws once naming every failing key, its module and
112
+ what the key is for.
113
+ - **A `client` key must start with `PUBLIC_`**, and the type system enforces it. Client
114
+ code reading a server key throws by name.
115
+ - **There is no `.dev.vars`.** Every runtime here reads `.env`. A leftover `.dev.vars`
116
+ hides `.env` from wrangler without warning, so `saasaloy doctor` reports one and
117
+ `pnpm env:setup` carries its values across and deletes it.
118
+ - **Never read `process.env`** in a file a Worker imports. Node tooling reads it freely.
119
+
120
+ The `saasaloy-env` skill is the runbook: adding a key, the section rule, the server/client
121
+ split, blank-on-purpose keys, and the offline rule.
122
+
93
123
  ### The `@repo/ui` Design Layer
94
124
 
95
- `packages/ui` owns the design layer: the Tailwind 4 theme (`src/styles/globals.css`),
96
- the `cn()` helper, the vendored [shadcn](https://ui.shadcn.com) primitives in
97
- `src/components/`, the marketing **blocks** in `src/blocks/`, the store-bound
98
- **containers** in `src/containers/`, and the landing page's copy in `src/content/`.
99
- `apps/web` pulls the theme in once, through the shared layout that imports
100
- `@repo/ui/globals.css`.
125
+ `packages/ui` owns the design layer: the Tailwind 4 theme in `src/styles/`, the `cn()` helper and the client-state stores in `src/lib/`, the vendored [shadcn](https://ui.shadcn.com) primitives in `src/components/`, the **blocks** in `src/blocks/`, the shells and layouts in `src/containers/`, and the copy in `src/content/`.
126
+
127
+ Those layer folders are the whole top level of `src/`: `assets` (images, SVGs and icons, added when the first one arrives), `blocks`, `components`, `containers`, `content`, `lib` and `styles`. Do not add an app folder such as `src/admin/` or `src/web/` beside them. When one app needs its own files inside a layer, give that layer a child folder (`blocks/admin/`, `components/shared/`).
128
+
129
+ The theme is one shared file plus one theme file per app:
130
+
131
+ - **`src/styles/globals.css`** is the shared layer: the Tailwind imports, the `@source`
132
+ globs and the base layer. It holds no token values, and no app imports it directly.
133
+ - **`src/styles/web.css`** is the web app's theme: the `dark` custom variant, its `:root`
134
+ and `.dark` token values, its `@theme inline` colour mapping and its radius scale. It
135
+ imports `globals.css`, and `apps/web` pulls it in once, through the shared layout that
136
+ imports `@repo/ui/styles/web.css`. `components.json` points here, so a shadcn preset
137
+ lands here. The landing page's layout, type and motion utilities sit at the end of the
138
+ file, after the colour tokens, and a preset leaves them alone.
139
+ - **`src/styles/admin.css`** is the admin app's theme, in the same shape, written by
140
+ `saasaloy add admin`.
141
+
142
+ Each theme file repeats the `dark` variant and the `@theme inline` mapping on purpose.
143
+ shadcn merges a preset in place only when the target file itself holds them, and it does
144
+ not look through an `@import`. Move either one into `globals.css` and a preset appends a
145
+ duplicate to `web.css` instead.
101
146
 
102
- `DESIGN.md` at the repository root records the token values and the rules behind them. Read it before you add or change UI.
147
+ **Each app has its own `DESIGN.md`, in its own folder.** `apps/web/DESIGN.md` records the
148
+ web tokens in `web.css` and the rules behind them, and fingerprints `web.css`. An app
149
+ that carries its own theme file gets its own `apps/<app>/DESIGN.md`, which fingerprints
150
+ that file. There is no `DESIGN.md` at the repository root. Read the document for the app
151
+ you are changing before you add or change UI.
152
+
153
+ With the admin app installed, `apps/admin/DESIGN.md` records the admin tokens in `packages/ui/src/styles/admin.css` and fingerprints that file. A preset never targets `admin.css`; the admin theme changes by hand.
103
154
 
104
155
  Primitives, blocks and containers are reached by subpath — none is re-exported from the
105
156
  package root, so importing one never drags in the rest. The root export is project-wide
@@ -109,28 +160,32 @@ constants only (`siteName`):
109
160
  import { siteName } from "@repo/ui";
110
161
  import { Button } from "@repo/ui/components/button";
111
162
  import { PricingTable } from "@repo/ui/blocks/pricing-table";
112
- import { CartSummary } from "@repo/ui/containers/cart-summary";
113
163
  import { landing, ui } from "@repo/ui/content/landing";
114
164
  import { cn } from "@repo/ui/lib/utils";
115
165
  ```
116
166
 
117
- **Three layers, split by state scope — not by "has state".**
167
+ **The admin app has its own primitive set.** `saasaloy add admin` writes it into `src/components/admin/`, with its blocks in `src/blocks/admin/` and its shell and layouts in `src/containers/admin/`. Admin code imports only those, and a lint rule makes a base primitive under `apps/admin/**` an error, because both sets have a `button` and the wrong one renders with no complaint:
168
+
169
+ ```ts
170
+ import { Button } from "@repo/ui/components/admin/button";
171
+ import { DataTable } from "@repo/ui/blocks/admin/data-table";
172
+ import { PageHeader } from "@repo/ui/blocks/admin/page-header";
173
+ import { PageLayout } from "@repo/ui/containers/admin/page-layout";
174
+ ```
175
+
176
+ The `saasaloy-admin` skill lists the whole set and how a page composes it.
177
+
178
+ **Seven top-level folders, and no app folder at the root of `src/`.** A folder may hold `admin/`, `web/` or `shared/` children where one app owns the files, for example `src/blocks/admin/` or `src/containers/admin/`, which `saasaloy add admin` writes. A project without that app carries none of them.
118
179
 
119
- 1. **`src/components/`** — vendored shadcn primitives. No stores, no copy.
120
- 2. **`src/blocks/`** — compositions of primitives plus copy from the content module. Local
121
- state is allowed (an open accordion, a ticking clock). Importing a shared store is
122
- forbidden: a block takes its data as props and reports changes through callbacks, so
123
- every app — the Astro site, a React SPA's panels — can drive the same block from its own
124
- data source.
125
- 3. **`src/containers/`** — compositions that bind a shared store or cross-island state. A
126
- container subscribes, then wires data and callbacks into pure blocks. Containers are
127
- thin, app-flavored wiring; the visual weight stays in the blocks.
180
+ 1. **`src/assets/`** holds images, SVGs and icons. The base ships no asset, so the folder does not exist until the first one lands, together with an `assets.d.ts` that types its imports.
181
+ 2. **`src/components/`** holds the vendored shadcn primitives, official and custom. No stores, no copy.
182
+ 3. **`src/blocks/`** holds compositions of primitives plus copy from the content module. Local state is allowed (an open accordion, a ticking clock). Importing a shared store is forbidden: a block takes its data as props and reports changes through callbacks, so every app can drive the same block from its own data source.
183
+ 4. **`src/containers/`** holds the shells and layouts that hold blocks and components on a page: an app shell, a page layout, a page frame. A container takes its data, its links and its callbacks as props. It imports no router and no session, so the app that owns them passes them in.
184
+ 5. **`src/content/`** holds copy, in one place for later translation.
185
+ 6. **`src/lib/`** holds utilities and the client-state stores.
186
+ 7. **`src/styles/`** holds the CSS files with the token variables.
128
187
 
129
- **One rule covers the whole package: no IO in `@repo/ui`.** No fetch, no persistence, no
130
- auth, in any of the three layers. Client-state stores (nanostores) live in `src/lib/` and
131
- are bound **only** in containers; the app that owns the data hydrates the store. When a
132
- store-shaped need shows up in a block, move the binding up to a container instead of
133
- importing the store in place.
188
+ **One rule covers the whole package: no IO in `@repo/ui`.** No fetch, no persistence, no auth, no router, in any layer. Client-state stores (nanostores) live in `src/lib/`; the app that owns the data hydrates the store and passes what a block needs as props. When a store-shaped need shows up in a block, move the binding up into the app instead of importing the store in place.
134
189
 
135
190
  **Adding a primitive the base doesn't vendor.** `shadcn` is already an exact-pinned
136
191
  dependency of `@repo/ui`, so reach it with `--filter … exec` — that runs in the package
@@ -152,7 +207,7 @@ pnpm --filter @repo/ui exec shadcn add dialog
152
207
  imports and a blanket glob charges every app for every primitive. A primitive the
153
208
  landing page or `src/blocks/` renders gets an `@source "../components/<name>.tsx";` line
154
209
  in `globals.css`; a primitive only one app renders gets its `@source` line in that app's
155
- own stylesheet, the way `apps/admin/src/styles/admin.css` takes the whole directory.
210
+ own theme file, the way `src/styles/admin.css` takes the whole directory.
156
211
  Nothing errors when you skip this — the classes just never reach the CSS.
157
212
  - `style` is `base-nova` (Base UI) and is fixed at init — the CLI cannot change it later.
158
213
  - `rsc` is `false`, so the CLI strips the `"use client"` directive for you. It means
@@ -160,8 +215,10 @@ pnpm --filter @repo/ui exec shadcn add dialog
160
215
 
161
216
  Primitives are source you own. Edit them in place rather than wrapping them.
162
217
 
218
+ **No UI kit or component library as a dependency.** When a design comes from a kit (Tailark, coss ui, a licensed dashboard kit), copy or rebuild the component into `src/components/` or `src/blocks/` as source this project owns. Do not install the kit's package, and keep no link to its repository for later updates. A copied component changes by hand, like any other file here. The shadcn CLI above is the one sanctioned way to fetch a primitive, because it writes source and leaves no runtime dependency behind.
219
+
163
220
  **Swapping the whole theme for a preset.** The token set in
164
- `packages/ui/src/styles/globals.css` is shadcn's `neutral` with `cssVariables: true`, and
221
+ `packages/ui/src/styles/web.css` is shadcn's `neutral` with `cssVariables: true`, and
165
222
  any shadcn **`registry:style`** item is a drop-in replacement for it. The same
166
223
  `--filter … exec` form applies — there is no separate theme command:
167
224
 
@@ -170,10 +227,11 @@ pnpm --filter @repo/ui exec shadcn add https://tweakcn.com/r/themes/modern-minim
170
227
  ```
171
228
 
172
229
  The CLI merges the preset's `:root`, `.dark` and `@theme inline` blocks **into** the
173
- existing ones, so the file's hand-written parts — the `@source` rules, the
174
- `@custom-variant dark`, the `@layer base` rules — stay put, and `components.json` is not
175
- touched. It usually *extends* `@theme inline` with mappings the base does not carry
176
- (fonts, tracking, shadows); that is expected.
230
+ existing ones in `web.css` and keeps its `@custom-variant dark`, so the hand-written
231
+ parts in `globals.css` — the `@source` rules and the `@layer base` rules — stay put, and
232
+ `components.json` is not touched. The preset reaches the web app only; the admin theme
233
+ in `admin.css` keeps its own values. It usually *extends* `@theme inline` with mappings
234
+ the base does not carry (fonts, tracking, shadows); that is expected.
177
235
 
178
236
  Two places to get an item from:
179
237
 
@@ -185,19 +243,78 @@ Two places to get an item from:
185
243
  Neither is special: the mechanism is the `registry:style` shape, so any URL that serves
186
244
  one works.
187
245
 
188
- **This edits a base file, and base files have no update path.** Saasaloy hands you the
189
- template once; it never comes back to migrate it. A swapped theme is yours to maintain,
190
- including re-applying anything you had customised in `globals.css` that the preset
191
- overwrote. Diff the file after running the command rather than assuming.
192
-
193
- A preset swap invalidates `DESIGN.md`, which records the token values the old theme had. The `saasaloy-design` skill's `theme` flow runs the command above for you and re-derives the contract from the merged result, so prefer it over running the command by hand. Pick one or the other — running both applies the preset twice.
194
-
195
- Light/dark/system switching is unaffected by any of this — it keys off the `.dark` class,
196
- which every preset keeps.
197
-
198
- **Blocks — the page-level compositions.** `src/blocks/` holds the marketing blocks the
199
- landing page is built from: `navbar`, `hero`, `feature-grid`, `pricing-table`, `faq`,
200
- `cta`, `footer`. The rules are not stylistic — each one prevents a real failure:
246
+ **This edits a file the project owns.** `web.css` is an owned base file: `saasaloy
247
+ update` never overwrites it, and a later template change to it reaches you only through
248
+ the merge plan. A swapped theme is yours to maintain, including re-applying anything you
249
+ had customised in `web.css` that the preset overwrote. Diff the file after running the
250
+ command rather than assuming.
251
+
252
+ A preset swap invalidates `apps/web/DESIGN.md`, which records the token values the old theme had. The `saasaloy-design` skill's `theme` flow runs the command above for you and re-derives the contract from the merged result, so prefer it over running the command by hand. Pick one or the other — running both applies the preset twice.
253
+
254
+ Theme switching is unaffected by any of this. It keys off the `.dark` class, which every preset keeps. The toggle has two states, light and dark: `system` is only the starting state for a visitor who never pressed it, and no press steps back to `system`. Do not give the toggle a third state or a light/dark/system menu.
255
+
256
+ **A project made before the theme split.** Older projects kept the web tokens in
257
+ `globals.css` and the design document at the repository root. `saasaloy update` creates
258
+ `packages/ui/src/styles/web.css` and lists `globals.css` and `Layout.astro` in the merge
259
+ plan, because the project owns both; it never moves your tokens for you. Finish the split
260
+ by hand, in this order:
261
+
262
+ 1. Move the `@custom-variant dark` line, the `:root` block, the `.dark` block and the
263
+ `@theme inline` block from `globals.css` into `web.css`, replacing the template's
264
+ copies there, below its `@import "./globals.css";`. Keep your own values, not the
265
+ template's.
266
+ 2. Add `"./styles/*": "./src/styles/*"` to the `exports` of `packages/ui/package.json`,
267
+ if the update did not.
268
+ 3. In `apps/web/src/layouts/Layout.astro`, import `@repo/ui/styles/web.css` in place of
269
+ `@repo/ui/globals.css`.
270
+ 4. Set `tailwind.css` in `packages/ui/components.json` to `src/styles/web.css`, if the
271
+ update did not.
272
+ 5. Move `DESIGN.md` to `apps/web/DESIGN.md`, and run the `saasaloy-design` skill's
273
+ `update` flow so its fingerprint names `web.css`.
274
+ 6. Run `pnpm build` and check that `globals.css` and `web.css` hold one `@theme inline`
275
+ and one `@custom-variant dark` between them.
276
+
277
+ With the admin app installed, `saasaloy update` also moves its theme: it creates
278
+ `packages/ui/src/styles/admin.css` and deletes `apps/admin/src/styles/admin.css`, or
279
+ keeps it with a warning when you edited it. Carry any edit across to the new file, then
280
+ delete the old one. The new admin app imports `@repo/ui/styles/admin.css`, so its build
281
+ fails until step 2 adds the `./styles/*` export. Update the base and finish step 2 before
282
+ you build admin, and before you run `saasaloy add admin` on a project made before the
283
+ split.
284
+
285
+ **Blocks — the page-level compositions.** `src/blocks/` holds the thirteen marketing
286
+ blocks the landing page is built from: `navbar`, `hero`, `logo-cloud`, `pain-points`,
287
+ `how-it-works`, `feature-grid`, `showcase`, `audience`, `testimonials`, `pricing-table`,
288
+ `faq`, `cta` and `footer`. `logo-cloud`, `pain-points`, `how-it-works`, `showcase`,
289
+ `audience` and `testimonials` are new; a project scaffolded before them gets the files
290
+ through `saasaloy update`, and adds each to its own `index.astro` by hand, since the page
291
+ is the owner's. The blocks need the landing utilities at the end of `web.css`. `web.css`
292
+ is an owned file, so `update` lists it in the merge plan and never adds them for you.
293
+ Copy the template's block, from the comment that opens "The landing page's layout" to
294
+ the `stylelint-enable` line, to the end of your `web.css`. A project made before the
295
+ theme split skips this: `update` creates its `web.css` from the template, block included.
296
+
297
+ **The section recipe.** `index.astro` composes the page from explicit imports, one line
298
+ per section. To remove a section, delete its line and its import, delete its file in
299
+ `src/blocks/`, and blank its navbar and footer labels in `landing.ts`. To reorder, move
300
+ the lines. Every block uses the same container (`section-container`, `-narrow`, `-wide`)
301
+ and spacing (`section-y`) utilities from `src/styles/web.css`, so any order keeps one
302
+ rhythm. A block sets no width, no section padding and no keyframes of its own; motion
303
+ comes from the named utilities there (`blur-in`, `beam-in`, `reveal`, `marquee`,
304
+ `shine-text`, `nav-float`, `hover-lift`), and every one stops under reduced motion.
305
+
306
+ **The logo recipe.** `landing.logos.items[]` is `{ id, name, mark }`. `mark` is a key in
307
+ `LOGO_MARKS` at the top of `blocks/logo-cloud.tsx`. Paste a customer's SVG there under a
308
+ kebab-case key with `fill="currentColor"`, then name the key in content. An unknown key
309
+ renders `name` as a text wordmark.
310
+
311
+ **The production bar.** The page holds to a rubric: no axe violation at WCAG 2.1 AA in
312
+ either theme, no sideways scroll from 360px up, nothing animating under reduced motion, a
313
+ skip link to `<main>` on every page, link-preview metadata on `/`, and exactly two islands
314
+ (`Navbar`, `PricingTable`). Keep each true when you change a block: a new section with
315
+ state is a third island and needs a reason.
316
+
317
+ The rules below are not stylistic — each one prevents a real failure:
201
318
 
202
319
  - **One block, one file, one component export (plus its prop types).**
203
320
  `pricing-table.tsx` exports `PricingTable` alongside `PricingTier` and
@@ -222,13 +339,13 @@ landing page is built from: `navbar`, `hero`, `feature-grid`, `pricing-table`, `
222
339
  `client:load` on the page hydrates everything and throws away the reason this is a
223
340
  static site — see `apps/web/src/pages/index.astro` for the worked example.
224
341
  - **`theme-toggle` is the exception that proves the rule.** It sits in `src/blocks/`
225
- beside the marketing blocks but is chrome, not copy: `index.astro` places it as a
226
- sibling of `<Navbar />` and it takes **no** client directive despite being
227
- interactive. It has no `onClick` and no state — the pre-paint inline script the shared
228
- layout emits (`THEME_INIT_SCRIPT`, from `packages/ui/src/lib/theme.ts`) drives every
342
+ beside the marketing blocks but is chrome, not copy: `navbar.tsx` renders it inside the
343
+ bar, and it needs **no** client directive of its own despite being interactive. It has
344
+ no `onClick` and no state — the pre-paint inline script the shared layout emits
345
+ (`THEME_INIT_SCRIPT`, from `packages/ui/src/lib/theme.ts`) drives every
229
346
  `[data-theme-toggle]` on the page through one delegated listener, and CSS picks the
230
347
  icon off `<html data-theme>`. Move it, restyle it via its `className`, or delete the
231
- one line in `index.astro` to drop it. Do **not** "fix" it by adding `client:*` or an
348
+ one line in `navbar.tsx` to drop it. Do **not** "fix" it by adding `client:*` or an
232
349
  `onClick`, and do not paste a second copy of the boot script into a page: any document
233
350
  that renders the block must inline that one constant in its `<head>`, or the control
234
351
  stays hidden.
@@ -259,8 +376,9 @@ landing page is built from: `navbar`, `hero`, `feature-grid`, `pricing-table`, `
259
376
  React file under `apps/web/src/components/` builds the client and passes the function
260
377
  down. Astro serializes island props, so the page renders **that** file, never the block
261
378
  directly.
262
- - **A block never imports a store either.** Local state is fine; shared state is not. See
263
- the containers section below.
379
+ - **A link that leaves the site opens a new tab.** Spread `externalLinkProps(href)` from `@repo/ui/lib/external-link` onto the anchor and render `newTabHint(href)` inside it. The href decides: an absolute URL gets `target="_blank"`, `rel="noopener noreferrer"` and a screen-reader hint, and a path or a same-page anchor stays in the tab. Do not write `target="_blank"` by hand.
380
+ - **A value that waits on a request shows a skeleton.** Render the `Skeleton` primitive at the size the value will take until the response arrives, never a blank gap or a spinner. Add it with `pnpm --filter @repo/ui exec shadcn add skeleton` the first time a block needs it.
381
+ - **A block never imports a store either.** Local state is fine; shared state is not. The app-side island reads the store and passes the values and the handlers down, the same pattern it uses for `onSubmit`.
264
382
  - **A module's UI arrives here too, and you wire it up by hand.** `saasaloy add <module>`
265
383
  writes a UI-bearing module's block into `src/blocks/` exactly like the base blocks, adds
266
384
  the app-side island that feeds it, and then stops: nothing globs a folder, and no command
@@ -271,25 +389,12 @@ landing page is built from: `navbar`, `hero`, `feature-grid`, `pricing-table`, `
271
389
 
272
390
  Blocks, like primitives, are source you own — they are meant to be edited, not wrapped.
273
391
 
274
- **Containers — the store bindings.** `src/containers/` holds the compositions that read
275
- shared state. The base ships none; the folder carries a note until you add your first.
276
-
277
- - **A container subscribes, a block renders.** The container reads the store, passes the
278
- values down as props, and passes handlers that write back to the store. It adds no markup
279
- a block could hold. If a container grows layout and copy, that part belongs in a block.
280
- - **The store lives in `src/lib/`, and only a container imports it.** A block that imports
281
- a store is a block a second app cannot reuse, because the store now decides where its data
282
- comes from. Move the binding up.
283
- - **Still no IO.** A container reads client state; it does not fetch, persist, or
284
- authenticate. The app hydrates the store — the same app-side island pattern the blocks use
285
- for `onSubmit`.
286
- - **Same file rules as blocks:** one container, one file, one component export, semantic
287
- kebab-case filename, no barrel. Astro gives every `client:*` component its own React root,
288
- so a container that a page hydrates must hold the whole composition.
289
- - **A container almost always needs a client directive.** It subscribes at runtime, so it
290
- ships JavaScript by definition. Pick the cheapest one that works (`client:idle` above the
291
- fold, `client:visible` below it), and keep the blocks it renders static in every page that
292
- does not need the store.
392
+ **Containers — the shells and the layouts.** `src/containers/` holds the compositions that place blocks and components on a page: an app shell, a page layout, a page frame. The base ships none; `saasaloy add admin` writes the admin app's into `src/containers/admin/`.
393
+
394
+ - **A container places, a block renders.** It decides where the panels, the header and the detail view go, and it adds no copy a block could hold.
395
+ - **No router, no session.** A container takes the nav data, the current area, a link component and the account as props. The app reads its router and its session and passes them in, so a container never depends on one app's routes.
396
+ - **Still no IO.** A container does not fetch, persist, or authenticate.
397
+ - **Same file rules as blocks:** one container per file, semantic kebab-case filename, no barrel.
293
398
 
294
399
  **The content module — where the words live.** `packages/ui/src/content/landing.ts` holds
295
400
  every user-visible string on the landing page, in two namespaces:
@@ -328,20 +433,27 @@ record and nothing else. Follow them when you add a block or a key:
328
433
  messages (`ui.pricing.perMonth`, `ui.pricing.perMonthAnnual`) — word order does not
329
434
  survive the seam in every language.
330
435
  5. **Only user-visible strings move.** Section `id`s and the same-page anchors pointing at
331
- them are structure and stay in the block. Three things break the rule, all because a
436
+ them are structure and stay in the block. A few things break the rule, all because a
332
437
  thing rewritten *with* the copy belongs *near* the copy: the whole `tiers` array (prices
333
- and `ctaHref`s included); each feature's `icon`, held as a registry *name* like `"zap"`
334
- and resolved to a component by the map at the top of `blocks/feature-grid.tsx`; and the
335
- two outbound calls to action, `landing.navbar.ctaHref` and
336
- `landing.cta.primaryActionHref`/`.secondaryActionHref`, which leave the page and so
337
- cannot break a section link. A translation layer reads `id`, `icon` and every `*Href` as
338
- non-message data.
438
+ and `ctaHref`s included); every `icon`, held as a registry *name* like `"zap"` and
439
+ resolved to a component by `packages/ui/src/lib/icons.ts`, and every logo `mark`,
440
+ resolved by `blocks/logo-cloud.tsx`; every outbound link (`*Href`), which leaves the
441
+ page and so cannot break a section link; and the code the page quotes (terminal
442
+ commands, module names, file paths). A translation layer reads `id`, `icon`, `mark`,
443
+ every `*Href` and the quoted code as non-message data. A pair of backticks inside a
444
+ message renders as `<code>` through `inlineCode()` from `@repo/ui/lib/inline-code`.
445
+
446
+ **No copy that goes stale.** Do not write a version number, a release date or a count into a message. Say something general and link to where the current value lives: the hero pill says "See what's new" and links to the changelog, so it stays true across every release.
339
447
 
340
448
  Two consequences worth knowing. Blanking a navbar or footer link's label in content drops
341
449
  that link, which is how a removed section loses its nav entry without editing a block. And
342
450
  the theme toggle's labels deliberately stay in `packages/ui/src/lib/theme.ts`: that file is
343
451
  inlined verbatim into a pre-paint `<script>` and is import-free on purpose.
344
452
 
453
+ **The default copy is Saasaloy's own.** A fresh project's page describes Saasaloy, and
454
+ `siteName` in `packages/ui/src/index.ts` defaults to `"Saasaloy"`, so the brand and the
455
+ words agree until you replace both. The two skills below replace them in one pass.
456
+
345
457
  **Making this project yours.** Three skills ship with the base (linked at
346
458
  `.claude/skills/`, real files in `.agents/skills/`). The first two run in order:
347
459
 
@@ -356,12 +468,12 @@ inlined verbatim into a pre-paint `<script>` and is import-free on purpose.
356
468
 
357
469
  The third has no place in that order, because it runs whenever the UI moves:
358
470
 
359
- 3. **`saasaloy-design`** keeps `DESIGN.md` true. Its `theme` flow swaps the preset and
360
- re-derives the contract; `update` re-derives after you change `globals.css` or add
361
- components; `audit` reports where the code and the contract disagree. It reads the
471
+ 3. **`saasaloy-design`** keeps `apps/web/DESIGN.md` true, and `apps/admin/DESIGN.md` when the admin app is installed. Its `theme` flow swaps the
472
+ preset and re-derives the contract; `update` re-derives after you change `web.css` or
473
+ add components; `audit` reports where the code and the contract disagree. It reads the
362
474
  product brief when one exists and never writes it.
363
475
 
364
- Invoke them rather than editing eight blocks by hand — and if you do edit by hand, keep the
476
+ Invoke them rather than editing thirteen blocks by hand — and if you do edit by hand, keep the
365
477
  strings in the content module so the next pass finds them.
366
478
 
367
479
  ### Naming Conventions
@@ -418,13 +530,14 @@ hook (which is also how you keep hooks out of CI).
418
530
  - Run type checking: `pnpm typecheck` (must pass before commits)
419
531
  - Run linting: `pnpm lint` (see above — it reports, `pnpm lint:fix` fixes)
420
532
  - Check formatting: `pnpm format:check`, or `pnpm format` to rewrite
421
- - There is no `pnpm test` at the root, and no workspace declares a `test` script. The base ships no test runner: pick one and add it per workspace when you have something to test.
533
+ - Run the tests: `pnpm test`. It runs `turbo run test` (each workspace's own `test` script), then `vitest run` from the root, whose `vitest.config.ts` treats every workspace under `apps/*` and `packages/*` as a project. Write new tests for Vitest: put a `*.test.ts` file beside the code, and give the workspace a `vitest.config.ts` only when it needs setup of its own. Do not add a per-workspace `test` script that calls Vitest, or the file runs twice. `packages/env` is the exception: its tests run on `node --test` through its own `test` script, and the root config leaves it out.
534
+ - Before you hand over UI work, run `pnpm dev`, load the pages you changed, and read the dev server's terminal output. Fix every error and warning it prints. A clean build does not prove a clean dev server.
422
535
 
423
536
  ## Boundaries
424
537
 
425
538
  ### ✅ Always Do
426
539
 
427
- - Read `DESIGN.md` before writing or changing UI
540
+ - Read the app's `DESIGN.md` (`apps/web/DESIGN.md` for the web app, `apps/admin/DESIGN.md` for the admin app) before writing or changing UI
428
541
  - Run `pnpm typecheck` before committing code changes
429
542
  - Run `pnpm lint` and fix all errors
430
543
  - Give every new app or package a `clean` script backed by `rimraf` (see above)
@@ -434,6 +547,7 @@ hook (which is also how you keep hooks out of CI).
434
547
  ### ⚠️ Ask First
435
548
 
436
549
  - Adding new dependencies (especially to root `package.json`)
550
+ - Building new UI before the owner has approved a mockup. Show screenshots of the mockup first and get approval. A change after approval gets a new mockup, not a fix after the work lands
437
551
  - Modifying Turborepo configuration (`turbo.json`)
438
552
  - Changing TypeScript strictness settings
439
553
  - Modifying the husky hooks (`.husky/`), `lint-staged.config.js`, or `commitlint.config.js`
@@ -447,11 +561,12 @@ hook (which is also how you keep hooks out of CI).
447
561
 
448
562
  - Never use `npm` or `npx`, instead use `pnpm` & `pnpm dlx`
449
563
  - Never use `rm -rf` in a package script — it breaks on Windows; use `rimraf`
450
- - Commit secrets, API keys, or environment variables
564
+ - Commit secrets or API keys. `packages/env/.env` is gitignored; `packages/env/.env.example`
565
+ is tracked and holds local defaults only
451
566
  - Modify `node_modules/` or `pnpm-lock.yaml` manually (use `pnpm install`)
452
567
  - Remove or disable TypeScript strict mode
453
568
  - Remove or disable the lint-staged or commitlint hooks, or commit with `--no-verify`
454
569
  as a habit rather than an emergency
455
570
  - Use `any` type without explicit `@ts-expect-error` or `@ts-ignore` with justification
456
- - Break the workspace structure (don't move packages outside `apps/*`, `packages/*`, or `infra`)
571
+ - Break the workspace structure (don't move packages outside `apps/*` or `packages/*`)
457
572
  - Commit without running type checks and linting
@@ -30,7 +30,7 @@ saasaloy list # what the registry offers, and what you already hav
30
30
 
31
31
  ## UI components
32
32
 
33
- `DESIGN.md` records the project's design tokens and UI rules for people and agents.
33
+ `apps/web/DESIGN.md` records the web app's design tokens and UI rules for people and agents.
34
34
 
35
35
  `packages/ui` ships a Tailwind 4 theme, a small set of [shadcn](https://ui.shadcn.com)
36
36
  primitives, and the marketing blocks the landing page is built from. Import primitives and
@@ -54,14 +54,26 @@ Components land in `packages/ui/src/components/` as source you own and can edit.
54
54
 
55
55
  ## Landing page
56
56
 
57
- `apps/web/src/pages/index.astro` composes the blocks in `packages/ui/src/blocks/` —
58
- `navbar`, `hero`, `feature-grid`, `pricing-table`, `faq`, `cta`, `footer`. Each is one
59
- self-contained `.tsx` with its copy as in-file defaults, so editing the file is how you
60
- change the page.
57
+ `apps/web/src/pages/index.astro` composes thirteen blocks from `packages/ui/src/blocks/`: `navbar`, `hero`, `logo-cloud`, `pain-points`, `how-it-works`, `feature-grid`, `showcase`, `audience`, `testimonials`, `pricing-table`, `faq`, `cta` and `footer`. The page ships describing Saasaloy itself, so it reads as a finished product on day one. You make it yours with two edits and no others:
61
58
 
62
- Blocks render to static HTML by default. Only the three that need browser state carry a
63
- client directive (`Navbar` is `client:idle`; `PricingTable` and `Faq` are
64
- `client:visible`) — the rest ship no JavaScript at all. Don't reach for `client:load`.
59
+ 1. **Rewrite the words** in `packages/ui/src/content/landing.ts`: every heading, link, price and icon name. The `saasaloy-landing-copy` skill drafts it from your product brief. The brand is `siteName` in `packages/ui/src/index.ts`, which `saasaloy-setup` sets.
60
+ 2. **Choose the sections** in `index.astro`. Each section is one import and one line.
61
+
62
+ ### The section recipe
63
+
64
+ - **Remove a section:** delete its line and its import in `index.astro`, then delete its file in `packages/ui/src/blocks/`. Blank its navbar and footer labels in `landing.ts` so no link points at a section that is gone.
65
+ - **Reorder:** move the lines. Every block uses the same container and spacing utilities, so any order keeps one rhythm.
66
+ - **Bring one back:** copy the block file in from a fresh `saasaloy init`, then add the import and the line.
67
+
68
+ ### The logo recipe
69
+
70
+ The logo row names each logo in `landing.logos.items` as `{ id, name, mark }`. `mark` is a key in the `LOGO_MARKS` registry at the top of `packages/ui/src/blocks/logo-cloud.tsx`. To add a customer's logo, paste its SVG into that registry under a kebab-case key with `fill="currentColor"`, then put the key in the item's `mark`. A key the registry doesn't know renders the item's `name` as a text wordmark, which is what the default row does.
71
+
72
+ ### What the page promises
73
+
74
+ Blocks render to static HTML by default. Only two carry a client directive: `Navbar` (`client:idle`) and `PricingTable` (`client:visible`). The FAQ and the pain points open with native `<details>`, all motion is CSS and stops under reduced motion, and the footer adds one inline script that writes the current year. Don't reach for `client:load`.
75
+
76
+ The page is checked against a written rubric: no axe violation at WCAG 2.1 AA in either theme, no sideways scroll from 360px up, a skip link on every page, and link-preview metadata on `/`. Keep those true when you change a block.
65
77
 
66
78
  The page composes itself from explicit imports, and nothing discovers a file behind your
67
79
  back. A module that ships UI writes its block into `packages/ui/src/blocks/` like every