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.
- package/README.md +7 -7
- package/dist/index.js +637 -522
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schemas/registry-item.schema.json +31 -1
- package/templates/base/AGENTS.md +204 -89
- package/templates/base/README.md +20 -8
- package/templates/base/_agents/skills/saasaloy-design/SKILL.md +38 -17
- package/templates/base/_agents/skills/saasaloy-env/SKILL.md +130 -0
- package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +63 -46
- package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +1 -1
- package/templates/base/_gitignore +0 -3
- package/templates/base/_saasaloy-base.json +8 -1
- package/templates/base/{DESIGN.md → apps/web/DESIGN.md} +36 -12
- package/templates/base/apps/web/astro.config.mjs +36 -6
- package/templates/base/apps/web/package.json +2 -1
- package/templates/base/apps/web/src/env.ts +41 -0
- package/templates/base/apps/web/src/layouts/Layout.astro +39 -11
- package/templates/base/apps/web/src/pages/index.astro +33 -24
- package/templates/base/apps/web/src/pages/privacy.astro +10 -2
- package/templates/base/apps/web/src/pages/terms.astro +10 -2
- package/templates/base/oxlint.config.mjs +125 -4
- package/templates/base/package.json +7 -3
- package/templates/base/packages/env/_env.example +54 -0
- package/templates/base/packages/env/package.json +35 -0
- package/templates/base/packages/env/scripts/env-setup.ts +87 -0
- package/templates/base/packages/env/src/check.test.ts +41 -0
- package/templates/base/packages/env/src/check.ts +50 -0
- package/templates/base/packages/env/src/define.test.ts +206 -0
- package/templates/base/packages/env/src/define.ts +315 -0
- package/templates/base/packages/env/src/deploy.test.ts +438 -0
- package/templates/base/packages/env/src/deploy.ts +518 -0
- package/templates/base/packages/env/src/dotenv.test.ts +179 -0
- package/templates/base/packages/env/src/dotenv.ts +173 -0
- package/templates/base/packages/env/src/env-file.ts +51 -0
- package/templates/base/packages/env/src/error.ts +38 -0
- package/templates/base/packages/env/src/example.test.ts +86 -0
- package/templates/base/packages/env/src/example.ts +90 -0
- package/templates/base/packages/env/src/index.ts +15 -0
- package/templates/base/packages/env/src/schema.ts +63 -0
- package/templates/base/packages/env/src/services.test.ts +83 -0
- package/templates/base/packages/env/src/services.ts +110 -0
- package/templates/base/packages/env/src/setup.test.ts +254 -0
- package/templates/base/packages/env/src/setup.ts +177 -0
- package/templates/base/packages/env/src/source.ts +20 -0
- package/templates/base/packages/env/src/sources.test.ts +137 -0
- package/templates/base/packages/env/src/sources.ts +93 -0
- package/templates/base/packages/env/src/zod.ts +8 -0
- package/templates/base/packages/env/tsconfig.json +13 -0
- package/templates/base/packages/tsconfig/base.json +7 -0
- package/templates/base/packages/ui/components.json +1 -1
- package/templates/base/packages/ui/package.json +4 -2
- package/templates/base/packages/ui/src/blocks/audience.tsx +73 -0
- package/templates/base/packages/ui/src/blocks/cta.tsx +41 -30
- package/templates/base/packages/ui/src/blocks/error-state.tsx +2 -0
- package/templates/base/packages/ui/src/blocks/faq.tsx +64 -36
- package/templates/base/packages/ui/src/blocks/feature-grid.tsx +48 -146
- package/templates/base/packages/ui/src/blocks/footer.tsx +129 -70
- package/templates/base/packages/ui/src/blocks/hero.tsx +183 -26
- package/templates/base/packages/ui/src/blocks/how-it-works.tsx +75 -0
- package/templates/base/packages/ui/src/blocks/logo-cloud.tsx +118 -0
- package/templates/base/packages/ui/src/blocks/navbar.tsx +204 -88
- package/templates/base/packages/ui/src/blocks/pain-points.tsx +131 -0
- package/templates/base/packages/ui/src/blocks/pricing-table.tsx +112 -94
- package/templates/base/packages/ui/src/blocks/showcase.tsx +185 -0
- package/templates/base/packages/ui/src/blocks/testimonials.tsx +85 -0
- package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +16 -12
- package/templates/base/packages/ui/src/components/badge.tsx +1 -1
- package/templates/base/packages/ui/src/components/button.tsx +2 -2
- package/templates/base/packages/ui/src/components/tabs.tsx +1 -1
- package/templates/base/packages/ui/src/containers/README.md +6 -5
- package/templates/base/packages/ui/src/content/landing.ts +590 -125
- package/templates/base/packages/ui/src/index.ts +6 -2
- package/templates/base/packages/ui/src/lib/external-link.ts +36 -0
- package/templates/base/packages/ui/src/lib/icons.ts +131 -0
- package/templates/base/packages/ui/src/lib/inline-code.ts +29 -0
- package/templates/base/packages/ui/src/lib/theme.ts +140 -56
- package/templates/base/packages/ui/src/styles/globals.css +47 -125
- package/templates/base/packages/ui/src/styles/web.css +359 -0
- package/templates/base/pnpm-workspace.yaml +0 -6
- package/templates/base/saasaloy.json +1 -0
- package/templates/base/stylelint.config.js +3 -3
- package/templates/base/turbo.json +13 -0
- package/templates/base/vitest.config.ts +21 -0
package/package.json
CHANGED
|
@@ -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 `
|
|
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
|
},
|
package/templates/base/AGENTS.md
CHANGED
|
@@ -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
|
|
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`
|
|
40
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
`src
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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/
|
|
120
|
-
2. **`src/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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/
|
|
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
|
|
174
|
-
`@
|
|
175
|
-
touched.
|
|
176
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
overwrote. Diff the file after running the
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
which every preset keeps.
|
|
197
|
-
|
|
198
|
-
**
|
|
199
|
-
|
|
200
|
-
`
|
|
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: `
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 `
|
|
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
|
|
263
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
- **A container
|
|
278
|
-
|
|
279
|
-
|
|
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.
|
|
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);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
360
|
-
re-derives the contract; `update` re-derives after you change `
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
571
|
+
- Break the workspace structure (don't move packages outside `apps/*` or `packages/*`)
|
|
457
572
|
- Commit without running type checks and linting
|
package/templates/base/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|