saasaloy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/index.js +8455 -0
  2. package/dist/index.js.map +1 -0
  3. package/package.json +69 -0
  4. package/schemas/manifest.schema.json +107 -0
  5. package/schemas/registry-item.schema.json +336 -0
  6. package/schemas/saasaloy-lock.schema.json +86 -0
  7. package/schemas/saasaloy.schema.json +42 -0
  8. package/templates/base/AGENTS.md +450 -0
  9. package/templates/base/CLAUDE.md +1 -0
  10. package/templates/base/DESIGN.md +209 -0
  11. package/templates/base/README.md +71 -0
  12. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +198 -0
  13. package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +395 -0
  14. package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +273 -0
  15. package/templates/base/_gitignore +32 -0
  16. package/templates/base/_husky/commit-msg +1 -0
  17. package/templates/base/_husky/pre-commit +1 -0
  18. package/templates/base/_prettierignore +31 -0
  19. package/templates/base/_saasaloy-base.json +9 -0
  20. package/templates/base/apps/web/astro.config.mjs +61 -0
  21. package/templates/base/apps/web/package.json +30 -0
  22. package/templates/base/apps/web/public/favicon.svg +4 -0
  23. package/templates/base/apps/web/src/layouts/Layout.astro +52 -0
  24. package/templates/base/apps/web/src/pages/404.astro +24 -0
  25. package/templates/base/apps/web/src/pages/500.astro +33 -0
  26. package/templates/base/apps/web/src/pages/index.astro +61 -0
  27. package/templates/base/apps/web/src/pages/privacy.astro +15 -0
  28. package/templates/base/apps/web/src/pages/terms.astro +14 -0
  29. package/templates/base/apps/web/tsconfig.json +11 -0
  30. package/templates/base/apps/web/wrangler.jsonc +27 -0
  31. package/templates/base/commitlint.config.js +9 -0
  32. package/templates/base/lint-staged.config.js +17 -0
  33. package/templates/base/oxlint.config.mjs +155 -0
  34. package/templates/base/package.json +44 -0
  35. package/templates/base/packages/tsconfig/base.json +17 -0
  36. package/templates/base/packages/tsconfig/package.json +18 -0
  37. package/templates/base/packages/ui/components.json +19 -0
  38. package/templates/base/packages/ui/package.json +39 -0
  39. package/templates/base/packages/ui/src/blocks/cta.tsx +82 -0
  40. package/templates/base/packages/ui/src/blocks/error-state.tsx +144 -0
  41. package/templates/base/packages/ui/src/blocks/faq.tsx +64 -0
  42. package/templates/base/packages/ui/src/blocks/feature-grid.tsx +185 -0
  43. package/templates/base/packages/ui/src/blocks/footer.tsx +99 -0
  44. package/templates/base/packages/ui/src/blocks/hero.tsx +84 -0
  45. package/templates/base/packages/ui/src/blocks/navbar.tsx +159 -0
  46. package/templates/base/packages/ui/src/blocks/pricing-table.tsx +175 -0
  47. package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +51 -0
  48. package/templates/base/packages/ui/src/components/accordion.tsx +78 -0
  49. package/templates/base/packages/ui/src/components/badge.tsx +53 -0
  50. package/templates/base/packages/ui/src/components/button.tsx +59 -0
  51. package/templates/base/packages/ui/src/components/card.tsx +103 -0
  52. package/templates/base/packages/ui/src/components/input.tsx +20 -0
  53. package/templates/base/packages/ui/src/components/label.tsx +18 -0
  54. package/templates/base/packages/ui/src/components/separator.tsx +23 -0
  55. package/templates/base/packages/ui/src/containers/README.md +11 -0
  56. package/templates/base/packages/ui/src/content/errors.ts +58 -0
  57. package/templates/base/packages/ui/src/content/landing.ts +304 -0
  58. package/templates/base/packages/ui/src/index.ts +8 -0
  59. package/templates/base/packages/ui/src/lib/interpolate.ts +31 -0
  60. package/templates/base/packages/ui/src/lib/sentinel.ts +11 -0
  61. package/templates/base/packages/ui/src/lib/theme.ts +167 -0
  62. package/templates/base/packages/ui/src/lib/utils.ts +11 -0
  63. package/templates/base/packages/ui/src/styles/globals.css +164 -0
  64. package/templates/base/packages/ui/tsconfig.json +7 -0
  65. package/templates/base/pnpm-workspace.yaml +23 -0
  66. package/templates/base/prettier.config.js +10 -0
  67. package/templates/base/saasaloy.json +8 -0
  68. package/templates/base/stylelint.config.js +46 -0
  69. package/templates/base/turbo.json +20 -0
