saasaloy 0.1.0

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 (69) hide show
  1. package/dist/index.js +8455 -0
  2. package/dist/index.js.map +1 -0
  3. package/package.json +69 -0
  4. package/schemas/manifest.schema.json +107 -0
  5. package/schemas/registry-item.schema.json +336 -0
  6. package/schemas/saasaloy-lock.schema.json +86 -0
  7. package/schemas/saasaloy.schema.json +42 -0
  8. package/templates/base/AGENTS.md +450 -0
  9. package/templates/base/CLAUDE.md +1 -0
  10. package/templates/base/DESIGN.md +209 -0
  11. package/templates/base/README.md +71 -0
  12. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +198 -0
  13. package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +395 -0
  14. package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +273 -0
  15. package/templates/base/_gitignore +32 -0
  16. package/templates/base/_husky/commit-msg +1 -0
  17. package/templates/base/_husky/pre-commit +1 -0
  18. package/templates/base/_prettierignore +31 -0
  19. package/templates/base/_saasaloy-base.json +9 -0
  20. package/templates/base/apps/web/astro.config.mjs +61 -0
  21. package/templates/base/apps/web/package.json +30 -0
  22. package/templates/base/apps/web/public/favicon.svg +4 -0
  23. package/templates/base/apps/web/src/layouts/Layout.astro +52 -0
  24. package/templates/base/apps/web/src/pages/404.astro +24 -0
  25. package/templates/base/apps/web/src/pages/500.astro +33 -0
  26. package/templates/base/apps/web/src/pages/index.astro +61 -0
  27. package/templates/base/apps/web/src/pages/privacy.astro +15 -0
  28. package/templates/base/apps/web/src/pages/terms.astro +14 -0
  29. package/templates/base/apps/web/tsconfig.json +11 -0
  30. package/templates/base/apps/web/wrangler.jsonc +27 -0
  31. package/templates/base/commitlint.config.js +9 -0
  32. package/templates/base/lint-staged.config.js +17 -0
  33. package/templates/base/oxlint.config.mjs +155 -0
  34. package/templates/base/package.json +44 -0
  35. package/templates/base/packages/tsconfig/base.json +17 -0
  36. package/templates/base/packages/tsconfig/package.json +18 -0
  37. package/templates/base/packages/ui/components.json +19 -0
  38. package/templates/base/packages/ui/package.json +39 -0
  39. package/templates/base/packages/ui/src/blocks/cta.tsx +82 -0
  40. package/templates/base/packages/ui/src/blocks/error-state.tsx +144 -0
  41. package/templates/base/packages/ui/src/blocks/faq.tsx +64 -0
  42. package/templates/base/packages/ui/src/blocks/feature-grid.tsx +185 -0
  43. package/templates/base/packages/ui/src/blocks/footer.tsx +99 -0
  44. package/templates/base/packages/ui/src/blocks/hero.tsx +84 -0
  45. package/templates/base/packages/ui/src/blocks/navbar.tsx +159 -0
  46. package/templates/base/packages/ui/src/blocks/pricing-table.tsx +175 -0
  47. package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +51 -0
  48. package/templates/base/packages/ui/src/components/accordion.tsx +78 -0
  49. package/templates/base/packages/ui/src/components/badge.tsx +53 -0
  50. package/templates/base/packages/ui/src/components/button.tsx +59 -0
  51. package/templates/base/packages/ui/src/components/card.tsx +103 -0
  52. package/templates/base/packages/ui/src/components/input.tsx +20 -0
  53. package/templates/base/packages/ui/src/components/label.tsx +18 -0
  54. package/templates/base/packages/ui/src/components/separator.tsx +23 -0
  55. package/templates/base/packages/ui/src/containers/README.md +11 -0
  56. package/templates/base/packages/ui/src/content/errors.ts +58 -0
  57. package/templates/base/packages/ui/src/content/landing.ts +304 -0
  58. package/templates/base/packages/ui/src/index.ts +8 -0
  59. package/templates/base/packages/ui/src/lib/interpolate.ts +31 -0
  60. package/templates/base/packages/ui/src/lib/sentinel.ts +11 -0
  61. package/templates/base/packages/ui/src/lib/theme.ts +167 -0
  62. package/templates/base/packages/ui/src/lib/utils.ts +11 -0
  63. package/templates/base/packages/ui/src/styles/globals.css +164 -0
  64. package/templates/base/packages/ui/tsconfig.json +7 -0
  65. package/templates/base/pnpm-workspace.yaml +23 -0
  66. package/templates/base/prettier.config.js +10 -0
  67. package/templates/base/saasaloy.json +8 -0
  68. package/templates/base/stylelint.config.js +46 -0
  69. package/templates/base/turbo.json +20 -0
