saasaloy 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +7 -7
  2. package/dist/index.js +637 -522
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/schemas/registry-item.schema.json +31 -1
  6. package/templates/base/AGENTS.md +180 -53
  7. package/templates/base/README.md +20 -8
  8. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +26 -15
  9. package/templates/base/_agents/skills/saasaloy-env/SKILL.md +130 -0
  10. package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +63 -46
  11. package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +1 -1
  12. package/templates/base/_gitignore +0 -3
  13. package/templates/base/_saasaloy-base.json +8 -1
  14. package/templates/base/{DESIGN.md → apps/web/DESIGN.md} +36 -12
  15. package/templates/base/apps/web/astro.config.mjs +36 -6
  16. package/templates/base/apps/web/package.json +2 -1
  17. package/templates/base/apps/web/src/env.ts +41 -0
  18. package/templates/base/apps/web/src/layouts/Layout.astro +39 -11
  19. package/templates/base/apps/web/src/pages/index.astro +33 -24
  20. package/templates/base/apps/web/src/pages/privacy.astro +10 -2
  21. package/templates/base/apps/web/src/pages/terms.astro +10 -2
  22. package/templates/base/oxlint.config.mjs +91 -4
  23. package/templates/base/package.json +7 -3
  24. package/templates/base/packages/env/_env.example +54 -0
  25. package/templates/base/packages/env/package.json +35 -0
  26. package/templates/base/packages/env/scripts/env-setup.ts +87 -0
  27. package/templates/base/packages/env/src/check.test.ts +41 -0
  28. package/templates/base/packages/env/src/check.ts +50 -0
  29. package/templates/base/packages/env/src/define.test.ts +206 -0
  30. package/templates/base/packages/env/src/define.ts +315 -0
  31. package/templates/base/packages/env/src/deploy.test.ts +438 -0
  32. package/templates/base/packages/env/src/deploy.ts +518 -0
  33. package/templates/base/packages/env/src/dotenv.test.ts +179 -0
  34. package/templates/base/packages/env/src/dotenv.ts +173 -0
  35. package/templates/base/packages/env/src/env-file.ts +51 -0
  36. package/templates/base/packages/env/src/error.ts +38 -0
  37. package/templates/base/packages/env/src/example.test.ts +86 -0
  38. package/templates/base/packages/env/src/example.ts +90 -0
  39. package/templates/base/packages/env/src/index.ts +15 -0
  40. package/templates/base/packages/env/src/schema.ts +63 -0
  41. package/templates/base/packages/env/src/services.test.ts +83 -0
  42. package/templates/base/packages/env/src/services.ts +110 -0
  43. package/templates/base/packages/env/src/setup.test.ts +254 -0
  44. package/templates/base/packages/env/src/setup.ts +177 -0
  45. package/templates/base/packages/env/src/source.ts +20 -0
  46. package/templates/base/packages/env/src/sources.test.ts +137 -0
  47. package/templates/base/packages/env/src/sources.ts +93 -0
  48. package/templates/base/packages/env/src/zod.ts +8 -0
  49. package/templates/base/packages/env/tsconfig.json +13 -0
  50. package/templates/base/packages/tsconfig/base.json +7 -0
  51. package/templates/base/packages/ui/components.json +1 -1
  52. package/templates/base/packages/ui/package.json +1 -0
  53. package/templates/base/packages/ui/src/blocks/audience.tsx +73 -0
  54. package/templates/base/packages/ui/src/blocks/cta.tsx +41 -30
  55. package/templates/base/packages/ui/src/blocks/error-state.tsx +2 -0
  56. package/templates/base/packages/ui/src/blocks/faq.tsx +64 -36
  57. package/templates/base/packages/ui/src/blocks/feature-grid.tsx +48 -146
  58. package/templates/base/packages/ui/src/blocks/footer.tsx +129 -70
  59. package/templates/base/packages/ui/src/blocks/hero.tsx +183 -26
  60. package/templates/base/packages/ui/src/blocks/how-it-works.tsx +75 -0
  61. package/templates/base/packages/ui/src/blocks/logo-cloud.tsx +118 -0
  62. package/templates/base/packages/ui/src/blocks/navbar.tsx +204 -88
  63. package/templates/base/packages/ui/src/blocks/pain-points.tsx +131 -0
  64. package/templates/base/packages/ui/src/blocks/pricing-table.tsx +112 -94
  65. package/templates/base/packages/ui/src/blocks/showcase.tsx +185 -0
  66. package/templates/base/packages/ui/src/blocks/testimonials.tsx +85 -0
  67. package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +16 -12
  68. package/templates/base/packages/ui/src/components/badge.tsx +1 -1
  69. package/templates/base/packages/ui/src/components/button.tsx +2 -2
  70. package/templates/base/packages/ui/src/components/tabs.tsx +1 -1
  71. package/templates/base/packages/ui/src/content/landing.ts +590 -125
  72. package/templates/base/packages/ui/src/index.ts +6 -2
  73. package/templates/base/packages/ui/src/lib/external-link.ts +36 -0
  74. package/templates/base/packages/ui/src/lib/icons.ts +131 -0
  75. package/templates/base/packages/ui/src/lib/inline-code.ts +29 -0
  76. package/templates/base/packages/ui/src/lib/theme.ts +140 -56
  77. package/templates/base/packages/ui/src/styles/globals.css +47 -125
  78. package/templates/base/packages/ui/src/styles/web.css +359 -0
  79. package/templates/base/pnpm-workspace.yaml +0 -6
  80. package/templates/base/saasaloy.json +1 -0
  81. package/templates/base/stylelint.config.js +3 -3
  82. package/templates/base/turbo.json +13 -0
  83. package/templates/base/vitest.config.ts +21 -0
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: saasaloy-env
3
+ description: Runbook for the env capability in packages/env — the one tracked key list, the pnpm env:setup distribution command, the pnpm deploy:production deploy with env-deploy, and the typed createEnv gate each service reads its values through. Use when deploying to Cloudflare, when a deploy stops naming a blank production key, adding an environment key, when a build or a request fails naming an unset key, when choosing which services read a key, when a value must stay blank, when a key is checkout-owned, when picking a value source, or when a leftover .dev.vars is hiding .env from wrangler.
4
+ ---
5
+
6
+ # env — one key list, one command, one typed gate
7
+
8
+ `packages/env` (`@repo/env`) owns both halves of this project's environment story.
9
+
10
+ The **node half** is a tracked key list (`packages/env/.env.example`), a gitignored value file (`packages/env/.env`), and `pnpm env:setup`, which writes every service's own `.env` from the two. The **runtime half** is `createEnv`, which validates the values a service actually got and hands back typed ones.
11
+
12
+ A *service* is a workspace that reads a `.env`: `api`, `web`, `admin`. The name is the workspace directory. `packages/env/src/services.ts` holds the table, and `saasaloy add api` appends a row to it. One row has no workspace and no file: `deploy` holds `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, which a deploy loads in memory.
13
+
14
+ ## Adding a key, in two steps
15
+
16
+ **1. Add its line to `packages/env/.env.example`**, under the `# @services` section naming every service that reads it. Write a comment above it saying where the value comes from and what unset looks like. Put the local development default on the line.
17
+
18
+ ```
19
+ # @services api
20
+
21
+ # Sender address every outbound message uses. The domain must be one the selected
22
+ # provider may send from. Unset, the first send throws naming this key.
23
+ EMAIL_FROM=dev@localhost
24
+ ```
25
+
26
+ A key with no `# @services` line above it fails `pnpm env:setup`. That is deliberate: a key that reaches no service is a key the Worker throws on, and failing here is the cheaper place to find out.
27
+
28
+ **2. Declare it in the preset** of the package that reads it, so `createEnv` validates it and the type reaches the calling code.
29
+
30
+ ```ts
31
+ // packages/email/src/env-preset.ts
32
+ export const emailPreset = definePreset({
33
+ module: "@repo/email",
34
+ extends: [],
35
+ server: {
36
+ EMAIL_FROM: z.string().min(1).describe("Sender address every message uses."),
37
+ },
38
+ });
39
+ ```
40
+
41
+ The `.describe()` text is what the aggregate error prints, so write it for the person reading a failed deploy.
42
+
43
+ ## Reading a key
44
+
45
+ On the server, pass the Worker's `env`:
46
+
47
+ ```ts
48
+ import { apiEnv } from "@api/env";
49
+
50
+ app.get("/x", (c) => {
51
+ const { EMAIL_FROM } = apiEnv(c.env);
52
+ });
53
+ ```
54
+
55
+ In a frontend, call it with nothing — the values are inlined at build time:
56
+
57
+ ```ts
58
+ import { webEnv } from "@web/env";
59
+
60
+ const base = webEnv().PUBLIC_API_URL;
61
+ ```
62
+
63
+ `createEnv` validates on the **first call per `env` object** and memoizes the result in a `WeakMap`. A Worker cannot see its `env` at module scope, so the first request is the earliest honest point to check. One throw then names every failing key at once, with the module that declared it and that module's own wording, so a deploy with three unset keys takes one round trip to fix.
64
+
65
+ ## Server, client, shared
66
+
67
+ - `server` — read only where `isServer` is true. A Worker, a build script.
68
+ - `client` — inlined into a public bundle. **Must** start with `PUBLIC_`, and the type system says so: a mis-prefixed key does not compile.
69
+ - `shared` — read on both sides.
70
+
71
+ `apps/web/src/env.ts` and `apps/admin/src/env.ts` set `isServer: false`, which arms a Proxy: reading a server key from client code throws by name. `typeof window === "undefined"` is not used, because it is true in a Worker *and* in a browser bundle before hydration.
72
+
73
+ The `runtimeEnvStrict` map in those files is hand-written, one literal `import.meta.env.PUBLIC_X` per key. Vite inlines a literal member expression and nothing else, so a spread, a loop or a computed key compiles to `undefined` in the built bundle and fails only in production. **Add the line by hand when you add a client key.**
74
+
75
+ ## Blank on purpose
76
+
77
+ `pnpm env:setup` fails on a key the list carries with no value. When a key is meant to stay empty, say so in its comment block:
78
+
79
+ ```
80
+ # Blank on purpose: unset means no proxy origin, and a link is root-relative.
81
+ STORAGE_PROXY_URL=
82
+ ```
83
+
84
+ ## Checkout-owned keys
85
+
86
+ `CHECKOUT_OWNED_KEYS` in `packages/env/src/services.ts` lists keys this checkout sets for itself. A key named there keeps whatever value is already on disk, whoever wrote it, so a per-branch `DATABASE_URL` survives every `pnpm env:setup`. `packages/env` never learns what a database is — only that some key is the checkout's to set.
87
+
88
+ ## Value sources
89
+
90
+ `pnpm env:setup` reads its values from a source, selected by `ENV_SOURCE`:
91
+
92
+ - **`local`** (the default, always installed) reads the gitignored `packages/env/.env`. `saasaloy env` prompts for each unset key and writes that file, so a project develops with no vendor account and no network.
93
+ - **`infisical`** arrives with `saasaloy add env-infisical`.
94
+
95
+ A source that does not answer at all — no CLI, no network — is treated as offline: `env:setup` keeps every service file that is already complete and warns, and fails writing nothing when one is incomplete. A refusal the source *answered* with, such as no access, always fails.
96
+
97
+ ## Production and `--env prod`
98
+
99
+ Each service row declares `dev` or `prod`. `pnpm env:setup --env prod` refuses to act without `--only <service>`, and refuses outright to write production values into a workspace under `apps/`. Two independent guards on the one command that can put a live credential on disk.
100
+
101
+ `saasaloy env` prints the `wrangler secret put` lines for a deploy. It never runs them, and `pnpm deploy:production` makes them unnecessary.
102
+
103
+ ## Deploying: `pnpm deploy:production` and `env-deploy`
104
+
105
+ `pnpm deploy:production` deploys every Worker in the project with wrangler. It runs in two steps:
106
+
107
+ 1. `node packages/env/src/deploy.ts --check` loads every service's `prod` values from the selected source and exits non-zero, naming each blank key, when one is missing. It also stops when `CLOUDFLARE_API_TOKEN` is blank and wrangler holds no `wrangler login` session. Nothing builds before this passes.
108
+ 2. `turbo run deploy` runs each workspace's `deploy` script, which is `env-deploy <service>`. A workspace whose package depends on `@repo/db` waits for `db:migrate:prod` first.
109
+
110
+ `env-deploy <service>` loads that service's `prod` values and the `deploy` credentials, runs `pnpm run build` with them in the environment, and runs `wrangler deploy --secrets-file <file>`. Code and secrets land in one Worker version, so a Worker that throws at module scope on a missing secret deploys cleanly the first time. The secrets file is JSON, mode `0600`, under the OS temp directory, and deleted when the deploy ends, passed or failed. It holds the service's values minus every `CLOUDFLARE_*` key, minus each key the Worker's `wrangler.jsonc` already sets in `vars`, minus empty values. An assets-only Worker, one with no `main`, gets no secrets file.
111
+
112
+ **Where the production values come from.** The `local` source reads the gitignored `packages/env/.env.prod`, never `packages/env/.env`, so a deploy cannot ship a `localhost` URL by accident. Copy the keys from `packages/env/.env.example` into it and set each one. `env-infisical` reads its `prod` environment. A variable the shell already exports wins over both, which is how CI passes values. The base `turbo.json` sets `passThroughEnv` on `deploy`, because turbo's strict env mode would drop the exported variables otherwise.
113
+
114
+ **Why the dev `.env` stays out of the build.** Vite gives the environment priority over a `.env` file, but the Cloudflare adapters prerender inside workerd, and wrangler hands workerd the `.env` beside the workspace as its vars. So `env-deploy` builds with `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false`, and it sets a key the source leaves out to an empty string rather than leaving it unset. `apps/web/.env` keeps its dev values, and nothing writes a production value under `apps/`.
115
+
116
+ Three more forms:
117
+
118
+ - `pnpm --filter @repo/api run deploy` deploys one Worker. Pass wrangler flags after `--`: `pnpm --filter @repo/web run deploy -- --dry-run` builds with the production values and uploads nothing.
119
+ - `env-deploy --run <service> -- <command> [args...]` runs one command with that service's `prod` values and the credentials. `database-d1`'s `db:migrate:prod` uses it.
120
+ - `env-deploy --check` is step 1 on its own.
121
+
122
+ Two things the flow does not do. `--secrets-file` only adds: a secret you removed from the value source stays on the Worker until you run `wrangler secret delete <KEY>` in its workspace. And a key marked `# Blank on purpose` passes the check even where production needs it, such as `BETTER_AUTH_SECRET`; the Worker's own `createEnv` gate is what refuses it.
123
+
124
+ ## `.dev.vars` is gone
125
+
126
+ This project writes `.env` and nothing else. A leftover `.dev.vars` **hides** `.env` from wrangler — wrangler loads `.env` only while no `.dev.vars` sits beside it, and it does not warn — so `saasaloy doctor` reports one and `pnpm env:setup` carries its values across and deletes it.
127
+
128
+ ## `process.env`
129
+
130
+ No file a Worker imports reads it. The Worker's `env` is the only source, and it arrives through `createEnv`. Node tooling — `scripts/`, `env-deploy`, a value source — reads it freely.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: saasaloy-landing-copy
3
- description: Write the scaffolded landing page's copy from docs/product-brief.md — as a markdown draft the owner reviews first, then into the content module. Use when the landing page still says "Acme" or "The SaaS you meant to build", when the owner asks to write, rewrite, improve or update the landing copy, headline, features, pricing or FAQ, and when re-running after the brief changed. Needs docs/product-brief.md; run saasaloy-setup first if there isn't one.
3
+ description: Write the scaffolded landing page's copy from docs/product-brief.md — as a markdown draft the owner reviews first, then into the content module. Use when the landing page still describes Saasaloy ("Ship the SaaS, not the scaffolding"), when the owner asks to write, rewrite, improve or update the landing copy, headline, features, pricing or FAQ, and when re-running after the brief changed. Needs docs/product-brief.md; run saasaloy-setup first if there isn't one.
4
4
  ---