@@ -0,0 +1,273 @@
1
+ ---
2
+ name: saasaloy-setup
3
+ description: Interview the project owner about their product once, and write the answers to docs/product-brief.md — the shared context every other Saasaloy skill reads. Use at the start of a freshly scaffolded project, when the owner says "set up this project", "tell you about my product", "what is this project", when siteName is still the scaffold's directory name, when another skill needs product context and no brief exists yet, and when re-running to update the brief after something changed.
4
+ ---
5
+
6
+ # saasaloy-setup — find out what this project is, once
7
+
8
+ A scaffolded project knows nothing about the product it is going to become. Its name is a
9
+ directory name, its language is English because nobody asked, and every skill that follows
10
+ has to either interview the owner again or guess.
11
+
12
+ So this skill asks once and writes the answers down. The deliverable is
13
+ **`docs/product-brief.md`**, plus the two facts that are code rather than prose: `siteName`
14
+ and the page's `lang` attribute.
15
+
16
+ It writes no copy. `saasaloy-landing-copy` does that, from the brief this leaves behind.
17
+
18
+ ## The write surface
19
+
20
+ | Path | What you change |
21
+ |------|-----------------|
22
+ | `docs/product-brief.md` | The whole file. Create `docs/` if it does not exist. |
23
+ | `packages/ui/src/index.ts` | `siteName` — the brand constant, and nothing else in the file. |
24
+ | `apps/web/src/layouts/Layout.astro` | The `lang` attribute only, and only when it differs from the brief's language code. |
25
+
26
+ Nothing else. You are not here to write the landing page, add a dependency, or touch a
27
+ block.
28
+
29
+ ## Step 0 — before you ask anything
30
+
31
+ 1. **Read `docs/product-brief.md`.** If it exists, someone has already been through this.
32
+ Read it fully and jump to [Re-running](#re-running).
33
+ 2. **Read `packages/ui/src/index.ts`** for the current `siteName`. `saasaloy init` sets it
34
+ to the directory name, so it is usually a slug like `my-saas` rather than a brand. It is
35
+ still the best opening guess you have, and it belongs in question 1's samples.
36
+ 3. **Look at git, and treat it as advice.** `git status --short` if there is a repo. A dirty
37
+ tree or no repo at all is worth one sentence — "your changes aren't committed, so you
38
+ can't undo this with git; every write here is previewed and confirmed anyway" — and then
39
+ you carry on. `saasaloy init` does not run `git init`, so a clean-tree requirement would
40
+ block exactly the person who just scaffolded. **Neither state is a reason to stop.**
41
+ 4. **Say how long this takes and what it is for**, in two lines, before question 1. Ten
42
+ questions is a real ask, and an owner who does not know why is an owner who answers
43
+ thinly.
44
+
45
+ ## Step 1 — the questions
46
+
47
+ Ten of them. **Question 1 is the project's name, it goes first, and it goes on its own** —
48
+ every sample answer after it is built out of the name and the answers before it, so asking
49
+ it last (as an earlier version of this workflow did) means every other question gets asked
50
+ with nothing to work from.
51
+
52
+ After that, ask in batches of **two or three**. Number them so the owner can answer by
53
+ number.
54
+
55
+ | # | Dimension | What you are actually after |
56
+ |---|-----------|----------------------------|
57
+ | 1 | **Name** | What the product is called. Ask alone, first, before anything else. |
58
+ | 2 | **Language** | The language the audience reads, as a name and a code (`en`, `bn`, `es`). Do not assume English. |
59
+ | 3 | **Audience** | The specific person who pays. A job title, a team, a situation. |
60
+ | 4 | **Problem** | What goes wrong for them today, in their words. |
61
+ | 5 | **Current alternative** | What they use *instead* right now — usually a spreadsheet, a contractor, or nothing. This is the real competitor. |
62
+ | 6 | **Differentiator** | What this does that the alternative cannot. One thing, not a list. |
63
+ | 7 | **Proof** | Something the owner can stand behind today: a number, a named customer who agreed to be named, a benchmark they ran, a certificate. |
64
+ | 8 | **Tone** | How the product should sound. Ask for an adjective *and* a site they think gets it right. |
65
+ | 9 | **Pricing** | Per tier: name, who it is for, real monthly price, annual price if there is one. See [Pricing](#pricing-is-extracted-never-invented). |
66
+ | 10 | **Where "sign up" goes** | The signup, waitlist or app URL the primary button should point at. See [The destination](#the-destination-question). |
67
+
68
+ ### Every question ships with sample answers
69
+
70
+ Never ask a bare question. Each one carries **three sample answers the owner can take as
71
+ written, edit, or ignore in favour of their own** — and say that all three routes are open,
72
+ every time. Most owners answer a picker in seconds and a blank prompt in paragraphs, and
73
+ the paragraph is usually vaguer.
74
+
75
+ In Claude Code, ask with the question-picker tool so the samples render as choices with a
76
+ free-text option beside them. Any other agent: number the samples `a` / `b` / `c` and add
77
+ "or write your own".
78
+
79
+ Four rules keep samples useful instead of leading:
80
+
81
+ - **Derive them.** A sample is a hypothesis built from the name and the answers already
82
+ given, not a stock phrase. After "Ledgerly" and "bookkeepers", the problem samples talk
83
+ about month-end and client accounts. If your three samples would fit any product on the
84
+ internet, you have written filler, and the owner will pick one, and the page will say
85
+ nothing.
86
+ - **Make them differ in substance, not wording.** Three ways to say "small businesses" is
87
+ one sample. Three genuinely different audiences is three.
88
+ - **For questions 7, 9 and 10, offer shapes rather than values.** These three become facts
89
+ the page asserts. "Two named beta customers who agreed to be named" is a shape and is
90
+ safe to suggest. "300 students enrolled" is a number you invented, and if the owner picks
91
+ it the landing page now lies. Same for a price, and same for a URL: suggest
92
+ `https://<yourdomain>/waitlist`, never a domain you made up.
93
+ - **Always include the honest empty answer** where one exists: "no proof yet", "no pricing
94
+ yet", "nothing to link to yet". An owner who cannot see that option supplies something
95
+ rather than admit to nothing, which is how invented proof gets in.
96
+
97
+ A worked pair:
98
+
99
+ > **1. What is this product called?**
100
+ > a. `ledgerly` — the name the folder already has
101
+ > b. Ledgerly — same word, capitalised the way you would write it on the page
102
+ > c. Something else entirely, if the folder name was a placeholder
103
+ > Or type it however you want it to appear on the page.
104
+
105
+ > **4. What goes wrong for a bookkeeper today?** *(samples built from your answer to 3)*
106
+ > a. Month-end close runs across four spreadsheets per client, and a broken formula only
107
+ > surfaces when the client queries the invoice.
108
+ > b. Chasing clients for receipts eats the first week of every month.
109
+ > c. Nothing is wrong exactly, it is just slow, and I want to say what instead.
110
+ > Or describe it in your own words — your words are the ones that end up on the page.
111
+
112
+ ### Push back, at most twice
113
+
114
+ An answer is **weak** when it names no audience, no number, no named alternative, or no
115
+ proof the owner could point at. "Everyone" is not an audience. "It's faster" is not a
116
+ differentiator. "Businesses struggle with productivity" is not a problem.
117
+
118
+ Push back **at most twice per question**, and make each follow-up narrower than the last —
119
+ carrying fresh samples, same as the first ask:
120
+
121
+ > — Who is your audience?
122
+ > — Small businesses.
123
+ > — Which ones? Think of the last person who asked you for this. What was their job?
124
+ > — Bookkeepers, mostly, at firms with 5–20 clients.
125
+
126
+ If the second follow-up still comes back thin, **stop asking.** Record it in the brief
127
+ verbatim, tagged `weak:`, and say what it will cost:
128
+
129
+ > I've recorded "it's faster" as weak — no number behind it — so anything written from this
130
+ > brief will describe what the product does rather than claim a speed advantage. Give me a
131
+ > benchmark later and that changes.
132
+
133
+ Two follow-ups is a ceiling, not a quota. A specific first answer needs none.
134
+
135
+ ### Pricing is extracted, never invented
136
+
137
+ The scaffolded page ships three plausible-looking tiers at $0 / $29 / Custom. Shipping
138
+ those as if they were real is the worst thing this workflow can do, so ask per tier: name,
139
+ who it is for, real monthly price, annual price if there is one.
140
+
141
+ Three acceptable outcomes, and no others:
142
+
143
+ 1. **Real prices.** Record them, with the date they were confirmed.
144
+ 2. **"Leave pricing as a placeholder."** Record that the shipped tiers are unconfirmed
145
+ placeholder copy, in those words.
146
+ 3. **"We don't have pricing yet."** Record that. The landing-copy skill will offer to drop
147
+ the pricing block, which is its call to make and its confirmation to take.
148
+
149
+ Never round, convert currencies, or fill in a typical number. Record the currency if it is
150
+ not USD.
151
+
152
+ Annual pricing has two shapes and both are fine: a real discount (record the effective
153
+ monthly cost when billed annually) or no discount at all (record that, so nobody writes
154
+ "Save 20%" over two identical numbers).
155
+
156
+ ### The destination question
157
+
158
+ Question 10 is the one owners are most surprised to be asked, and the one that most often
159
+ leaves a page broken. A pre-launch product with a **Join the waitlist** button that reloads
160
+ the homepage is worse off than one with a wrong icon.
161
+
162
+ Ask where the primary call to action should go. Three answers to expect:
163
+
164
+ - **A URL.** Record it. An external form, a waitlist provider, an app subdomain.
165
+ - **A route this project will have later** (`/signup`). Record it, and note it does not
166
+ exist yet.
167
+ - **Nothing yet.** Record it as `weak:`. The page keeps sending people to its own closing
168
+ section, which is honest but weak, and the labels have to stay modest to match.
169
+
170
+ ## Step 2 — write the brief
171
+
172
+ `docs/product-brief.md` is the deliverable. It is human-editable, it is the source of truth
173
+ on every re-run, and it is what every other skill reads instead of interviewing again.
174
+
175
+ ```md
176
+ # Product brief
177
+
178
+ Last updated: YYYY-MM-DD (saasaloy-setup)
179
+
180
+ - **Name**: Ledgerly
181
+ - **Language**: Bangla (`bn`)
182
+
183
+ ## Audience
184
+ Bookkeepers at firms carrying 5–20 client accounts.
185
+
186
+ ## Problem
187
+ Month-end close runs across four spreadsheets per client, and a broken formula is only
188
+ found when a client queries the invoice.
189
+
190
+ ## Current alternative
191
+ Excel plus a shared Dropbox folder.
192
+
193
+ ## Differentiator
194
+ weak: "it's more organised" — asked twice, no mechanism named. Copy avoids this claim.
195
+
196
+ ## Proof
197
+ Two named firms in beta (Rahman & Co, Hasan Associates), both agreed to be named.
198
+
199
+ ## Tone
200
+ Plain, unexcited. Owner pointed at basecamp.com.
201
+
202
+ ## Pricing
203
+ Confirmed 2026-08-08: Solo ৳900/mo, Firm ৳3,500/mo, no annual discount yet.
204
+
205
+ ## Where "sign up" goes
206
+ https://ledgerly.com.bd/waitlist — a Tally form, live now.
207
+
208
+ ## Known gaps
209
+ - The template loads no webfont, so a non-Latin script renders in whatever face the
210
+ visitor's device provides.
211
+ ```
212
+
213
+ Two rules about this file:
214
+
215
+ - **Keep every `weak:` tag verbatim.** It is the thread the next run picks up.
216
+ - **Write it before you touch any code.** If the owner walks away after question 6, a brief
217
+ with six answers in it is worth having; six answers held in a conversation are not.
218
+
219
+ ## Step 3 — set the two facts that are code
220
+
221
+ Show the change and confirm it, one file at a time.
222
+
223
+ 1. **`siteName`** in `packages/ui/src/index.ts`. This is the brand as it appears in the
224
+ header, the footer and the browser tab. It is not translated, and it is the only thing
225
+ in that file you may edit.
226
+ 2. **`lang`** in `apps/web/src/layouts/Layout.astro`. Set it to the brief's language code
227
+ whenever the attribute differs from it, including back to `en` on a re-run that returns
228
+ to English. It ships hardcoded as `en`. Getting this wrong tells screen readers to
229
+ pronounce Bangla with English phonetics.
230
+
231
+ Then check it still builds, because both files are imported by the page:
232
+
233
+ ```sh
234
+ pnpm --filter @repo/ui typecheck
235
+ pnpm build
236
+ ```
237
+
238
+ ## Re-running
239
+
240
+ A brief on disk means the interview already happened.
241
+
242
+ 1. **Summarise it back** in a few lines and ask what changed.
243
+ 2. **Ask only about what moved** — plus every `weak:` tag, which is exactly the thread a
244
+ second pass should pick up. "Last time 'it's faster' had no number behind it. Do you
245
+ have one now?" Carry samples on those questions too.
246
+ 3. **Update `Last updated`** and rewrite only the sections that changed.
247
+ 4. **If the brief was hand-edited**, that wins. Do not tidy someone's prose back into your
248
+ own phrasing.
249
+
250
+ ## What happens next
251
+
252
+ Say so explicitly when you finish, because the brief on its own changes nothing the owner
253
+ can see:
254
+
255
+ > The brief is at `docs/product-brief.md` and `siteName` is set. Run
256
+ > `/saasaloy-landing-copy` and it will write the landing page from this, as a markdown
257
+ > draft you review before anything touches the site.
258
+
259
+ ## Boundaries to honor
260
+
261
+ - **[The write surface](#the-write-surface) is the whole list of files you may touch.**
262
+ - **Never write landing copy.** Not into `packages/ui/src/content/landing.ts`, not into a
263
+ block, not "just the headline while we're here". The brief is your output.
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
266
+ anywhere.
267
+ - **Never invent pricing, proof, a customer name, or a URL.** Not in the brief, and not in
268
+ a sample answer.
269
+ - **No dependencies, no i18n machinery, no webfonts.** Record the gap in the brief; do not
270
+ close it.
271
+ - **A dirty tree, a missing repo, or a thin interview is not a blocker.** Warn, record less,
272
+ and say what you left out. (This is about those three conditions only — anything unsafe
273
+ or outside this skill's scope you decline as you normally would.)
@@ -0,0 +1,32 @@
1
+ # Dependencies & build output
2
+ node_modules/
3
+ dist/
4
+ .astro/
5
+ .wrangler/
6
+ *.tsbuildinfo
7
+
8
+ # Logs
9
+ *.log
10
+ npm-debug.log*
11
+ pnpm-debug.log*
12
+
13
+ # Environment & secrets
14
+ .env
15
+ .env.*
16
+ !.env.example
17
+ .dev.vars
18
+ .dev.vars.*
19
+ !.dev.vars.example
20
+ *.pem
21
+
22
+ # OS / editor
23
+ .DS_Store
24
+ .idea/
25
+ .vscode-test
26
+
27
+ # Claude Code skill links — regenerated per-machine by `saasaloy init` and `saasaloy add`.
28
+ # The real skill files are committed under .agents/skills/.
29
+ .claude/skills/
30
+
31
+ # Turborepo task cache
32
+ .turbo
@@ -0,0 +1 @@
1
+ pnpm exec commitlint --edit "$1"
@@ -0,0 +1 @@
1
+ pnpm exec lint-staged
@@ -0,0 +1,31 @@
1
+ # Prettier does not read .gitignore, so anything git-ignored that Prettier would
2
+ # otherwise walk has to be repeated here. oxlint needs none of this — it honours
3
+ # .gitignore already.
4
+
5
+ # Markdown is deliberately not formatted. Ultracite's Prettier config sets
6
+ # `proseWrap: "never"`, which collapses every hand-wrapped paragraph — including the
7
+ # AGENTS.md and CLAUDE.md your agents read — into one line each.
8
+ **/*.md
9
+
10
+ # Generated by pnpm, not hand-edited.
11
+ pnpm-lock.yaml
12
+
13
+ # Saasaloy's own state, written by the CLI. `JSON.stringify(…, 2)` always expands
14
+ # arrays; Prettier would collapse the short ones and every `saasaloy add` would then
15
+ # leave `pnpm lint` red until you reformatted files you do not edit by hand.
16
+ saasaloy.json
17
+ saasaloy-lock.json
18
+ .saasaloy/
19
+
20
+ # Wrangler configs are patched surgically by `saasaloy add` (jsonc-parser rewrites only
21
+ # the touched region, so the comments Cloudflare configs rely on survive). Prettier
22
+ # wants trailing commas and collapsed short arrays in the inserted region, which the
23
+ # patcher does not emit — leaving `pnpm lint` red after every binding a module adds.
24
+ **/wrangler.jsonc
25
+
26
+ # Build output and dependencies.
27
+ **/node_modules
28
+ **/dist
29
+ **/.turbo
30
+ **/.astro
31
+ **/.wrangler
@@ -0,0 +1,9 @@
1
+ {
2
+ "seedFiles": [
3
+ "DESIGN.md",
4
+ "README.md",
5
+ "saasaloy.json",
6
+ "apps/web/src/pages/index.astro",
7
+ "packages/ui/src/content/landing.ts"
8
+ ]
9
+ }
@@ -0,0 +1,61 @@
1
+ // @ts-check
2
+ import { fileURLToPath } from "node:url";
3
+ import cloudflare from "@astrojs/cloudflare";
4
+ import react from "@astrojs/react";
5
+ import tailwindcss from "@tailwindcss/vite";
6
+ import { defineConfig } from "astro/config";
7
+
8
+ // Static output — the marketing site is content-first, and every page here is
9
+ // prerendered at build time. `output: "static"` is written out rather than left to the
10
+ // default so the next person changing this file has to mean it.
11
+ //
12
+ // The Cloudflare adapter is registered anyway, and src/pages/500.astro is why. Astro
13
+ // only treats a 500 page as an error handler when an adapter is present; without one it
14
+ // is an ordinary page nothing ever routes to. The adapter also means the first page a
15
+ // module drops in with `prerender = false` runs on demand with no config change and
16
+ // inherits that same 500 screen.
17
+ //
18
+ // Registering it costs no Worker while everything is prerendered: the adapter builds in
19
+ // assets-only mode, emits no server entry, and moves the site to dist/client. That move
20
+ // is why wrangler.jsonc points `assets.directory` there — read its comment before you
21
+ // change either file.
22
+ //
23
+ // The React integration ships in the base template itself (not per-feature) — every
24
+ // downstream module (waitlist, admin, ui components) needs `.tsx` islands sooner or
25
+ // later, so it's set up once here rather than patched in repeatedly.
26
+ export default defineConfig({
27
+ site: "https://example.com",
28
+ output: "static",
29
+ // Sessions off. Left unset, the Cloudflare adapter reads this key, decides sessions are
30
+ // wanted, and writes a `SESSION` KV namespace binding with no id into the config it
31
+ // generates at dist/client/wrangler.json — which is the config `wrangler deploy` actually
32
+ // reads (see wrangler.jsonc). A prerendered marketing site stores no session, so that
33
+ // would be a namespace to provision for nothing. Note this is Astro's own `session`, not
34
+ // an adapter option; passing it to `cloudflare()` below does nothing.
35
+ session: false,
36
+ // `imageService: "compile"` optimises images during the build and serves the results
37
+ // as plain assets. The adapter's default would reach for Cloudflare's IMAGES binding
38
+ // at runtime, which needs a Worker this site does not have.
39
+ adapter: cloudflare({ imageService: "compile" }),
40
+ integrations: [react()],
41
+ // Fixed dev port. Every cross-origin consumer in this repo — the api Worker's CORS
42
+ // allowlist, auth's `trustedOrigins`, the waitlist form's `PUBLIC_API_URL` fallback —
43
+ // hardcodes the localhost dev origins, so the port cannot be allowed to drift.
44
+ // `strictPort` makes a busy port a loud failure instead of a silent +1 that turns
45
+ // into a mystery CORS rejection. web is 3000, api is 4000 (see apps/api).
46
+ server: { port: 3000 },
47
+ vite: {
48
+ // Tailwind 4 is a Vite plugin, not an Astro integration — `@astrojs/tailwind` is EOL
49
+ // and never supported v4. The theme itself (tokens, @source globs) lives in
50
+ // packages/ui/src/styles/globals.css, which Layout.astro imports via @repo/ui.
51
+ plugins: [tailwindcss()],
52
+ server: { strictPort: true },
53
+ resolve: {
54
+ // `@web` mirrors saasaloy.json's alias of the same name (apps/web/src) — that
55
+ // alias only drives the CLI's file-placement when a module's files[] target
56
+ // `@web/...`; this is what makes the dropped source's own `@web/...` imports
57
+ // (e.g. a dropped page importing @web/components/*) actually resolve.
58
+ alias: { "@web": fileURLToPath(new URL("src", import.meta.url)) },
59
+ },
60
+ },
61
+ });
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@repo/web",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "astro dev",
8
+ "build": "astro build",
9
+ "clean": "rimraf dist .astro .wrangler",
10
+ "preview": "astro preview",
11
+ "deploy": "wrangler deploy"
12
+ },
13
+ "dependencies": {
14
+ "astro": "7.3.1",
15
+ "@astrojs/cloudflare": "14.3.0",
16
+ "@astrojs/react": "6.0.5",
17
+ "react": "19.2.8",
18
+ "react-dom": "19.2.8",
19
+ "@repo/ui": "workspace:*"
20
+ },
21
+ "devDependencies": {
22
+ "@tailwindcss/vite": "4.3.3",
23
+ "@types/node": "26.4.1",
24
+ "@types/react": "19.2.18",
25
+ "@types/react-dom": "19.2.5",
26
+ "rimraf": "6.1.3",
27
+ "tailwindcss": "4.3.3",
28
+ "wrangler": "4.129.0"
29
+ }
30
+ }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="{{PROJECT_NAME}}">
2
+ <rect width="32" height="32" rx="7" fill="#111827" />
3
+ <path d="M11 21.5c1.3 1 2.9 1.5 4.8 1.5 3.4 0 5.4-1.6 5.4-4 0-2.2-1.4-3.3-4.5-4-2.4-.5-3.1-1-3.1-1.9 0-1 .9-1.7 2.5-1.7 1.4 0 2.7.4 3.8 1.2l1.4-2.5C19.9 9.4 18.3 9 16.4 9c-3.2 0-5.2 1.7-5.2 4 0 2.3 1.5 3.4 4.6 4.1 2.3.5 3 1 3 1.9 0 1-1 1.6-2.6 1.6-1.6 0-3-.5-4.1-1.4z" fill="#f9fafb" />
4
+ </svg>
@@ -0,0 +1,52 @@
1
+ ---
2
+ // The one document shell for every page. It owns <html>/<head>/<body> and the single
3
+ // import of the shared theme, so a page is only ever its own content — no page may
4
+ // duplicate the shell or import globals.css again.
5
+ import "@repo/ui/globals.css";
6
+ import { THEME_INIT_SCRIPT } from "@repo/ui/lib/theme";
7
+
8
+ interface Props {
9
+ title: string;
10
+ description?: string;
11
+ }
12
+
13
+ const { title, description } = Astro.props;
14
+ ---
15
+
16
+ <!doctype html>
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="utf-8" />
20
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
21
+ <title>{title}</title>
22
+ {
23
+ /*
24
+ Explicit icon link, not the implicit /favicon.ico lookup. Without a link the
25
+ browser guesses /favicon.ico, and Chrome tags that guess `Sec-Fetch-Site:
26
+ cross-site`, which the Astro dev server blocks with a `[router]` warning
27
+ (astro/dist/vite-plugin-astro-server/sec-fetch.js). A declared same-origin
28
+ link never gets that treatment. Replace public/favicon.svg with the project mark.
29
+ */
30
+ }
31
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
32
+ {description && <meta name="description" content={description} />}
33
+ {
34
+ /*
35
+ Pre-paint theme resolution. This runs synchronously in <head>, BEFORE the browser
36
+ paints, so a dark-mode visitor never sees a white flash. `is:inline` keeps Astro
37
+ from bundling it into a deferred module, which would defeat the whole point —
38
+ `<script type="module">` is deferred by specification and always runs too late.
39
+ The script body is @repo/ui's THEME_INIT_SCRIPT, imported above and baked into
40
+ this HTML at build time, so the layout holds no copy of it: it reads the stored
41
+ choice, falls back to the OS preference, and also drives every
42
+ `[data-theme-toggle]` on the page through one delegated listener. That is why
43
+ @repo/ui/blocks/theme-toggle needs no client directive. Do not inline a second
44
+ resolver here, and do not turn this into an import.
45
+ */
46
+ }
47
+ <script is:inline set:html={THEME_INIT_SCRIPT} />
48
+ </head>
49
+ <body class="bg-background text-foreground min-h-dvh font-sans antialiased">
50
+ <slot />
51
+ </body>
52
+ </html>
@@ -0,0 +1,24 @@
1
+ ---
2
+ // The wrong-path page. Astro prerenders it to dist/client/404.html, and wrangler.jsonc's
3
+ // `assets.not_found_handling: "404-page"` is what makes Cloudflare serve it (with a real
4
+ // 404 status) for an address that matches no asset and no route.
5
+ //
6
+ // No markup of its own: every error screen in this repo is @repo/ui's ErrorState, and
7
+ // every word is @repo/ui/content/errors. Change the copy there, not here.
8
+ //
9
+ // No `client:*` directive, deliberately. ErrorState is static, so this page ships zero
10
+ // JavaScript for it.
11
+ import { siteName } from "@repo/ui";
12
+ import { ErrorState } from "@repo/ui/blocks/error-state";
13
+ import { errors } from "@repo/ui/content/errors";
14
+ import Layout from "../layouts/Layout.astro";
15
+ ---
16
+
17
+ <Layout title={`${errors.notFound.title} — ${siteName}`}>
18
+ <ErrorState
19
+ code={errors.notFound.code}
20
+ title={errors.notFound.title}
21
+ description={errors.notFound.description}
22
+ primaryAction={{ label: errors.notFound.homeLabel, href: "/" }}
23
+ />
24
+ </Layout>
@@ -0,0 +1,33 @@
1
+ ---
2
+ // The server-failure page. Astro serves it when an on-demand page throws, which the
3
+ // Cloudflare adapter registered in astro.config.mjs is what makes possible.
4
+ //
5
+ // Nothing in the base template renders on demand today, so nothing here can trigger it.
6
+ // That is the point: the first page a module drops in with `prerender = false` inherits
7
+ // this screen instead of a stack trace.
8
+ //
9
+ // Prerendered, and it reads nothing from the failed request — no `Astro.props.error`, no
10
+ // status text, no path. A 500 page renders inside a request that already went wrong, so
11
+ // anything it reports from that request is a leak waiting to happen, and the visitor can
12
+ // do nothing with the detail anyway.
13
+ //
14
+ // That also rules out a retry control. A retry has to know which URL failed, and this
15
+ // page is built once at build time with no request in hand — a "try again" that reloads
16
+ // /500 would be a lie. The one action is the home page; the browser's own reload button
17
+ // is the real retry.
18
+ //
19
+ // No `client:*` directive: ErrorState is static and ships no JavaScript.
20
+ import { siteName } from "@repo/ui";
21
+ import { ErrorState } from "@repo/ui/blocks/error-state";
22
+ import { errors } from "@repo/ui/content/errors";
23
+ import Layout from "../layouts/Layout.astro";
24
+ ---
25
+
26
+ <Layout title={`${errors.serverFailure.title} — ${siteName}`}>
27
+ <ErrorState
28
+ code={errors.serverFailure.code}
29
+ title={errors.serverFailure.title}
30
+ description={errors.serverFailure.description}
31
+ primaryAction={{ label: errors.serverFailure.homeLabel, href: "/" }}
32
+ />
33
+ </Layout>
@@ -0,0 +1,61 @@
1
+ ---
2
+ import { siteName } from "@repo/ui";
3
+ import { Cta } from "@repo/ui/blocks/cta";
4
+ import { Faq } from "@repo/ui/blocks/faq";
5
+ import { FeatureGrid } from "@repo/ui/blocks/feature-grid";
6
+ import { Footer } from "@repo/ui/blocks/footer";
7
+ import { Hero } from "@repo/ui/blocks/hero";
8
+ import { Navbar } from "@repo/ui/blocks/navbar";
9
+ import { PricingTable } from "@repo/ui/blocks/pricing-table";
10
+ import { ThemeToggle } from "@repo/ui/blocks/theme-toggle";
11
+ import { landing } from "@repo/ui/content/landing";
12
+ import { interpolate } from "@repo/ui/lib/interpolate";
13
+ import Layout from "../layouts/Layout.astro";
14
+
15
+ // This page carries no copy of its own — the tab title and meta description come from
16
+ // landing.meta.*, which spells the brand as a `{siteName}` token. Every block reads its
17
+ // own words from the same file (packages/ui/src/content/landing.ts).
18
+
19
+ // This page composes itself from explicit imports and nothing else. A module that ships
20
+ // UI writes a block into packages/ui/src/blocks/ plus a small island under
21
+ // src/components/ that feeds it, and then tells you to place it here; the module never
22
+ // puts itself on the page. Read the module's own skill for the import line and where it
23
+ // suggests the component goes, then decide the position yourself.
24
+ ---
25
+
26
+ {
27
+ /*
28
+ The page is composed from @repo/ui/blocks/*, each reached by its own subpath export.
29
+ Blocks render to static HTML by default; only the three that need browser state carry
30
+ a client directive, and each one is deliberate:
31
+ - Navbar client:idle — mobile menu toggle, above the fold, so hydrate when
32
+ the browser goes idle rather than waiting for scroll.
33
+ - PricingTable client:visible — monthly/annual toggle, below the fold.
34
+ - Faq client:visible — accordion, below the fold.
35
+ Hero, FeatureGrid, Cta and Footer take no directive and ship no JavaScript at all.
36
+ ThemeToggle is the interesting case: it is interactive yet takes no directive either,
37
+ because its behaviour comes from the pre-paint inline script Layout.astro emits, not
38
+ from React. Giving it a `client:*` would ship a hydration runtime for a button that
39
+ has no onClick. It sits here rather than inside Navbar so that navbar.tsx stays the
40
+ landing blocks' file alone, and it carries its own positioning: fixed to the corner,
41
+ clear of the sticky header's own controls at every width.
42
+ Never reach for a blanket `client:load` here — it would hydrate the whole page.
43
+ */
44
+ }
45
+ <Layout
46
+ title={interpolate(landing.meta.title, { siteName })}
47
+ description={interpolate(landing.meta.description, { siteName })}
48
+ >
49
+ <Navbar client:idle siteName={siteName} />
50
+ <ThemeToggle
51
+ className="border-border/60 bg-background/80 fixed right-4 bottom-4 z-50 shadow-sm backdrop-blur"
52
+ />
53
+ <main>
54
+ <Hero siteName={siteName} />
55
+ <FeatureGrid />
56
+ <PricingTable client:visible />
57
+ <Faq client:visible />
58
+ <Cta siteName={siteName} />
59
+ </main>
60
+ <Footer siteName={siteName} />
61
+ </Layout>
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { siteName } from "@repo/ui";
3
+ import Layout from "../layouts/Layout.astro";
4
+ ---
5
+
6
+ <Layout title={`Privacy Policy — ${siteName}`}>
7
+ <main class="mx-auto my-16 max-w-2xl px-6 leading-[1.6]">
8
+ <h1 class="mb-6 text-3xl font-bold tracking-tight">Privacy Policy</h1>
9
+ <p class="mb-4">
10
+ Placeholder privacy policy for {siteName}. Replace with your own before
11
+ launch.
12
+ </p>
13
+ <p><a href="/" class="hover:underline">← Home</a></p>
14
+ </main>
15
+ </Layout>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { siteName } from "@repo/ui";
3
+ import Layout from "../layouts/Layout.astro";
4
+ ---
5
+
6
+ <Layout title={`Terms of Service — ${siteName}`}>
7
+ <main class="mx-auto my-16 max-w-2xl px-6 leading-[1.6]">
8
+ <h1 class="mb-6 text-3xl font-bold tracking-tight">Terms of Service</h1>
9
+ <p class="mb-4">
10
+ Placeholder terms for {siteName}. Replace with your own before launch.
11
+ </p>
12
+ <p><a href="/" class="hover:underline">← Home</a></p>
13
+ </main>
14
+ </Layout>