@@ -0,0 +1,450 @@
1
+ # {{PROJECT_NAME}} — agent instructions
2
+
3
+ This is a SaaS project scaffolded with Saasaloy.
4
+
5
+
6
+ ## Project Structure
7
+
8
+ This is a **pnpm workspace monorepo** managed by **Turborepo**.
9
+
10
+ - **Root**: Configuration files, shared tooling
11
+ - **`apps/*`**: Applications (Next.js, Astro apps - currently empty, will be added)
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
+
17
+ ### Dev Port Map
18
+
19
+ Every dev port in this repo is pinned, not assigned on the fly. The api Worker's CORS
20
+ allow-list and the auth module's trusted origins hardcode the localhost origins, so a
21
+ drifting port turns into an unexplained CORS rejection.
22
+
23
+ | Workspace | Port | Dev URL | Pinned in |
24
+ | --- | --- | --- | --- |
25
+ | `apps/web` (Astro) | 3000 | `http://localhost:3000` | `apps/web/astro.config.mjs` (`server.port`) |
26
+ | `apps/admin` (TanStack Start) | 3001 | `http://localhost:3001` | `apps/admin/vite.config.ts` (`server.port`, `strictPort`) |
27
+ | `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` |
29
+
30
+ The `apps/admin` and `apps/api` rows apply once you run `saasaloy add admin` or
31
+ `saasaloy add api`.
32
+
33
+ Rules for a new app or service:
34
+
35
+ - **Take the next free port in the block** — `apps/*` count up from 3000, backend services
36
+ from 4000. Add a row to this table in the same change.
37
+ - **Set `strictPort` (or the framework's equivalent).** A busy port must fail loudly. A
38
+ 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.
41
+ - **Update both allow-lists when you add a browser origin**: `DEV_ORIGINS` in
42
+ `apps/api/src/index.ts` and `DEV_ORIGINS` in `packages/auth/src/auth.ts`.
43
+
44
+ ### Workspace Commands
45
+
46
+ - Filter to specific package: `pnpm --filter <package-name> <command>`
47
+ - Example: `pnpm --filter @repo/ui build`
48
+ - Use `pnpm turbo run <task> --filter <package-name>` for Turborepo tasks
49
+
50
+ ### The `clean` Script — Required in Every Workspace
51
+
52
+ `pnpm clean` at the root wipes the repo back to a fresh-clone state: it runs
53
+ `turbo run clean` across every workspace, then deletes all `node_modules` and `.turbo`
54
+ directories. Recover with `pnpm install`.
55
+
56
+ **Every app and package you create MUST declare its own `clean` script.** A workspace
57
+ without one is silently skipped by `turbo run clean` and leaves stale build output behind.
58
+
59
+ - Use **`rimraf`** (added as an exact-pinned `devDependency` of that workspace) — never
60
+ `rm -rf`, which does not exist on Windows. Pass `-g` when any argument is a glob;
61
+ without it rimraf treats arguments as literal paths.
62
+ - Delete only what the workspace **generates**: `dist`, `.astro`, `.wrangler`,
63
+ `*.tsbuildinfo`. Never delete committed source or generated-then-committed files
64
+ (e.g. Drizzle migrations).
65
+ - Do **not** delete `node_modules` or `.turbo` from a workspace-level `clean` — the root
66
+ script removes those in one pass after Turborepo has finished. Deleting `.turbo` while
67
+ Turborepo is still streaming its task log into it fails on Windows.
68
+
69
+ ```jsonc
70
+ // apps/<name>/package.json
71
+ "scripts": {
72
+ "clean": "rimraf -g dist .wrangler \"*.tsbuildinfo\""
73
+ },
74
+ "devDependencies": {
75
+ "rimraf": "6.1.3"
76
+ }
77
+ ```
78
+
79
+ ## Tech & Tools
80
+
81
+ - **pnpm** — non-auth settings live in `pnpm-workspace.yaml` (camelCase), never `.npmrc`.
82
+ Exact versions are pinned (`saveExact`).
83
+ - **TypeScript + ESM.** Internal packages are consumed JIT (no build step) via `workspace:*`.
84
+ - **Add features, don't hand-wire them.** Prefer `saasaloy add <module>` over manually
85
+ creating routes/schema/auth. Most of what a module contributes is a file dropped at a
86
+ convention-based extension point; the rest is a small reversible patch on a file that has
87
+ to name its entries statically. An api route is the second kind — `saasaloy add` edits
88
+ `apps/api/src/index.ts` to add one `.route()` link and its import, and `saasaloy remove`
89
+ takes both out again. Do not add a route by dropping a file into `apps/api/src/routes/`
90
+ and expecting it to mount: nothing globs that folder, and the entry file's `AppType` is
91
+ what the typed `hc` client reads.
92
+
93
+ ### The `@repo/ui` Design Layer
94
+
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`.
101
+
102
+ `DESIGN.md` at the repository root records the token values and the rules behind them. Read it before you add or change UI.
103
+
104
+ Primitives, blocks and containers are reached by subpath — none is re-exported from the
105
+ package root, so importing one never drags in the rest. The root export is project-wide
106
+ constants only (`siteName`):
107
+
108
+ ```ts
109
+ import { siteName } from "@repo/ui";
110
+ import { Button } from "@repo/ui/components/button";
111
+ import { PricingTable } from "@repo/ui/blocks/pricing-table";
112
+ import { CartSummary } from "@repo/ui/containers/cart-summary";
113
+ import { landing, ui } from "@repo/ui/content/landing";
114
+ import { cn } from "@repo/ui/lib/utils";
115
+ ```
116
+
117
+ **Three layers, split by state scope — not by "has state".**
118
+
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.
128
+
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.
134
+
135
+ **Adding a primitive the base doesn't vendor.** `shadcn` is already an exact-pinned
136
+ dependency of `@repo/ui`, so reach it with `--filter … exec` — that runs in the package
137
+ directory, where `components.json` lives:
138
+
139
+ ```sh
140
+ pnpm --filter @repo/ui exec shadcn add dialog
141
+ ```
142
+
143
+ - **Not `pnpm -C packages/ui dlx …`.** `-C` scopes pnpm's own workspace resolution; it
144
+ does *not* change the directory `dlx` spawns the command in. shadcn then looks for
145
+ `components.json` wherever your shell happens to be and dies at `Verifying framework`.
146
+ If you must use `dlx`, pass shadcn's own flag: `--cwd packages/ui`.
147
+ - Never `npx` (see Never Do).
148
+ - The CLI writes into `src/components/`. Anything it appends to `package.json` arrives
149
+ as a range — **re-pin it to an exact version**.
150
+ - `style` is `base-nova` (Base UI) and is fixed at init — the CLI cannot change it later.
151
+ - `rsc` is `false`, so the CLI strips the `"use client"` directive for you. It means
152
+ nothing in Astro, and the vendored primitives don't carry it.
153
+
154
+ Primitives are source you own. Edit them in place rather than wrapping them.
155
+
156
+ **Swapping the whole theme for a preset.** The token set in
157
+ `packages/ui/src/styles/globals.css` is shadcn's `neutral` with `cssVariables: true`, and
158
+ any shadcn **`registry:style`** item is a drop-in replacement for it. The same
159
+ `--filter … exec` form applies — there is no separate theme command:
160
+
161
+ ```sh
162
+ pnpm --filter @repo/ui exec shadcn add https://tweakcn.com/r/themes/modern-minimal.json
163
+ ```
164
+
165
+ The CLI merges the preset's `:root`, `.dark` and `@theme inline` blocks **into** the
166
+ existing ones, so the file's hand-written parts — the three `@source` globs, the
167
+ `@custom-variant dark`, the `@layer base` rules — stay put, and `components.json` is not
168
+ touched. It usually *extends* `@theme inline` with mappings the base does not carry
169
+ (fonts, tracking, shadows); that is expected.
170
+
171
+ Two places to get an item from:
172
+
173
+ 1. **[`https://ui.shadcn.com/create`](https://ui.shadcn.com/create)** — first-party.
174
+ Describe or dial in a theme and it hands you a URL.
175
+ 2. **[`https://tweakcn.com`](https://tweakcn.com)** — a much larger preset library,
176
+ serving items at `https://tweakcn.com/r/themes/<name>.json`.
177
+
178
+ Neither is special: the mechanism is the `registry:style` shape, so any URL that serves
179
+ one works.
180
+
181
+ **This edits a base file, and base files have no update path.** Saasaloy hands you the
182
+ template once; it never comes back to migrate it. A swapped theme is yours to maintain,
183
+ including re-applying anything you had customised in `globals.css` that the preset
184
+ overwrote. Diff the file after running the command rather than assuming.
185
+
186
+ 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.
187
+
188
+ Light/dark/system switching is unaffected by any of this — it keys off the `.dark` class,
189
+ which every preset keeps.
190
+
191
+ **Blocks — the page-level compositions.** `src/blocks/` holds the marketing blocks the
192
+ landing page is built from: `navbar`, `hero`, `feature-grid`, `pricing-table`, `faq`,
193
+ `cta`, `footer`. The rules are not stylistic — each one prevents a real failure:
194
+
195
+ - **One block, one file, one component export (plus its prop types).**
196
+ `pricing-table.tsx` exports `PricingTable` alongside `PricingTier` and
197
+ `PricingTableProps`, and nothing else — no second component, no default export, no
198
+ `blocks/index.ts` barrel. Astro gives every `client:*` component its own React root, so
199
+ a compound primitive (accordion, dialog, dropdown) split across an `.astro` file throws
200
+ "must be used within" at runtime. Keep the whole composition inside the block.
201
+ - **Props-driven, with every default read from the content module.** Every prop is still
202
+ optional and still carries a default — but the *words* come from
203
+ `packages/ui/src/content/landing.ts`, not from the block. `hero.tsx` has
204
+ `title = landing.hero.title`; `pricing-table.tsx` holds no tier data at all. Keep the
205
+ props for the cases a page really varies (a second landing page can override any of
206
+ them); change the copy by editing the content module. See below.
207
+ - **Never pass a component or a function as a prop from `.astro`.** Astro serializes
208
+ island props, so icons live *inside* the block. Swap one by editing the block.
209
+ - **Semantic kebab-case filenames** (`feature-grid.tsx`), never shadcn's registry-style
210
+ `{category}-{NN}` numbering — that disambiguates variants in a public registry, and
211
+ this project has neither.
212
+ - **Static by default.** A block with no state renders to HTML and ships zero JS. Add a
213
+ client directive only to the block that actually needs the browser, and pick the
214
+ cheapest one: `client:idle` above the fold, `client:visible` below it. A blanket
215
+ `client:load` on the page hydrates everything and throws away the reason this is a
216
+ static site — see `apps/web/src/pages/index.astro` for the worked example.
217
+ - **`theme-toggle` is the exception that proves the rule.** It sits in `src/blocks/`
218
+ beside the marketing blocks but is chrome, not copy: `index.astro` places it as a
219
+ sibling of `<Navbar />` and it takes **no** client directive despite being
220
+ interactive. It has no `onClick` and no state — the pre-paint inline script the shared
221
+ layout emits (`THEME_INIT_SCRIPT`, from `packages/ui/src/lib/theme.ts`) drives every
222
+ `[data-theme-toggle]` on the page through one delegated listener, and CSS picks the
223
+ icon off `<html data-theme>`. Move it, restyle it via its `className`, or delete the
224
+ one line in `index.astro` to drop it. Do **not** "fix" it by adding `client:*` or an
225
+ `onClick`, and do not paste a second copy of the boot script into a page: any document
226
+ that renders the block must inline that one constant in its `<head>`, or the control
227
+ stays hidden.
228
+ - **`error-state` is the only error screen, and every app renders it.** An app that adds
229
+ a route surface answers a miss with `ErrorState` — `apps/web` through
230
+ `src/pages/404.astro`, the admin SPA through the root route's `notFoundComponent` and
231
+ its error boundary, and `apps/api` through `base.notFound(...)`, which answers the same
232
+ `{ error: { code, message } }` envelope as every other failure. Do **not** write a
233
+ second error screen in an app: one block means one restyle when the theme moves, and a
234
+ bespoke copy drifts out of the token vocabulary the day nobody is looking. Change the
235
+ words in `packages/ui/src/content/errors.ts`, the markup in
236
+ `packages/ui/src/blocks/error-state.tsx`.
237
+ - **A `prerender = false` page inherits `500.astro`.** `apps/web` registers
238
+ `@astrojs/cloudflare` while staying on `output: "static"`, so `src/pages/500.astro` is
239
+ built ahead of time and carries no detail from the failed request. The day a page opts
240
+ into on-demand rendering, a throw inside it lands on that page for free — nothing to
241
+ add. Two things move on that day and only that day: `wrangler.jsonc` gains a `main`
242
+ pointing at the server entry the adapter now writes, and `dist/server` stops being
243
+ empty. Setting `main` before then breaks the build, which is why the comment in
244
+ `wrangler.jsonc` says to wait.
245
+ - **A block never reaches the network; the app injects the behaviour.** A block that needs
246
+ to send something takes a function prop (`onSubmit`) and calls it. It does not import an
247
+ api package, an http client, or `import.meta.env`. Two reasons, both concrete.
248
+ `packages/ui` has a `typecheck` script and consumes workspaces as source, so one
249
+ `import type { AppType } from "@repo/api/client"` here makes `tsc` compile the entire
250
+ Worker source tree under this package's tsconfig. And a block that hardcodes an endpoint
251
+ is a block you cannot reuse on a second page. The counterpart lives in the app: a small
252
+ React file under `apps/web/src/components/` builds the client and passes the function
253
+ down. Astro serializes island props, so the page renders **that** file, never the block
254
+ directly.
255
+ - **A block never imports a store either.** Local state is fine; shared state is not. See
256
+ the containers section below.
257
+ - **A module's UI arrives here too, and you wire it up by hand.** `saasaloy add <module>`
258
+ writes a UI-bearing module's block into `src/blocks/` exactly like the base blocks, adds
259
+ the app-side island that feeds it, and then stops: nothing globs a folder, and no command
260
+ edits `index.astro`. The command prints a pointer to the module's skill, and that skill's
261
+ **Wire-up** section carries the import line, the component to render, the client
262
+ directive, and a suggested spot on the page. Put it where you want it. `saasaloy remove`
263
+ deletes the files it wrote and leaves your import alone, so take that line out yourself.
264
+
265
+ Blocks, like primitives, are source you own — they are meant to be edited, not wrapped.
266
+
267
+ **Containers — the store bindings.** `src/containers/` holds the compositions that read
268
+ shared state. The base ships none; the folder carries a note until you add your first.
269
+
270
+ - **A container subscribes, a block renders.** The container reads the store, passes the
271
+ values down as props, and passes handlers that write back to the store. It adds no markup
272
+ a block could hold. If a container grows layout and copy, that part belongs in a block.
273
+ - **The store lives in `src/lib/`, and only a container imports it.** A block that imports
274
+ a store is a block a second app cannot reuse, because the store now decides where its data
275
+ comes from. Move the binding up.
276
+ - **Still no IO.** A container reads client state; it does not fetch, persist, or
277
+ authenticate. The app hydrates the store — the same app-side island pattern the blocks use
278
+ for `onSubmit`.
279
+ - **Same file rules as blocks:** one container, one file, one component export, semantic
280
+ kebab-case filename, no barrel. Astro gives every `client:*` component its own React root,
281
+ so a container that a page hydrates must hold the whole composition.
282
+ - **A container almost always needs a client directive.** It subscribes at runtime, so it
283
+ ships JavaScript by definition. Pick the cheapest one that works (`client:idle` above the
284
+ fold, `client:visible` below it), and keep the blocks it renders static in every page that
285
+ does not need the store.
286
+
287
+ **The content module — where the words live.** `packages/ui/src/content/landing.ts` holds
288
+ every user-visible string on the landing page, in two namespaces:
289
+
290
+ - **`landing.*`** — marketing copy. What the product is, who it is for, what it costs.
291
+ This is the whole surface a copy rewrite touches. The brand itself is not here: `siteName`
292
+ lives in `packages/ui/src/index.ts`, is not translated, and is set once by
293
+ `saasaloy-setup`.
294
+ - **`ui.*`** — chrome and accessibility labels (`Monthly`, `Most popular`, `Close menu`,
295
+ `Billing period`). Nothing here says anything about the product, so a copy pass never
296
+ rewrites it. It does get translated, key for key, when `landing.*` is written in some
297
+ language other than English — no translation layer ships in the base, so that pass is the
298
+ only one these strings get.
299
+
300
+ Blocks import it **directly** — `import { landing, ui } from "@repo/ui/content/landing"` —
301
+ never as props from `index.astro`. Astro serializes island props, so passing content into
302
+ `<PricingTable client:visible />` would write every string into the HTML payload *and*
303
+ still ship the defaults inside the island's bundle. Direct import keeps the static blocks
304
+ at zero JavaScript.
305
+
306
+ Five rules keep that file mechanically translatable — a translation layer reads a keyed
307
+ record and nothing else. Follow them when you add a block or a key:
308
+
309
+ 1. **Max three levels below a namespace** (`landing.features.title`). Compiler-based i18n
310
+ libraries emit one flat identifier per message and cannot see deeper nesting.
311
+ 2. **Position is never the key.** Lists are arrays whose items carry a stable `id`, so
312
+ reordering the feature grid cannot silently reattach the wrong translation. One list is
313
+ exempt — a tier's `features` bullets stay a plain `string[]`, because nothing reads them
314
+ individually (a feature id and an FAQ id both anchor an item that outlives its wording)
315
+ and a tier's bullets are rewritten with that tier. The content file states the trade-off
316
+ in full.
317
+ 3. **Single-brace `{token}` placeholders, never a template literal.** Copy is data; a
318
+ template literal is a function, which no extraction tool can read. Render with
319
+ `interpolate()` from `@repo/ui/lib/interpolate`.
320
+ 4. **No runtime concatenation.** `/month` plus `", billed annually"` is two whole
321
+ messages (`ui.pricing.perMonth`, `ui.pricing.perMonthAnnual`) — word order does not
322
+ survive the seam in every language.
323
+ 5. **Only user-visible strings move.** Section `id`s and the same-page anchors pointing at
324
+ them are structure and stay in the block. Three things break the rule, all because a
325
+ thing rewritten *with* the copy belongs *near* the copy: the whole `tiers` array (prices
326
+ and `ctaHref`s included); each feature's `icon`, held as a registry *name* like `"zap"`
327
+ and resolved to a component by the map at the top of `blocks/feature-grid.tsx`; and the
328
+ two outbound calls to action, `landing.navbar.ctaHref` and
329
+ `landing.cta.primaryActionHref`/`.secondaryActionHref`, which leave the page and so
330
+ cannot break a section link. A translation layer reads `id`, `icon` and every `*Href` as
331
+ non-message data.
332
+
333
+ Two consequences worth knowing. Blanking a navbar or footer link's label in content drops
334
+ that link, which is how a removed section loses its nav entry without editing a block. And
335
+ the theme toggle's labels deliberately stay in `packages/ui/src/lib/theme.ts`: that file is
336
+ inlined verbatim into a pre-paint `<script>` and is import-free on purpose.
337
+
338
+ **Making this project yours.** Three skills ship with the base (linked at
339
+ `.claude/skills/`, real files in `.agents/skills/`). The first two run in order:
340
+
341
+ 1. **`saasaloy-setup`** asks ten questions about the product — starting with its name — and
342
+ writes the answers to `docs/product-brief.md`. Every question carries sample answers you
343
+ can take, edit, or ignore. It also sets `siteName` and the page's `lang`. Nothing else
344
+ reads your product knowledge out of your head, so run it first; other skills read the
345
+ brief rather than interviewing you again.
346
+ 2. **`saasaloy-landing-copy`** turns that brief into the landing page's words. It drafts
347
+ into `docs/landing-copy-draft.md` for you to review and edit, then writes
348
+ `packages/ui/src/content/landing.ts` once you approve, then deletes the draft.
349
+
350
+ The third has no place in that order, because it runs whenever the UI moves:
351
+
352
+ 3. **`saasaloy-design`** keeps `DESIGN.md` true. Its `theme` flow swaps the preset and
353
+ re-derives the contract; `update` re-derives after you change `globals.css` or add
354
+ components; `audit` reports where the code and the contract disagree. It reads the
355
+ product brief when one exists and never writes it.
356
+
357
+ Invoke them rather than editing eight blocks by hand — and if you do edit by hand, keep the
358
+ strings in the content module so the next pass finds them.
359
+
360
+ ### Naming Conventions
361
+
362
+ - **Functions**: camelCase (`fetchUserData`, `calculateTotal`)
363
+ - **Components**: PascalCase (`UserProfile`, `DataTable`)
364
+ - **Constants**: UPPER_SNAKE_CASE (`API_BASE_URL`, `MAX_RETRIES`)
365
+ - **Types/Interfaces**: PascalCase (`User`, `ApiResponse`)
366
+ - **Files**: kebab-case for components (`user-profile.tsx`), camelCase for utilities (`utils.ts`)
367
+
368
+ ## Linting, Formatting, and Commit Hooks
369
+
370
+ The linter is **[oxlint](https://oxc.rs)** configured from
371
+ **[Ultracite](https://www.ultracite.ai)**'s presets, with **Prettier** and **Stylelint**
372
+ owning formatting. All four run from `oxlint.config.mjs`, `prettier.config.js` and
373
+ `stylelint.config.js` at the root — there is no shared config package, because oxlint
374
+ cannot consume one (`extends` takes file paths, JSON only).
375
+
376
+ - `pnpm lint` — the gate. Four passes, in order:
377
+ 1. `lint:types` — oxlint with `--type-aware` over `packages/ui/src`
378
+ 2. `lint:code` — oxlint over everything, no type information
379
+ 3. `lint:css` — Stylelint over `**/*.css`
380
+ 4. `format:check` — `prettier --check .`
381
+ - `pnpm lint:fix` — passes 1-3 with `--fix`: the type-aware oxlint invocation over
382
+ `packages/ui/src`, the plain one over everything, then Stylelint. **Never** add
383
+ `--fix-suggestions`: it rewrites `a[i++]` to `a[i += 1]`, which is a different program.
384
+ - `pnpm format` — `prettier --write .`
385
+ - `pnpm typecheck` — `tsc`, and it must pass before you commit.
386
+
387
+ **Why the type-aware pass is scoped and the plain one is not.** `--type-aware` is a
388
+ global CLI switch, so the split is by invocation rather than by config. It stays off
389
+ `.astro` because `apps/web/tsconfig.json` includes the build-generated
390
+ `.astro/types.d.ts`, which would make `astro sync` a prerequisite of every `pnpm lint`,
391
+ including on a fresh clone. Do not merge the two passes.
392
+
393
+ **Markdown is not formatted.** `.prettierignore` excludes `**/*.md` because Ultracite's
394
+ Prettier config sets `proseWrap: "never"`, which would collapse every hand-wrapped
395
+ paragraph — this file included — into a single line.
396
+
397
+ **Commit hooks are installed by husky** on your first `pnpm install` (`prepare: "husky"`),
398
+ and they need a `.git` directory, which `saasaloy init` creates for you:
399
+
400
+ - `pre-commit` runs **lint-staged** over staged files only — oxlint `--fix`, Stylelint
401
+ `--fix`, Prettier `--write`. It skips the type-aware pass on purpose: that one needs the
402
+ whole project graph, which defeats staged-file scoping.
403
+ - `commit-msg` runs **commitlint** with `@commitlint/config-conventional`, so messages
404
+ must read `type(scope): subject` — `feat:`, `fix(api):`, `chore(deps):`.
405
+
406
+ Bypass in a genuine emergency with `git commit --no-verify`, or `HUSKY=0` to skip every
407
+ hook (which is also how you keep hooks out of CI).
408
+
409
+ ## Testing Instructions
410
+
411
+ - Run type checking: `pnpm typecheck` (must pass before commits)
412
+ - Run linting: `pnpm lint` (see above — it reports, `pnpm lint:fix` fixes)
413
+ - Check formatting: `pnpm format:check`, or `pnpm format` to rewrite
414
+ - 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.
415
+
416
+ ## Boundaries
417
+
418
+ ### ✅ Always Do
419
+
420
+ - Read `DESIGN.md` before writing or changing UI
421
+ - Run `pnpm typecheck` before committing code changes
422
+ - Run `pnpm lint` and fix all errors
423
+ - Give every new app or package a `clean` script backed by `rimraf` (see above)
424
+ - Use TypeScript strict mode (no `any` without explicit reason)
425
+ - Use workspace package names (`@repo/ui`, `@repo/tsconfig`) for imports
426
+
427
+ ### ⚠️ Ask First
428
+
429
+ - Adding new dependencies (especially to root `package.json`)
430
+ - Modifying Turborepo configuration (`turbo.json`)
431
+ - Changing TypeScript strictness settings
432
+ - Modifying the husky hooks (`.husky/`), `lint-staged.config.js`, or `commitlint.config.js`
433
+ - Creating new workspace packages
434
+ - Changing `oxlint.config.mjs`, `prettier.config.js`, or `stylelint.config.js` — including
435
+ turning a rule off. Suppress the one occurrence with a comment that says why instead
436
+ - Database schema changes or migrations
437
+ - CI/CD workflow modifications (`.github/workflows/`)
438
+
439
+ ### 🚫 Never Do
440
+
441
+ - Never use `npm` or `npx`, instead use `pnpm` & `pnpm dlx`
442
+ - Never use `rm -rf` in a package script — it breaks on Windows; use `rimraf`
443
+ - Commit secrets, API keys, or environment variables
444
+ - Modify `node_modules/` or `pnpm-lock.yaml` manually (use `pnpm install`)
445
+ - Remove or disable TypeScript strict mode
446
+ - Remove or disable the lint-staged or commitlint hooks, or commit with `--no-verify`
447
+ as a habit rather than an emergency
448
+ - Use `any` type without explicit `@ts-expect-error` or `@ts-ignore` with justification
449
+ - Break the workspace structure (don't move packages outside `apps/*`, `packages/*`, or `infra`)
450
+ - Commit without running type checks and linting
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,209 @@
1
+ ---
2
+ version: alpha
3
+ name: "{{PROJECT_NAME}}"
4
+ description: "The neutral, content-first design system shipped by the Saasaloy base template."
5
+ omitted:
6
+ - section: spacing
7
+ reason: "Tailwind's default scale is used unchanged"
8
+ colors:
9
+ background: "oklch(1 0 0)"
10
+ foreground: "oklch(0.145 0 0)"
11
+ card: "oklch(1 0 0)"
12
+ card-foreground: "oklch(0.145 0 0)"
13
+ primary: "oklch(0.205 0 0)"
14
+ primary-foreground: "oklch(0.985 0 0)"
15
+ secondary: "oklch(0.97 0 0)"
16
+ secondary-foreground: "oklch(0.205 0 0)"
17
+ muted: "oklch(0.97 0 0)"
18
+ muted-foreground: "oklch(0.556 0 0)"
19
+ destructive: "oklch(0.577 0.245 27.325)"
20
+ border: "oklch(0.922 0 0)"
21
+ ring: "oklch(0.708 0 0)"
22
+ dark-background: "oklch(0.145 0 0)"
23
+ dark-foreground: "oklch(0.985 0 0)"
24
+ dark-card: "oklch(0.205 0 0)"
25
+ dark-card-foreground: "oklch(0.985 0 0)"
26
+ dark-primary: "oklch(0.922 0 0)"
27
+ dark-primary-foreground: "oklch(0.205 0 0)"
28
+ dark-secondary: "oklch(0.269 0 0)"
29
+ dark-secondary-foreground: "oklch(0.985 0 0)"
30
+ dark-muted-foreground: "oklch(0.708 0 0)"
31
+ dark-destructive: "oklch(0.704 0.191 22.216)"
32
+ typography:
33
+ headline-display:
34
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
35
+ fontSize: 3.75rem
36
+ fontWeight: 600
37
+ lineHeight: 1
38
+ letterSpacing: -0.025em
39
+ headline-lg:
40
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
41
+ fontSize: 2.25rem
42
+ fontWeight: 600
43
+ lineHeight: 2.5rem
44
+ letterSpacing: -0.025em
45
+ headline-md:
46
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
47
+ fontSize: 1.875rem
48
+ fontWeight: 600
49
+ lineHeight: 2.25rem
50
+ letterSpacing: -0.025em
51
+ body-lg:
52
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
53
+ fontSize: 1.125rem
54
+ fontWeight: 400
55
+ lineHeight: 1.75rem
56
+ body-md:
57
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
58
+ fontSize: 1rem
59
+ fontWeight: 400
60
+ lineHeight: 1.5rem
61
+ body-sm:
62
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
63
+ fontSize: 0.875rem
64
+ fontWeight: 400
65
+ lineHeight: 1.25rem
66
+ label-md:
67
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
68
+ fontSize: 0.875rem
69
+ fontWeight: 500
70
+ lineHeight: 1.25rem
71
+ label-sm:
72
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
73
+ fontSize: 0.75rem
74
+ fontWeight: 500
75
+ lineHeight: 1rem
76
+ rounded:
77
+ sm: 0.375rem
78
+ md: 0.5rem
79
+ lg: 0.625rem
80
+ xl: 0.875rem
81
+ 2xl: 1rem
82
+ 4xl: 2rem
83
+ components:
84
+ page:
85
+ backgroundColor: "{colors.background}"
86
+ textColor: "{colors.foreground}"
87
+ typography: "{typography.body-md}"
88
+ page-dark:
89
+ backgroundColor: "{colors.dark-background}"
90
+ textColor: "{colors.dark-foreground}"
91
+ hero-title:
92
+ textColor: "{colors.foreground}"
93
+ typography: "{typography.headline-display}"
94
+ section-title:
95
+ textColor: "{colors.foreground}"
96
+ typography: "{typography.headline-lg}"
97
+ legal-title:
98
+ textColor: "{colors.foreground}"
99
+ typography: "{typography.headline-md}"
100
+ body-large:
101
+ textColor: "{colors.muted-foreground}"
102
+ typography: "{typography.body-lg}"
103
+ body-small:
104
+ textColor: "{colors.muted-foreground}"
105
+ typography: "{typography.body-sm}"
106
+ button-primary:
107
+ backgroundColor: "{colors.primary}"
108
+ textColor: "{colors.primary-foreground}"
109
+ typography: "{typography.label-md}"
110
+ rounded: "{rounded.lg}"
111
+ height: 2rem
112
+ button-primary-dark:
113
+ backgroundColor: "{colors.dark-primary}"
114
+ textColor: "{colors.dark-primary-foreground}"
115
+ button-secondary:
116
+ backgroundColor: "{colors.secondary}"
117
+ textColor: "{colors.secondary-foreground}"
118
+ rounded: "{rounded.lg}"
119
+ button-secondary-dark:
120
+ backgroundColor: "{colors.dark-secondary}"
121
+ textColor: "{colors.dark-secondary-foreground}"
122
+ button-destructive:
123
+ backgroundColor: "{colors.destructive}"
124
+ textColor: "{colors.primary-foreground}"
125
+ destructive-text-dark:
126
+ textColor: "{colors.dark-destructive}"
127
+ input:
128
+ textColor: "{colors.foreground}"
129
+ typography: "{typography.body-md}"
130
+ rounded: "{rounded.lg}"
131
+ height: 2rem
132
+ card:
133
+ backgroundColor: "{colors.card}"
134
+ textColor: "{colors.card-foreground}"
135
+ typography: "{typography.body-sm}"
136
+ rounded: "{rounded.xl}"
137
+ card-dark:
138
+ backgroundColor: "{colors.dark-card}"
139
+ textColor: "{colors.dark-card-foreground}"
140
+ callout:
141
+ backgroundColor: "{colors.muted}"
142
+ textColor: "{colors.foreground}"
143
+ rounded: "{rounded.2xl}"
144
+ badge:
145
+ backgroundColor: "{colors.primary}"
146
+ textColor: "{colors.primary-foreground}"
147
+ typography: "{typography.label-sm}"
148
+ rounded: "{rounded.4xl}"
149
+ focus-ring:
150
+ backgroundColor: "{colors.ring}"
151
+ border:
152
+ backgroundColor: "{colors.border}"
153
+ muted-text-dark:
154
+ textColor: "{colors.dark-muted-foreground}"
155
+ ---
156
+
157
+ # {{PROJECT_NAME}} Design System
158
+
159
+ ## Overview
160
+
161
+ The base uses a neutral, content-first system. It gives a new product a clear structure without choosing a brand palette for the owner. Product identity enters through the theme tokens, the product brief, and the copy.
162
+
163
+ ## Colors
164
+
165
+ The light theme uses white surfaces, near-black text, and a dark neutral primary action. The dark theme reverses that relationship with near-black surfaces and near-white text. Muted neutrals separate supporting content. The destructive color is the only chromatic semantic token in the seed.
166
+
167
+ Use the semantic custom properties in `packages/ui/src/styles/globals.css`. Do not copy their current `oklch()` values into components.
168
+
169
+ ## Typography
170
+
171
+ The template uses Tailwind's system sans stack. Marketing headings use semibold weight, tight tracking, and the `text-3xl` through `text-6xl` scale. Body copy uses the `text-sm` through `text-lg` scale. Controls use medium weight at `text-xs` or `text-sm`.
172
+
173
+ ## Layout
174
+
175
+ Pages use centered maximum-width containers. Sections use responsive horizontal padding and large vertical gaps. Components use Tailwind's default spacing scale, which stays omitted from the token map because the project does not define a custom scale.
176
+
177
+ ## Elevation & Depth
178
+
179
+ Surfaces use borders, rings, and tonal contrast instead of a shadow scale. The small shadow on the input is a component detail. Do not infer a project shadow scale from it.
180
+
181
+ ## Shapes
182
+
183
+ The base radius is `0.625rem`. Controls use the `lg` radius. Cards use `xl`. Large callouts use `2xl`. Badges use `4xl` for a pill shape.
184
+
185
+ ## Components
186
+
187
+ Primary buttons use the primary pair. Secondary buttons use the secondary pair. Destructive actions use the destructive token with restrained tint states. Inputs and buttons share a `2rem` default height and the `lg` radius. Cards use the card pair and a border-strength ring.
188
+
189
+ The `error-state` block is the one screen for a failure: a wrong path, a failed render, a server error. It is a single centered card holding a muted icon, a status label in the mono face at the smallest size, a title at `body-lg`, a description in `muted-foreground`, and one or two actions on the button scale. It carries no destructive token and no alert tint, because a mistyped address is not a danger state. Every app renders this block rather than its own error markup, so a theme change reaches all three at once.
190
+
191
+ ## Do's and Don'ts
192
+
193
+ - Read this file and `packages/ui/src/styles/globals.css` before you write UI.
194
+ - Use semantic token utilities such as `bg-primary` and `text-muted-foreground`.
195
+ - Keep light and dark token pairs together when you change a semantic role.
196
+ - Keep new pages within the established type scale and radius vocabulary.
197
+ - Do not add a color, radius, shadow, or type size that the code does not define.
198
+ - Do not use a shadow when a border or tonal surface provides the needed separation.
199
+ - Keep UI copy direct and specific. Use the product brief for product language.
200
+
201
+ ## Motion
202
+
203
+ The template uses short Tailwind transitions for color and state changes. Accordion motion uses the animation supplied by the vendored primitive. Motion stays in prose because the DESIGN.md alpha schema has no motion token group.
204
+
205
+ ## Dark Mode
206
+
207
+ The `.dark` class selects the dark token set. The pre-paint theme script applies the class before rendering and records the resolved mode in `data-theme`. Components must use semantic tokens so both themes stay aligned.
208
+
209
+ _Seeded from the saasaloy base template · CLI {{CLI_VERSION}} · tokens sha256:101fd7fd684f of packages/ui/src/styles/globals.css_