5
5
 
6
6
  # saasaloy-landing-copy — draft it in markdown, then write the page
@@ -41,12 +41,12 @@ says what to do when they disagree with the brief.
41
41
  an owner ends up answering the same thing twice and getting two different pages.
42
42
  2. **Read `packages/ui/src/content/landing.ts`.** You need the current copy to diff against,
43
43
  and its comments carry the shape rules you have to keep.
44
- 3. **Read the icon registry** at the top of `packages/ui/src/blocks/feature-grid.tsx`.
44
+ 3. **Read the icon registry** in `packages/ui/src/lib/icons.ts`.
45
45
  Reading a block is fine; editing one is not. Its keys are the whole vocabulary available
46
46
  to `landing.features.items[].icon`, and they are the actual list rather than one copied
47
47
  into this file that went stale.
48
48
  4. **Check the two facts you do not own.** If `siteName` in `packages/ui/src/index.ts` is
49
- still the scaffold's directory slug, or `lang` in `apps/web/src/layouts/Layout.astro` is
49
+ still the shipped `"Saasaloy"` or a directory slug, or `lang` in `apps/web/src/layouts/Layout.astro` is
50
50
  `en` while the brief names another language, say so now and carry both into the draft's
51
51
  **Not mine to fix** list. Writing Bangla copy into a page that declares itself English is
