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