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.
- package/dist/index.js +8455 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/schemas/manifest.schema.json +107 -0
- package/schemas/registry-item.schema.json +336 -0
- package/schemas/saasaloy-lock.schema.json +86 -0
- package/schemas/saasaloy.schema.json +42 -0
- package/templates/base/AGENTS.md +450 -0
- package/templates/base/CLAUDE.md +1 -0
- package/templates/base/DESIGN.md +209 -0
- package/templates/base/README.md +71 -0
- package/templates/base/_agents/skills/saasaloy-design/SKILL.md +198 -0
- package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +395 -0
- package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +273 -0
- package/templates/base/_gitignore +32 -0
- package/templates/base/_husky/commit-msg +1 -0
- package/templates/base/_husky/pre-commit +1 -0
- package/templates/base/_prettierignore +31 -0
- package/templates/base/_saasaloy-base.json +9 -0
- package/templates/base/apps/web/astro.config.mjs +61 -0
- package/templates/base/apps/web/package.json +30 -0
- package/templates/base/apps/web/public/favicon.svg +4 -0
- package/templates/base/apps/web/src/layouts/Layout.astro +52 -0
- package/templates/base/apps/web/src/pages/404.astro +24 -0
- package/templates/base/apps/web/src/pages/500.astro +33 -0
- package/templates/base/apps/web/src/pages/index.astro +61 -0
- package/templates/base/apps/web/src/pages/privacy.astro +15 -0
- package/templates/base/apps/web/src/pages/terms.astro +14 -0
- package/templates/base/apps/web/tsconfig.json +11 -0
- package/templates/base/apps/web/wrangler.jsonc +27 -0
- package/templates/base/commitlint.config.js +9 -0
- package/templates/base/lint-staged.config.js +17 -0
- package/templates/base/oxlint.config.mjs +155 -0
- package/templates/base/package.json +44 -0
- package/templates/base/packages/tsconfig/base.json +17 -0
- package/templates/base/packages/tsconfig/package.json +18 -0
- package/templates/base/packages/ui/components.json +19 -0
- package/templates/base/packages/ui/package.json +39 -0
- package/templates/base/packages/ui/src/blocks/cta.tsx +82 -0
- package/templates/base/packages/ui/src/blocks/error-state.tsx +144 -0
- package/templates/base/packages/ui/src/blocks/faq.tsx +64 -0
- package/templates/base/packages/ui/src/blocks/feature-grid.tsx +185 -0
- package/templates/base/packages/ui/src/blocks/footer.tsx +99 -0
- package/templates/base/packages/ui/src/blocks/hero.tsx +84 -0
- package/templates/base/packages/ui/src/blocks/navbar.tsx +159 -0
- package/templates/base/packages/ui/src/blocks/pricing-table.tsx +175 -0
- package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +51 -0
- package/templates/base/packages/ui/src/components/accordion.tsx +78 -0
- package/templates/base/packages/ui/src/components/badge.tsx +53 -0
- package/templates/base/packages/ui/src/components/button.tsx +59 -0
- package/templates/base/packages/ui/src/components/card.tsx +103 -0
- package/templates/base/packages/ui/src/components/input.tsx +20 -0
- package/templates/base/packages/ui/src/components/label.tsx +18 -0
- package/templates/base/packages/ui/src/components/separator.tsx +23 -0
- package/templates/base/packages/ui/src/containers/README.md +11 -0
- package/templates/base/packages/ui/src/content/errors.ts +58 -0
- package/templates/base/packages/ui/src/content/landing.ts +304 -0
- package/templates/base/packages/ui/src/index.ts +8 -0
- package/templates/base/packages/ui/src/lib/interpolate.ts +31 -0
- package/templates/base/packages/ui/src/lib/sentinel.ts +11 -0
- package/templates/base/packages/ui/src/lib/theme.ts +167 -0
- package/templates/base/packages/ui/src/lib/utils.ts +11 -0
- package/templates/base/packages/ui/src/styles/globals.css +164 -0
- package/templates/base/packages/ui/tsconfig.json +7 -0
- package/templates/base/pnpm-workspace.yaml +23 -0
- package/templates/base/prettier.config.js +10 -0
- package/templates/base/saasaloy.json +8 -0
- package/templates/base/stylelint.config.js +46 -0
- 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_
|