52
52
  a real defect, and one attribute long. Do not fix it yourself.
@@ -113,29 +113,29 @@ This file is deleted once the copy lands in `packages/ui/src/content/landing.ts`
113
113
  ## Hero
114
114
  | Key | Now | Proposed |
115
115
  |-----|-----|----------|
116
- | `hero.eyebrow` | Now in early access | In beta with two firms |
116
+ | `hero.announcement` | See what's new | In beta with two firms |
117
117
  | `hero.primaryActionLabel` | Get started | Join the beta |
118
118
 
119
119
  `hero.title`:
120
- > ~~The SaaS you meant to build, already scaffolded.~~
120
+ > ~~Ship the SaaS, not the scaffolding.~~
121
121
  > **Close the month without four spreadsheets per client.**
122
122
 
123
123
  *From the brief: the problem line, and "Excel plus a shared Dropbox folder" as the
124
- alternative. The eyebrow uses the two named beta firms rather than a stage name.*
124
+ alternative. The announcement uses the two named beta firms rather than a stage name.*
125
125
 
126
126
  ## Features
127
127
  | # | Icon | Title | About |
128
128
  |---|------|-------|-------|
129
- | 1 | `list-checks` (was `zap`) | One close, one checklist | … |
129
+ | 1 | `list-checks` (was `terminal`) | One close, one checklist | … |
130
130
 
131
131
  ## Where the buttons go
132
132
  | Key | Now | Proposed |
133
133
  |-----|-----|----------|
134
- | `navbar.ctaHref` | `#cta` | https://ledgerly.com.bd/waitlist |
135
- | `cta.primaryActionHref` | `/` | https://ledgerly.com.bd/waitlist |
134
+ | `navbar.ctaHref` | Saasaloy's getting started guide | https://ledgerly.com.bd/waitlist |
135
+ | `cta.primaryActionHref` | Saasaloy's getting started guide | https://ledgerly.com.bd/waitlist |
136
136
 
137
137
  ## Least sure about
138
- - The eyebrow names your two beta firms. Fine to say publicly?
138
+ - The announcement names your two beta firms. Fine to say publicly?
139
139
  - FAQ 4 says data exports as CSV. The brief doesn't mention exports; I inferred it.
140
140
 
141
141
  ## Not mine to fix
@@ -170,57 +170,70 @@ than doubling it.
170
170
 
171
171
  | Key | What goes there |
172
172
  |-----|-----------------|
173
- | `meta.title` | Browser tab. `{siteName} — <what it does>`, under 60 characters. |
173
+ | `meta.title` | Browser tab and link preview. `{siteName}: <what it does>`, under 60 characters. |
174
174
  | `meta.description` | Search result and link preview. One sentence, under 155 characters. |
175
- | `navbar.linkFeatures` `.linkPricing` `.linkFaq` | Nav labels. One or two words. They point at the sections of the same name — keep the meaning, or the anchor lies. |
175
+ | `navbar.linkFeatures` `.linkHowItWorks` `.linkShowcase` `.linkPricing` `.linkFaq` | Nav labels. One or two words. They point at the sections of the same name — keep the meaning, or the anchor lies. An empty label hides the link. |
176
+ | `navbar.secondaryLabel` `.secondaryHref` | The outline button beside the main one, usually docs, source or sign-in. Empty `secondaryHref` hides it. See [Destinations](#destinations). |
176
177
  | `navbar.ctaLabel` `.ctaHref` | Two or three words, a verb first, and where it goes. See [Destinations](#destinations). |
177
- | `hero.eyebrow` | The one-line status above the headline ("Now in early access"). Empty string hides it. |
178
+ | `hero.announcement` `.announcementHref` | The pill above the headline ("Now in early access") and where it links, usually a changelog or launch post. Empty `announcement` hides it. |
178
179
  | `hero.title` | The headline. Under ten words, one idea, concrete. This is the sentence the whole page is judged on. |
179
180
  | `hero.description` | One or two sentences saying what it does for whom. |
180
- | `hero.primaryActionLabel` `.secondaryActionLabel` | Button labels, a verb first. Both scroll down the page; their hrefs are anchors and stay in the block. |
181
+ | `hero.primaryActionLabel` `.primaryActionHref` `.secondaryActionLabel` `.secondaryActionHref` | Button labels, a verb first, and where each goes. See [Destinations](#destinations). |
182
+ | `hero.terminalLabel` `.terminalTitle` `.terminalLines[]` `.treeTitle` `.tree[]` | The hero's product visual: a terminal and a file tree. For a product that is not a developer tool, keep the shipped lines and tell the owner in the draft to replace the visual with a screenshot (the block's header comment shows how). Command lines and paths are code, not prose. |
183
+ | `logos.label` `.items[]` | One line beside the logo row, and `{ id, name, mark }` per logo. `name` is the accessible label; `mark` names an SVG the owner adds to `logo-cloud.tsx`, and an empty `mark` renders `name` as text. Never invent a customer: no logos in the brief means offer to drop the block. |
184
+ | `painPoints.title` `.description` `.intro` `.items[]` | The problem the product solves. Four items by default: `{ id, icon, title, description }`, each a pain the brief's customer actually has. See [Icons](#icons). |
185
+ | `howItWorks.title` `.description` `.steps[]` | Three ordered steps: `{ id, title, description }`. Only for a real sequence a customer follows. |
181
186
  | `features.title` `.description` | The section's heading and one supporting line. |
182
187
  | `features.items[]` | Six by default: `{ id, icon, title, description }`. Title 2–4 words; description one sentence about what the owner can *do*, not about the technology. See [Icons](#icons). |
188
+ | `showcase.title` `.description` `.rows[]` | Alternating rows: `{ id, title, description, points[], visual, imageSrc, imageAlt }`. With real screenshots in `apps/web/public`, set `imageSrc` and a real `imageAlt`; otherwise keep `visual` and its lists, or offer to drop the block. |
189
+ | `audience.title` `.description` `.items[]` | Who it is for. Four items: `{ id, icon, title, description }`, from the brief's customer section. |
190
+ | `testimonials.title` `.description` `.items[]` | `{ id, quote, name, role }`. **Real quotes only**, with the person's permission. With none in the brief, keep the shipped placeholders and offer to drop the block. Never write a quote. |
183
191
  | `pricing.title` `.description` | Heading plus one line. |
184
192
  | `pricing.annualNote` `.currencySymbol` | See [Pricing](#pricing). |
185
- | `pricing.tiers[]` | `{ id, name, description, monthlyPrice, annualPrice, features, ctaLabel, ctaHref }`. Prices are whole units; `null` renders "Custom". Set `featured` on at most one. A tier's `ctaHref` stays `#cta` unless the brief gives that tier its own destination. |
193
+ | `pricing.tiers[]` | `{ id, name, description, monthlyPrice, annualPrice, priceSuffix, features, ctaLabel, ctaHref }`, plus `featured` and `badge` on at most one. Prices are whole units; `null` renders "Custom". An empty `priceSuffix` shows "/month". |
186
194
  | `faq.items[]` | Five by default: `{ id, question, answer }`. Write the questions a buyer asks before paying — pricing, migration, lock-in, data, what happens when they outgrow it. One to three sentences, and answer the question. |
195
+ | `faq.moreLabel` `.moreLinkLabel` `.moreLinkHref` | The line under the list. Empty `moreLinkHref` hides it. |
187
196
  | `cta.title` `.description` | The closing ask, plus one line removing a reason to hesitate. |
188
- | `cta.primaryActionHref` `.secondaryActionHref` | See [Destinations](#destinations). |
197
+ | `cta.command` | A command shown as a code chip. Empty for a product that is not installed from a terminal. |
198
+ | `cta.primaryActionLabel` `.primaryActionHref` `.secondaryActionLabel` `.secondaryActionHref` | See [Destinations](#destinations). |
189
199
  | `footer.tagline` | One line under the brand. |
190
- | `footer.groupProduct` `.groupLegal` `.link*` | Footer labels. One or two words each. |
200
+ | `footer.links[]` `.linkTerms` `.linkPrivacy` | `{ id, label, href }` per outbound link, then the two legal labels. Empty drops a link. |
201
+ | `footer.githubHref` `.xHref` | Social profiles. Empty hides the icon. |
202
+ | `footer.copyright` | Keep `{year}` and `{siteName}` in it. |
191
203
 
192
204
  Three mechanical rules the code depends on:
193
205
 
194
- - **`{siteName}` is the only placeholder, and it works in exactly four strings**:
195
- `meta.title`, `meta.description`, `hero.description`, `cta.description`. Anywhere else it
196
- renders as the literal text `{siteName}`. Never a template literal — copy is data.
197
- - **Feature and FAQ `id`s are stable keys**, not positions. Rewriting a `title` under an
206
+ - **`{siteName}` is the placeholder**, and the blocks fill it in titles, descriptions,
207
+ answers and tier names. Never a template literal — copy is data. `{year}` works only in
208
+ `footer.copyright`. A pair of backticks in a description or answer renders as `<code>`.
209
+ - **Every list item's `id` is a stable key**, not a position. Rewriting a `title` under an
198
210
  existing id is free. Renaming an id is allowed but it is a new string to a translation
199
211
  layer, so do it only when the item genuinely became a different item.
200
- - **Deleting an item is fine**, in any of the three lists. Fewer than six features and fewer
201
- than five questions both lay out correctly.
212
+ - **Deleting an item is fine**, in any list. Fewer features, questions, steps or quotes all
213
+ lay out correctly.
202
214
 
203
215
  ### Icons
204
216
 
205
- Each feature carries an `icon` naming a glyph from the registry at the top of
206
- `packages/ui/src/blocks/feature-grid.tsx`. Rewrite what a feature is about and move its
207
- icon in the same edit — that is the whole reason the name lives in content. A page whose
208
- "practice all four modules" card renders a terminal prompt reads as machine-assembled,
209
- because it was.
217
+ Feature, pain point and audience items carry an `icon` naming a glyph from the registry in
218
+ `packages/ui/src/lib/icons.ts`. Rewrite what an item is about and move its icon in the same
219
+ edit — that is the whole reason the name lives in content. A page whose "practice all four
220
+ modules" card renders a terminal prompt reads as machine-assembled, because it was.
210
221
 
211
222
  - Use a key the registry actually has. Read the map; do not guess from memory. An unknown
212
223
  name silently renders the fallback sparkle, which looks like a choice and is not.
213
224
  - If nothing in the registry fits, say so in the draft and name the lucide icon you would
214
- want. Widening the map is a two-line edit **the owner makes**, in a block, which is not
215
- yours.
225
+ want. Widening the map is a two-line edit **the owner makes**, in a library file, which is
226
+ not yours.
216
227
 
217
228
  ### Destinations
218
229
 
219
- Three of the page's hrefs are content, and they are the ones that leave the page:
220
- `navbar.ctaHref`, `cta.primaryActionHref`, `cta.secondaryActionHref`. The brief's
221
- **Where "sign up" goes** section is where the answer lives.
230
+ Every href that leaves the page is content: the navbar's two buttons, the hero's two
231
+ actions and its announcement, the CTA's two actions, each tier's `ctaHref`, the FAQ's
232
+ contact link and the footer's links and social profiles. The brief's **Where "sign up"
233
+ goes** section is where the main answer lives.
222
234
 
223
- - **A URL in the brief.** Write it into `navbar.ctaHref` and `cta.primaryActionHref`.
235
+ - **A URL in the brief.** Write it into `navbar.ctaHref`, `hero.primaryActionHref` and
236
+ `cta.primaryActionHref`.
224
237
  - **Nothing yet.** Leave them as shipped (`#cta` and `/`) and write labels the page can
225
238
  honestly satisfy — "See how it works" rather than "Start your free trial". Note it in the
226
239
  draft and in the brief's **Known gaps**. A primary button that reloads the page is the
@@ -229,8 +242,12 @@ Three of the page's hrefs are content, and they are the ones that leave the page
229
242
  - **`cta.secondaryActionHref`** is usually docs or an about page. `/` is honest for a
230
243
  one-page site; a label promising docs that do not exist is not.
231
244
 
232
- Everything else stays put. `#features`, `#pricing`, `#faq` and `#cta` are anchors matching
233
- section ids, they live in the blocks, and changing one breaks a link.
245
+ - **The shipped values point at Saasaloy's own repo and docs.** Replace every one. An href
246
+ you cannot fill gets an empty string where the key allows it, which hides the link.
247
+
248
+ Everything else stays put. `#features`, `#how-it-works`, `#showcase`, `#pricing` and
249
+ `#faq` are anchors matching section ids, they live in the blocks, and changing one breaks a
250
+ link.
234
251
 
235
252
  ### Pricing
236
253
 
@@ -324,22 +341,22 @@ sure about, and anything still on the **Not mine to fix** list.
324
341
 
325
342
  ### Dropping a block is its own confirmation
326
343
 
327
- When the brief yields nothing a block can honestly carry — no pricing, no proof for the FAQ
328
- — you may propose removing that block. **Separately.** Never bundled into the copy write,
344
+ When the brief yields nothing a block can honestly carry — no pricing, no customer quotes,
345
+ no customer logos, no screenshots for the showcase, no real sequence for how it works — you
346
+ may propose removing that block. **Separately.** Never bundled into the copy write,
329
347
  never silent.
330
348
 
331
349
  It is three edits, and all three are allowed:
332
350
 
333
- 1. Delete that block's line from `apps/web/src/pages/index.astro`.
351
+ 1. Delete that block's line and its import from `apps/web/src/pages/index.astro`. Tell
352
+ the owner they can delete the block file in `packages/ui/src/blocks/` too; that file is
353
+ not yours to delete.
334
354
  2. Blank the matching label in content (`landing.navbar.linkPricing = ""`,
335
355
  `landing.footer.linkPricing = ""`). A blank label drops the link, so the page keeps no
336
356
  nav entry pointing at a section that is gone.
337
- 3. **Retarget or drop any hero action pointing at the removed section.** A blank label
338
- does not save you here: the hero's `href` lives in the block, so it survives an empty
339
- label and the button still points at nothing. The hero's secondary action ships as
340
- `href: "#pricing"`, so when you remove the pricing block, pass `secondaryAction={null}`
341
- to `<Hero>` in `index.astro` to drop the button, or pass a whole action pointing at a
342
- section that still exists. Confirm which one with the owner along with the removal.
357
+ 3. **Retarget any action pointing at the removed section.** The hero's and the CTA's
358
+ hrefs are content, so check each one in `landing.ts` and point it somewhere that still
359
+ exists. Confirm the new target with the owner along with the removal.
343
360
 
344
361
  Removing a block is not redesigning one. You are editing lines in a page, not editing
345
362
  `packages/ui/src/blocks/`.
@@ -371,7 +388,7 @@ Copy on disk plus a brief means this has run before.
371
388
  - **Never edit a block.** `packages/ui/src/blocks/*.tsx` is off limits: no markup, no
372
389
  classes, no structure, no widening the icon registry. Read them freely. If copy will not
373
390
  fit a block, say so and let the owner decide.
374
- - **Never touch the design layer** — `packages/ui/src/styles/globals.css`,
391
+ - **Never touch the design layer** — `packages/ui/src/styles/*`,
375
392
  `components.json`, `packages/ui/src/components/*`, or any Tailwind class anywhere.
376
393
  - **Never rewrite `ui.*`**, in any language. Translate it or leave it.
377
394
  - **Leave anchors alone.** `#features`, `#pricing`, `#faq`, `#cta` and a tier's default
@@ -262,7 +262,7 @@ can see:
262
262
  - **Never write landing copy.** Not into `packages/ui/src/content/landing.ts`, not into a
263
263
  block, not "just the headline while we're here". The brief is your output.
264
264
  - **Never edit a block or the design layer** — `packages/ui/src/blocks/*`,
265
- `packages/ui/src/styles/globals.css`, `packages/ui/src/components/*`, `components.json`, or a Tailwind class
265
+ `packages/ui/src/styles/*`, `packages/ui/src/components/*`, `components.json`, or a Tailwind class
266
266
  anywhere.
267
267
  - **Never invent pricing, proof, a customer name, or a URL.** Not in the brief, and not in
268
268
  a sample answer.
@@ -14,9 +14,6 @@ pnpm-debug.log*
14
14
  .env
15
15
  .env.*
16
16
  !.env.example
17
- .dev.vars
18
- .dev.vars.*
19
- !.dev.vars.example
20
17
  *.pem
21
18
 
22
19
  # OS / editor
@@ -1,18 +1,25 @@
1
1
  {
2
2
  "seedFiles": [
3
- "DESIGN.md",
3
+ "apps/web/DESIGN.md",
4
4
  "README.md",
5
5
  "saasaloy.json",
6
6
  "apps/web/src/pages/index.astro",
7
7
  "packages/ui/src/content/landing.ts"
8
8
  ],
9
+ "retiredSeedFiles": ["DESIGN.md"],
9
10
  "ownedFiles": [
11
+ "packages/env/.env.example",
12
+ "packages/env/src/services.ts",
13
+ "packages/env/src/sources.ts",
14
+ "apps/web/src/env.ts",
10
15
  "packages/ui/src/styles/globals.css",
16
+ "packages/ui/src/styles/web.css",
11
17
  "packages/ui/src/blocks/**",
12
18
  "packages/ui/src/components/**",
13
19
  "packages/ui/src/containers/**",
14
20
  "packages/ui/src/content/**",
15
21
  "packages/ui/src/index.ts",
22
+ "packages/ui/src/lib/icons.ts",
16
23
  "apps/web/src/layouts/Layout.astro",
17
24
  "apps/web/public/favicon.*"
18
25
  ]
@@ -32,10 +32,16 @@ colors:
32
32
  typography:
33
33
  headline-display:
34
34
  fontFamily: "ui-sans-serif, system-ui, sans-serif"
35
- fontSize: 3.75rem
35
+ fontSize: 4.5rem
36
+ fontWeight: 500
37
+ lineHeight: 1.05
38
+ letterSpacing: -0.035em
39
+ headline-section:
40
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
41
+ fontSize: 3rem
36
42
  fontWeight: 600
37
- lineHeight: 1
38
- letterSpacing: -0.025em
43
+ lineHeight: 1.1
44
+ letterSpacing: -0.03em
39
45
  headline-lg:
40
46
  fontFamily: "ui-sans-serif, system-ui, sans-serif"
41
47
  fontSize: 2.25rem
@@ -53,6 +59,11 @@ typography:
53
59
  fontSize: 1.125rem
54
60
  fontWeight: 400
55
61
  lineHeight: 1.75rem
62
+ lead:
63
+ fontFamily: "ui-sans-serif, system-ui, sans-serif"
64
+ fontSize: 1.125rem
65
+ fontWeight: 400
66
+ lineHeight: 1.75rem
56
67
  body-md:
57
68
  fontFamily: "ui-sans-serif, system-ui, sans-serif"
58
69
  fontSize: 1rem
@@ -93,7 +104,10 @@ components:
93
104
  typography: "{typography.headline-display}"
94
105
  section-title:
95
106
  textColor: "{colors.foreground}"
96
- typography: "{typography.headline-lg}"
107
+ typography: "{typography.headline-section}"
108
+ section-lead:
109
+ textColor: "{colors.muted-foreground}"
110
+ typography: "{typography.lead}"
97
111
  legal-title:
98
112
  textColor: "{colors.foreground}"
99
113
  typography: "{typography.headline-md}"
@@ -162,25 +176,25 @@ The base uses a neutral, content-first system. It gives a new product a clear st
162
176
 
163
177
  ## Apps
164
178
 
165
- An app may carry its own token set in its own stylesheet on top of these tokens, importing `packages/ui/src/styles/globals.css` first and reassigning the semantic custom properties after it. The admin app does that, and its skill documents the values.
179
+ This file covers the web app. Each app has its own theme file in `packages/ui/src/styles/` and its own `DESIGN.md` in its own folder. `web.css` holds the tokens recorded here, and `globals.css` holds the shared Tailwind setup with no token values. Another app's theme file imports `globals.css` and declares its own tokens, mapping and radius scale, so a change here never reaches it.
166
180
 
167
181
  ## Colors
168
182
 
169
183
  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.
170
184
 
171
- Use the semantic custom properties in `packages/ui/src/styles/globals.css`. Do not copy their current `oklch()` values into components.
185
+ Use the semantic custom properties in `packages/ui/src/styles/web.css`. Do not copy their current `oklch()` values into components.
172
186
 
173
187
  ## Typography
174
188
 
175
- 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`.
189
+ The template uses Tailwind's system sans stack, with no web font. Three named sizes in `web.css` carry the landing page's ramp. `text-display` is the hero's one `h1`: medium weight, very tight tracking, and fluid from `2.5rem` to `4.5rem`. `text-section-title` is every section's `h2`: semibold, tight, fluid from `2rem` to `3rem`. `text-lead` is the paragraph under either. The token map records the largest size of each fluid step. Body copy uses the `text-sm` through `text-lg` scale. Controls use medium weight at `text-xs` or `text-sm`. Code the page quotes (commands, module names, paths) uses the mono face at `text-xs` or the surrounding size.
176
190
 
177
191
  ## Layout
178
192
 
179
- 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.
193
+ Three container widths live in `web.css`. `section-container` is every section's frame at `64rem`. `section-container-narrow` is a deliberate `48rem` reading measure for the FAQ and the closing CTA. `section-container-wide` is `80rem`, for the navbar and the hero's product frame. All three share a `1.5rem` gutter. The sticky navbar floats: a gap holds it off the top of the viewport, and it overlays the first section instead of pushing it down. The hero's background, grid and light beams start at the top of the page, behind the bar, so no band of plain background shows above the hero. `section-y` is the one vertical rhythm between sections: `5rem` on small screens and `8rem` from `48rem` up. A block uses these names and sets no width or section padding of its own, so sections can be added, removed and reordered without the spacing drifting. Components use Tailwind's default spacing scale, which stays omitted from the token map because the project does not define a custom scale.
180
194
 
181
195
  ## Elevation & Depth
182
196
 
183
- 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.
197
+ Surfaces use borders, rings, and tonal contrast instead of a shadow scale. Landing cards add a hairline `shadow-xs`. Two elements carry a soft drop shadow tinted from `foreground`: the hero's product frame and the featured pricing tier. Those are the page's only raised surfaces. The small shadow on the input is a component detail. Do not infer a project shadow scale from any of them.
184
198
 
185
199
  ## Shapes
186
200
 
@@ -188,26 +202,36 @@ The base radius is `0.625rem`. Controls use the `lg` radius. Cards use `xl`. Lar
188
202
 
189
203
  ## Components
190
204
 
205
+ The landing page is thirteen blocks in `packages/ui/src/blocks/`, composed by explicit imports in `apps/web/src/pages/index.astro`: navbar, hero, logo-cloud, pain-points, how-it-works, feature-grid, showcase, audience, testimonials, pricing-table, faq, cta and footer. They share one vocabulary. A section opens with a centred `section-title` and `lead`. Cards are bordered at the `xl` radius. A product visual sits in a double frame: an outer bordered shell at `1.25rem` and an inner surface at `0.875rem`. Primary calls to action sit in a thin ringed wrapper beside a ghost button. Decoration is drawn from tokens: the `grid-pattern` utility under a radial mask, and a dashed rule for dividers inside a card and above the footer.
206
+
191
207
  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.
192
208
 
209
+ Cards in one row share one height. The grid stretches its cards, so it carries no `items-start`, and sibling cards hold the same number of one-line items. The pricing tiers are the worked example: five one-line bullets each. A card's primary action sits inside that card, on the primary button, never below or beside it.
210
+
211
+ A value that waits on a request shows the `Skeleton` primitive in its place, at the size the value will take, until the response arrives. It never shows a blank gap or a spinner. Add the primitive with `pnpm --filter @repo/ui exec shadcn add skeleton` the first time a page needs it.
212
+
193
213
  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.
194
214
 
195
215
  ## Do's and Don'ts
196
216
 
197
- - Read this file and `packages/ui/src/styles/globals.css` before you write UI.
217
+ - Read this file and `packages/ui/src/styles/web.css` before you write UI.
198
218
  - Use semantic token utilities such as `bg-primary` and `text-muted-foreground`.
199
219
  - Keep light and dark token pairs together when you change a semantic role.
200
220
  - Keep new pages within the established type scale and radius vocabulary.
201
221
  - Do not add a color, radius, shadow, or type size that the code does not define.
202
222
  - Do not use a shadow when a border or tonal surface provides the needed separation.
203
223
  - Keep UI copy direct and specific. Use the product brief for product language.
224
+ - Do not put a value in copy that goes stale: a version number, a release date, a count. Say something general and link to where the current value lives, the way the hero pill says "See what's new" and links to the changelog.
225
+ - Open every link that leaves the site in a new tab. Spread `externalLinkProps(href)` from `@repo/ui/lib/external-link` onto the anchor and render `newTabHint(href)` inside it. A path on this site or a same-page anchor stays in the tab.
204
226
 
205
227
  ## Motion
206
228
 
207
- 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.
229
+ Motion is CSS only and additive. With reduced motion requested, or in a browser that lacks the feature, every element sits in its final state. The named utilities in `web.css` are the whole vocabulary: `blur-in` (the hero's load-time cascade, staggered with `--blur-in-delay`), `beam-in` (the hero's two light beams, which show from their top-left end and widen just after the cascade starts, staggered with `--beam-in-delay`), `reveal` (a scroll-tied entrance below the fold), `marquee` (the logo row), `shine-text` (the announcement pill), `nav-float` (the navbar frosting once the page scrolls) and `hover-lift`. Blocks use these names and write no keyframes. Color and state changes use short Tailwind transitions. Motion stays in prose because the DESIGN.md alpha schema has no motion token group. A theme toggle press reveals the new theme as a circle that grows from the button, through a view transition whose animation lives in `globals.css`. The script only starts it, and reduced motion skips it.
208
230
 
209
231
  ## Dark Mode
210
232
 
211
233
  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.
212
234
 
213
- _Seeded from the saasaloy base template · CLI {{CLI_VERSION}} · tokens sha256:101fd7fd684f of packages/ui/src/styles/globals.css_
235
+ The theme toggle has two states, light and dark. `system` is only the starting state: a visitor who never pressed the toggle follows the OS. A press flips the resolved mode and stores the choice, and no press steps back to `system`. Do not add a third state or a light/dark/system menu to the toggle.
236
+
237
+ _Seeded from the saasaloy base template · CLI {{CLI_VERSION}} · tokens sha256:e25d78b57596 of packages/ui/src/styles/web.css_
@@ -5,6 +5,31 @@ import react from "@astrojs/react";
5
5
  import tailwindcss from "@tailwindcss/vite";
6
6
  import { defineConfig } from "astro/config";
7
7
 
8
+ // `astro build` gets its own Vite cache. Both commands keep pre-bundled dependencies under
9
+ // node_modules/.vite, each stamped with a hash of the config that made them. The two
10
+ // hashes always differ, and Vite deletes a cache whose hash does not match. So a build
11
+ // run beside a live `astro dev` deletes the dev server's deps_ssr folder, and the next
12
+ // module the dev server loads from it fails with "The file does not exist at
13
+ // .../deps_ssr/<dep>.js which is in the optimize deps directory". A separate folder for
14
+ // the build keeps the two apart; dev keeps the default.
15
+ /** @returns {import("astro").AstroIntegration} the integration that moves the build cache */
16
+ function separateBuildCache() {
17
+ return {
18
+ name: "separate-build-cache",
19
+ hooks: {
20
+ "astro:config:setup": ({ command, updateConfig }) => {
21
+ if (command === "build") {
22
+ updateConfig({ vite: { cacheDir: "node_modules/.vite-build" } });
23
+ }
24
+ },
25
+ },
26
+ };
27
+ }
28
+
29
+ // Every React component the dev server can render, for Vite's dependency scan. See the
30
+ // `optimizeDeps` notes in `vite` below.
31
+ const COMPONENT_ENTRIES = ["src/**/*.tsx", "../../packages/ui/src/**/*.tsx"];
32
+
8
33
  // Static output — the marketing site is content-first, and every page here is
9
34
  // prerendered at build time. `output: "static"` is written out rather than left to the
10
35
  // default so the next person changing this file has to mean it.
@@ -44,7 +69,7 @@ export default defineConfig({
44
69
  // makes pnpm install the same plugin for the adapter. Move it and `wrangler` together:
45
70
  // they release as a pair, and apps/api (once added) pins the same two versions.
46
71
  adapter: cloudflare({ imageService: "compile" }),
47
- integrations: [react()],
72
+ integrations: [react(), separateBuildCache()],
48
73
  // Fixed dev port. Every cross-origin consumer in this repo — the api Worker's CORS
49
74
  // allowlist, auth's `trustedOrigins`, the waitlist form's `PUBLIC_API_URL` fallback —
50
75
  // hardcodes the localhost dev origins, so the port cannot be allowed to drift.
@@ -53,8 +78,9 @@ export default defineConfig({
53
78
  server: { port: 3000 },
54
79
  vite: {
55
80
  // Tailwind 4 is a Vite plugin, not an Astro integration — `@astrojs/tailwind` is EOL
56
- // and never supported v4. The theme itself (tokens, @source globs) lives in
57
- // packages/ui/src/styles/globals.css, which Layout.astro imports via @repo/ui.
81
+ // and never supported v4. The theme lives in packages/ui: the @source globs in
82
+ // src/styles/globals.css, the tokens in src/styles/web.css, which Layout.astro
83
+ // imports via `@repo/ui/styles/web.css`.
58
84
  plugins: [tailwindcss()],
59
85
  server: { strictPort: true },
60
86
  // Dev only: pre-bundle every dependency the React components import before the
@@ -67,10 +93,14 @@ export default defineConfig({
67
93
  // A glob, not an include list, so a component a module adds is covered with no edit
68
94
  // here.
69
95
  ssr: {
70
- optimizeDeps: {
71
- entries: ["src/**/*.tsx", "../../packages/ui/src/**/*.tsx"],
72
- },
96
+ optimizeDeps: { entries: COMPONENT_ENTRIES },
73
97
  },
98
+ // The browser needs the same entries. Its scan reads an .astro page's <script> tags but
99
+ // not the frontmatter that imports an island, so the islands' own dependencies arrive
100
+ // mid-request. Vite re-bundles, renames the shared chunks, and deletes the ones the
101
+ // page already asked for: "The file does not exist at .../deps/react-dom-<hash>.js
102
+ // which is in the optimize deps directory", then a full reload.
103
+ optimizeDeps: { entries: COMPONENT_ENTRIES },
74
104
  resolve: {
75
105
  // `@web` mirrors saasaloy.json's alias of the same name (apps/web/src) — that
76
106
  // alias only drives the CLI's file-placement when a module's files[] target
@@ -8,7 +8,7 @@
8
8
  "build": "astro build",
9
9
  "clean": "rimraf dist .astro .wrangler",
10
10
  "preview": "astro preview",
11
- "deploy": "wrangler deploy"
11
+ "deploy": "env-deploy web"
12
12
  },
13
13
  "dependencies": {
14
14
  "astro": "7.3.5",
@@ -16,6 +16,7 @@
16
16
  "@astrojs/react": "7.0.0",
17
17
  "react": "19.3.0",
18
18
  "react-dom": "19.3.0",
19
+ "@repo/env": "workspace:*",
19
20
  "@repo/ui": "workspace:*"
20
21
  },
21
22
  "devDependencies": {