create-ailk 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +54 -5
- package/dist/copy.d.ts +14 -0
- package/dist/copy.js +38 -1
- package/dist/lib/extract-module-bundle.d.ts +20 -1
- package/dist/lib/extract-module-bundle.js +64 -5
- package/dist/lib/module-license-gate.d.ts +6 -1
- package/dist/lib/module-license-gate.js +6 -1
- package/dist/lib/pro-callout.js +1 -1
- package/dist/modules/commerce/0.6.0.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
- package/dist/modules/workspace/0.6.0.tar.gz +0 -0
- package/dist/programmatic.js +14 -10
- package/package.json +3 -3
- package/site-templates/marketing-site/DESIGN.md +148 -0
- package/site-templates/marketing-site-free/DESIGN.md +148 -0
- package/site-templates/minimalism-site/DESIGN.md +148 -0
- package/site-templates/saas-product/DESIGN.md +148 -0
- package/site-templates/simple-service/DESIGN.md +148 -0
- package/templates/CLAUDE.md +2 -3
- package/templates/DESIGN.md +155 -0
- package/templates/README.md +42 -8
- package/templates/apps/web/PRODUCT.md +86 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
- package/templates/apps/web/lib/site-brand.tsx +4 -8
- package/templates/apps/web/middleware.ts +1 -1
- package/templates/apps/web/src/lib/commerce-sections.ts +35 -3
- package/templates/content/_site.mdx +18 -3
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +3 -2
- package/versions.json +33 -33
- package/templates/apps/web/public/logo-dark.svg +0 -21
- package/templates/apps/web/public/logo-light.svg +0 -21
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: AI Launch Kit base scaffold
|
|
3
|
+
description: The foundation every AI Launch Kit site starts from — pure white, black ink, black hairline rules, system type, generous pacing.
|
|
4
|
+
colors:
|
|
5
|
+
bg: "#ffffff"
|
|
6
|
+
fg: "#000000"
|
|
7
|
+
surface: "#ffffff"
|
|
8
|
+
surface-secondary: "#ffffff"
|
|
9
|
+
border: "#000000"
|
|
10
|
+
text-muted: "#333333"
|
|
11
|
+
brand: "#000000"
|
|
12
|
+
fg-on-brand: "#ffffff"
|
|
13
|
+
accent: "#000000"
|
|
14
|
+
bg-dark: "#000000"
|
|
15
|
+
fg-dark: "#ffffff"
|
|
16
|
+
border-dark: "#ffffff"
|
|
17
|
+
brand-dark: "#ffffff"
|
|
18
|
+
typography:
|
|
19
|
+
display:
|
|
20
|
+
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, sans-serif"
|
|
21
|
+
fontSize: "6rem"
|
|
22
|
+
fontWeight: 400
|
|
23
|
+
lineHeight: 1
|
|
24
|
+
letterSpacing: "-0.05em"
|
|
25
|
+
heading-section:
|
|
26
|
+
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, sans-serif"
|
|
27
|
+
fontSize: "2.25rem"
|
|
28
|
+
fontWeight: 400
|
|
29
|
+
lineHeight: 1.25
|
|
30
|
+
letterSpacing: "-0.05em"
|
|
31
|
+
heading-item:
|
|
32
|
+
fontSize: "1.25rem"
|
|
33
|
+
fontWeight: 600
|
|
34
|
+
lineHeight: 1.375
|
|
35
|
+
body:
|
|
36
|
+
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, sans-serif"
|
|
37
|
+
fontSize: "1.125rem"
|
|
38
|
+
fontWeight: 400
|
|
39
|
+
lineHeight: 1.5
|
|
40
|
+
eyebrow:
|
|
41
|
+
fontSize: "0.875rem"
|
|
42
|
+
fontWeight: 600
|
|
43
|
+
lineHeight: 1.4
|
|
44
|
+
letterSpacing: "0.05em"
|
|
45
|
+
rounded:
|
|
46
|
+
button: "0.25rem"
|
|
47
|
+
card: "0.75rem"
|
|
48
|
+
spacing:
|
|
49
|
+
section: "4rem"
|
|
50
|
+
card: "1.5rem"
|
|
51
|
+
grid: "1.5rem"
|
|
52
|
+
container: "1280px"
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
<!--
|
|
56
|
+
Source template: _base (the AI Launch Kit base scaffold)
|
|
57
|
+
Version: create-ailk {{CREATE_AILK_VERSION}}
|
|
58
|
+
|
|
59
|
+
This file describes the visual system this scaffold ships with. The impeccable
|
|
60
|
+
design skill reads it at the start of every design session, so an agent
|
|
61
|
+
building a new page stays on-system.
|
|
62
|
+
|
|
63
|
+
A site template (installed with `--template <name>`) replaces this file with
|
|
64
|
+
its own DESIGN.md — same path, with a header naming the template it came
|
|
65
|
+
from. Edit whichever one your site has; the header tells you which.
|
|
66
|
+
|
|
67
|
+
The tokens above describe what the scaffold RENDERS today. Colours come from
|
|
68
|
+
content/_site.mdx (`theme.colors`); type, radius and spacing are platform
|
|
69
|
+
defaults a site cannot override yet — see "Where this is heading" below.
|
|
70
|
+
-->
|
|
71
|
+
|
|
72
|
+
# AI Launch Kit base scaffold
|
|
73
|
+
|
|
74
|
+
## Overview
|
|
75
|
+
|
|
76
|
+
The foundation reads like a well-made wireframe: nothing on the page but the words, black on white, with rules where other sites use shadows and colour. It is the look of a site whose owner has not chosen a brand yet and does not pretend otherwise — every placeholder reads as a placeholder, and the structure of the argument is what the visitor sees. A brand is applied on top of it; it is not one.
|
|
77
|
+
|
|
78
|
+
Every page is composed from the AI Launch Kit component library (`@working-theory/ui`). Nothing is hand-built: a page is a list of registered sections, each with a bounded set of variants. Each section is one full-width band with a lot of air around it.
|
|
79
|
+
|
|
80
|
+
## Colors
|
|
81
|
+
|
|
82
|
+
### Primary
|
|
83
|
+
|
|
84
|
+
**Black** (`brand` `#000000`) fills the primary button; its label is white. The secondary button is white with a black hairline and black label.
|
|
85
|
+
|
|
86
|
+
### Neutral
|
|
87
|
+
|
|
88
|
+
Pure white ground (`bg` `#ffffff`), pure black ink for headings (`fg` `#000000`), and near-black `#333333` for body copy — well above AA on white. **Borders are black** (`#000000`): card outlines, the rule under the navigation, the rules between sections and the rule above the footer are all 1px black lines. Cards are white, not tinted (`surface-secondary` is white too). There is no accent colour — `accent` is black.
|
|
89
|
+
|
|
90
|
+
Dark mode inverts: black ground, white ink, white rules, white primary button with black label.
|
|
91
|
+
|
|
92
|
+
### Named Rules
|
|
93
|
+
|
|
94
|
+
- **Rules, not fills.** A container is drawn with a black hairline on the white ground. Grey fills are reserved for image placeholders.
|
|
95
|
+
|
|
96
|
+
## Typography
|
|
97
|
+
|
|
98
|
+
System sans throughout; no web font.
|
|
99
|
+
|
|
100
|
+
### Hierarchy
|
|
101
|
+
|
|
102
|
+
| Role | Size | Weight | Notes |
|
|
103
|
+
| --------------- | -------- | ------ | --------------------------------------------------------------------------- |
|
|
104
|
+
| Hero headline | 6rem | 400 | Tracking -0.05em, line-height 1 |
|
|
105
|
+
| Section heading | 2.25rem | 400 | Tracking -0.05em — light and tight, not bold |
|
|
106
|
+
| Item heading | 1.25rem | 600 | Card, feature and step titles |
|
|
107
|
+
| Body | 1.125rem | 400 | `#333333`, line-height 1.5 |
|
|
108
|
+
| Section label | 0.875rem | 600 | Uppercase, +0.05em tracking today — the small label above a section heading |
|
|
109
|
+
|
|
110
|
+
Measured from the rendered scaffold (1440px wide), not transcribed from tokens.
|
|
111
|
+
|
|
112
|
+
The small label above a section heading is **kept** as part of this foundation's voice (operator-disposed 2026-09-22) and is headed toward the treatment below.
|
|
113
|
+
|
|
114
|
+
## Layout
|
|
115
|
+
|
|
116
|
+
Content sits in a 1280px container. Sections stack at the platform's 4rem rhythm today; a few sections choose a compact (2.5rem) or spacious (6rem) step internally. Card grids gap at 1.5rem. Breakpoints: 320 / 768 / 1024 / 1440.
|
|
117
|
+
|
|
118
|
+
## Shapes
|
|
119
|
+
|
|
120
|
+
Two radii today: buttons at 0.25rem, cards and outlined boxes at 0.75rem. Headed toward square corners — see below.
|
|
121
|
+
|
|
122
|
+
## Components
|
|
123
|
+
|
|
124
|
+
### Buttons
|
|
125
|
+
|
|
126
|
+
Primary: black fill, white label, 0.25rem corners. Secondary: transparent with a black 1px outline and black label. Both are the library `Button`.
|
|
127
|
+
|
|
128
|
+
### Cards / Containers
|
|
129
|
+
|
|
130
|
+
White panels with a black 1px outline, 0.75rem corners, `card` (1.5rem) padding. The one exception today: the "how it works" steps render as dark filled cards (see below).
|
|
131
|
+
|
|
132
|
+
### Navigation
|
|
133
|
+
|
|
134
|
+
Wordmark or logo left, page links, then an outlined and a filled button at the far right. A black hairline separates it from the page. An optional announcement banner sits above it.
|
|
135
|
+
|
|
136
|
+
## Where this is heading
|
|
137
|
+
|
|
138
|
+
The foundation's pinned direction is the pacing of a classic wireframe marketing kit — `standardissue.webflow.io` is the reference for rhythm, type scale, spacing density and section pacing only; no image, copy or code is taken from it. Six parts of that direction cannot be expressed through a site's theme today, so they wait on a platform change rather than being hand-coded. Until they land, this file describes the rendered site above, not these targets.
|
|
139
|
+
|
|
140
|
+
| Target | Reference value | Blocked by |
|
|
141
|
+
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| Square corners on buttons, cards and inputs | radius 0 | A site's `theme:` block accepts colours only — no radius |
|
|
143
|
+
| Bold, tighter headings | hero ~3.6rem and section headings ~2.9rem, both at weight 700 with tracking about -0.02em (today 6rem and 2.25rem at weight 400) | No per-site type-role override |
|
|
144
|
+
| Generous section pacing | about 10rem between sections, not 4rem | No per-site section-spacing override; `spacious` (6rem) is not author-selectable |
|
|
145
|
+
| Section label in sentence case, bold, no tracking | "Core Features", not "CORE FEATURES" | The eyebrow role bakes in uppercase and tracking |
|
|
146
|
+
| Whitespace between sections, not rules | no rule between bands | Section dividers share the one `border` colour with card outlines, so black outlines make black dividers |
|
|
147
|
+
| Outlined steps in "how it works" | three plain outlined columns | The steps section renders dark filled cards; no outlined variant |
|
|
148
|
+
|
|
149
|
+
## Do's and Don'ts
|
|
150
|
+
|
|
151
|
+
- **Do** keep every page built from registered sections. If a section you need does not exist, request it rather than writing one-off markup.
|
|
152
|
+
- **Do** change colours in `content/_site.mdx` `theme.colors`; they reach every section.
|
|
153
|
+
- **Don't** add a colour accent. The foundation's one idea is black on white; a brand pass adds colour deliberately, through the theme.
|
|
154
|
+
- **Don't** replace a black rule with a shadow or a grey fill.
|
|
155
|
+
- **Don't** hard-code the "heading toward" values above into a page. They arrive through the platform, or not at all.
|
package/templates/README.md
CHANGED
|
@@ -29,7 +29,7 @@ pnpm preflight # architecture + types + lint + build
|
|
|
29
29
|
pnpm build # production build
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
> Requires Node.js 20+ and `pnpm
|
|
32
|
+
> Requires Node.js 20+ and `pnpm` — that is the whole prerequisite list. No database is needed to run or build this site (see [When you need a database](#when-you-need-a-database)), and no vendor API keys are required for first boot — Stripe, Anthropic, and email-provider integrations are opt-in. `pnpm install` generates the Prisma client for you; there is no generator step to remember.
|
|
33
33
|
|
|
34
34
|
## Content
|
|
35
35
|
|
|
@@ -63,16 +63,50 @@ Apply `myTheme` via the provider you use to wrap children. The full extension su
|
|
|
63
63
|
|
|
64
64
|
Copy `.env.example` to `.env` and fill in only what you need. Most variables are optional. A complete reference:
|
|
65
65
|
|
|
66
|
-
| Variable | Purpose
|
|
67
|
-
| ---------------------------------- |
|
|
68
|
-
| `AILK_LICENSE_KEY` | Optional. JWT issued by ailaunchkit.ai; verified locally to unlock Pro features. Get one at <https://ailaunchkit.ai/
|
|
69
|
-
| `NEXT_PUBLIC_STREAMING_AI_ENABLED` | Optional. Set to `true` to enable the opt-in streaming AI assist UI. Off by default.
|
|
70
|
-
| `ANTHROPIC_API_KEY` | Required only when `NEXT_PUBLIC_STREAMING_AI_ENABLED=true`. Used by the AI assist surface only.
|
|
71
|
-
| `AILK_LOCAL_LEAD_CAPTURE` | Optional. When `true`, leads from the contact form are written to a local file instead of an API.
|
|
72
|
-
| `AILK_LEADS_API_URL` | Optional. Override the upstream that receives lead-capture submissions.
|
|
66
|
+
| Variable | Purpose |
|
|
67
|
+
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
68
|
+
| `AILK_LICENSE_KEY` | Optional. JWT issued by ailaunchkit.ai; verified locally to unlock Pro features. Get one at <https://ailaunchkit.ai/pricing>. |
|
|
69
|
+
| `NEXT_PUBLIC_STREAMING_AI_ENABLED` | Optional. Set to `true` to enable the opt-in streaming AI assist UI. Off by default. |
|
|
70
|
+
| `ANTHROPIC_API_KEY` | Required only when `NEXT_PUBLIC_STREAMING_AI_ENABLED=true`. Used by the AI assist surface only. |
|
|
71
|
+
| `AILK_LOCAL_LEAD_CAPTURE` | Optional. When `true`, leads from the contact form are written to a local file instead of an API. |
|
|
72
|
+
| `AILK_LEADS_API_URL` | Optional. Override the upstream that receives lead-capture submissions. |
|
|
73
73
|
|
|
74
74
|
See [`.env.example`](./.env.example) for the full template with comments. Per-app overrides (e.g. `apps/api/.env`) are honored alongside the root `.env`.
|
|
75
75
|
|
|
76
|
+
## When you need a database
|
|
77
|
+
|
|
78
|
+
**A free marketing site needs no database at all.** `pnpm install`, `pnpm dev` and `pnpm build` all run green on an empty machine with no Postgres, no `DATABASE_URL` and no `.env` — the pages render from `content/*.mdx`, and the Prisma client is generated for you by `database`'s `postinstall` so the workspace type-checks and builds either way.
|
|
79
|
+
|
|
80
|
+
You need Postgres once you turn on one of these four:
|
|
81
|
+
|
|
82
|
+
| Surface | What it stores |
|
|
83
|
+
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| **Auth** (sign-in, sessions, accounts) | users, sessions, accounts |
|
|
85
|
+
| **Flows** — `/save` and `/checkout` | a visitor's in-progress flow state, so they can resume or pay later |
|
|
86
|
+
| **Lead capture in DB mode** | submitted leads. The default file mode (`AILK_LOCAL_LEAD_CAPTURE=true`) writes to disk instead and needs nothing. |
|
|
87
|
+
| **`CONTENT_ADAPTER=database`** | pages authored in the Artifact store rather than in `content/*.mdx` |
|
|
88
|
+
|
|
89
|
+
### One command to get one
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
docker run --name ailk-postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:17
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Point the app at it in `.env` (both variables, same URL for local work — the second is the direct connection `prisma migrate` uses):
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
|
99
|
+
DATABASE_URL_NON_POOLED=postgresql://postgres:postgres@localhost:5432/postgres
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Then create the tables:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pnpm db:migrate:deploy
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
That is the whole setup. `pnpm db:studio` opens a browser UI over the data, and `pnpm db:seed` loads the sample rows. Turning the database off again is just unsetting the two variables — the marketing pages never read it.
|
|
109
|
+
|
|
76
110
|
## AI assist (optional)
|
|
77
111
|
|
|
78
112
|
The site ships with an opt-in streaming AI assist. It is **off by default**. To turn it on, set `NEXT_PUBLIC_STREAMING_AI_ENABLED=true` in `.env` and provide an `ANTHROPIC_API_KEY`. The assist runs server-side so the API key never reaches the browser. With both unset, the chat UI is hidden and no AI traffic flows.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Product
|
|
2
|
+
|
|
3
|
+
<!-- impeccable:product-schema 1 -->
|
|
4
|
+
|
|
5
|
+
<!--
|
|
6
|
+
This file is the product context the impeccable design skill reads before
|
|
7
|
+
every design decision on this app (apps/web). It holds facts about your
|
|
8
|
+
product, not the look (that is DESIGN.md) and not the pitch (that is your
|
|
9
|
+
strategy docs).
|
|
10
|
+
|
|
11
|
+
Platform, Stack and the accessibility floor below are facts about this
|
|
12
|
+
AI Launch Kit scaffold. Every other section is empty until it has a real
|
|
13
|
+
source: each one names the strategy doc it will come from. Until then, treat
|
|
14
|
+
it as unknown — never invent a customer, a number or a capability to fill it.
|
|
15
|
+
|
|
16
|
+
Each section carries a `source:` marker. `hand` means nothing generates the
|
|
17
|
+
section, so edit it directly (or answer `/impeccable init`'s questions).
|
|
18
|
+
-->
|
|
19
|
+
|
|
20
|
+
## Platform
|
|
21
|
+
|
|
22
|
+
<!-- source: hand -->
|
|
23
|
+
|
|
24
|
+
web
|
|
25
|
+
|
|
26
|
+
## Stack
|
|
27
|
+
|
|
28
|
+
<!-- source: hand -->
|
|
29
|
+
|
|
30
|
+
Next.js 16 (App Router) and React 19, in TypeScript, styled with Tailwind CSS 4. Pages are MDX content files rendered through the AI Launch Kit component library (`@working-theory/ui`, `@working-theory/theme`, `@working-theory/page-renderer`); translations through next-intl. pnpm workspace. Exact versions: `apps/web/package.json`.
|
|
31
|
+
|
|
32
|
+
## Users
|
|
33
|
+
|
|
34
|
+
<!-- source: hand -->
|
|
35
|
+
|
|
36
|
+
[Not yet set — comes from your strategy run: Messaging Canvas §3 Champion profile]
|
|
37
|
+
|
|
38
|
+
## Product Purpose
|
|
39
|
+
|
|
40
|
+
<!-- source: hand -->
|
|
41
|
+
|
|
42
|
+
[Not yet set — comes from your strategy run: ENTITY §1.4 Purpose]
|
|
43
|
+
|
|
44
|
+
## Positioning
|
|
45
|
+
|
|
46
|
+
<!-- source: hand -->
|
|
47
|
+
|
|
48
|
+
[Not yet set — comes from your strategy run: Messaging Canvas §1 Inherited from positioning]
|
|
49
|
+
|
|
50
|
+
## Operating Context
|
|
51
|
+
|
|
52
|
+
<!-- source: hand -->
|
|
53
|
+
|
|
54
|
+
[Not yet set — comes from your strategy run: Messaging Canvas §3 Champion profile, Reading context]
|
|
55
|
+
|
|
56
|
+
## Capabilities and Constraints
|
|
57
|
+
|
|
58
|
+
<!-- source: hand -->
|
|
59
|
+
|
|
60
|
+
[Not yet set — comes from your strategy run: OFFERINGS §4 Offering catalog]
|
|
61
|
+
|
|
62
|
+
## Brand Commitments
|
|
63
|
+
|
|
64
|
+
<!-- source: hand -->
|
|
65
|
+
|
|
66
|
+
[Not yet set — comes from your strategy run: VOICE §2 Voice anchor, and your brand_assets/ folder]
|
|
67
|
+
|
|
68
|
+
## Evidence on Hand
|
|
69
|
+
|
|
70
|
+
<!-- source: hand -->
|
|
71
|
+
|
|
72
|
+
[Not yet set — comes from your strategy run: Messaging Canvas §5 Pillars, the proof each pillar carries]
|
|
73
|
+
|
|
74
|
+
## Product Principles
|
|
75
|
+
|
|
76
|
+
<!-- source: hand -->
|
|
77
|
+
|
|
78
|
+
[Not yet set — comes from your strategy run: ENTITY § Product Principles]
|
|
79
|
+
|
|
80
|
+
## Accessibility & Inclusion
|
|
81
|
+
|
|
82
|
+
<!-- source: hand -->
|
|
83
|
+
|
|
84
|
+
The AI Launch Kit build audits every section component in its library against WCAG 2 Level A and AA (axe-core, plus its best-practice rules), on each section's stories. Smaller components are audited only where a story opts in, and your own pages are not audited automatically. Every text-on-background pair in the platform's default colors meets 4.5:1 contrast for normal text and 3:1 for large text, in light and dark mode. Colors you set yourself in `content/_site.mdx` are not checked automatically — keep them to the same ratios.
|
|
85
|
+
|
|
86
|
+
Commitments above this floor: [Not yet set — comes from your strategy run: ENTITY, accessibility commitments]
|
|
@@ -4,11 +4,28 @@
|
|
|
4
4
|
* This is the end-to-end claim the wiring makes — a site declares a theme and
|
|
5
5
|
* the built page renders in those colours — asserted on the actual repo file
|
|
6
6
|
* rather than a fixture, so deleting the `theme:` block would fail here.
|
|
7
|
+
*
|
|
8
|
+
* Every expectation is DERIVED from whichever `_site.mdx` the test reads, never
|
|
9
|
+
* hard-coded (#6460). This file is snapshotted into `create-ailk`'s
|
|
10
|
+
* `templates/`, so it runs against TWO different `_site.mdx` files: this
|
|
11
|
+
* monorepo's, and the scaffold's own. Hard-coded values made it a snapshot of
|
|
12
|
+
* one tree's palette masquerading as a round-trip test — green here, red in a
|
|
13
|
+
* scaffold whose theme differs, which is exactly what happened when the base
|
|
14
|
+
* scaffold moved to the black-on-white foundation. It also meant a CUSTOMER who
|
|
15
|
+
* took the file's own invitation to "declare only what you change" got a red
|
|
16
|
+
* suite for editing their own colours.
|
|
17
|
+
*
|
|
18
|
+
* Deriving keeps every claim the hard-coded form made — the mapping is applied,
|
|
19
|
+
* the values reach the CSS, and nothing else moves — while making all three
|
|
20
|
+
* true of any declared palette. Deleting the `theme:` block still fails, on the
|
|
21
|
+
* `toBeDefined()` below.
|
|
7
22
|
*/
|
|
8
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
24
|
import { tmpdir } from "node:os";
|
|
10
25
|
import { join } from "node:path";
|
|
11
26
|
|
|
27
|
+
import { parse as parseYaml } from "yaml";
|
|
28
|
+
|
|
12
29
|
import { PartialColorPaletteSchema } from "@working-theory/site-config/schema";
|
|
13
30
|
import { siteColorTokenMap, themeToCssVarsBothModes } from "@working-theory/theme";
|
|
14
31
|
|
|
@@ -26,6 +43,46 @@ function darkBlock(css: string): string {
|
|
|
26
43
|
return css.slice(css.indexOf(':root[data-theme="dark"]'));
|
|
27
44
|
}
|
|
28
45
|
|
|
46
|
+
/** The two modes, as the palette and the emitted CSS both name them. */
|
|
47
|
+
const MODES = ["light", "dark"] as const;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `theme.colors` block exactly as the `_site.mdx` under test declares it —
|
|
51
|
+
* raw SLOT names (`text`, `primary`), not token keys. Read with the same
|
|
52
|
+
* frontmatter parse `site-theme.ts` uses, so the test cannot pass against a
|
|
53
|
+
* file the implementation reads differently.
|
|
54
|
+
*/
|
|
55
|
+
function declaredColors(): Partial<Record<(typeof MODES)[number], Record<string, string>>> {
|
|
56
|
+
const raw = readFileSync(join(REPO_CONTENT_ROOT, "_site.mdx"), "utf8");
|
|
57
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
|
|
58
|
+
const parsed = parseYaml(frontmatter?.[1] ?? "") as
|
|
59
|
+
| { theme?: { colors?: Record<string, Record<string, string>> } }
|
|
60
|
+
| null;
|
|
61
|
+
return parsed?.theme?.colors ?? {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The CSS custom property a token key is emitted as — `fgOnBrand` →
|
|
66
|
+
* `--color-fg-on-brand`. The colon in every call site anchors the match, so
|
|
67
|
+
* `--color-surface` never collides with `--color-surface-secondary`.
|
|
68
|
+
*/
|
|
69
|
+
function cssVar(tokenKey: string): string {
|
|
70
|
+
return `--color-${tokenKey.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `{ [tokenKey]: value }` for one mode's declared slots, mapping applied. */
|
|
74
|
+
function declaredTokens(mode: (typeof MODES)[number]): Record<string, string> {
|
|
75
|
+
const out: Record<string, string> = {};
|
|
76
|
+
for (const [slot, value] of Object.entries(declaredColors()[mode] ?? {})) {
|
|
77
|
+
const tokenKey = siteColorTokenMap[slot as keyof typeof siteColorTokenMap];
|
|
78
|
+
// A slot with no token counterpart cannot reach the CSS at all —
|
|
79
|
+
// `siteThemeToBrandTokens` throws on it, which the schema's own tests
|
|
80
|
+
// cover. Nothing to assert about it here.
|
|
81
|
+
if (typeof tokenKey === "string") out[tokenKey] = value;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
29
86
|
describe("loadSiteBrandTokens — the real content/_site.mdx", () => {
|
|
30
87
|
const originalRoot = process.env.AILK_CONTENT_ROOT;
|
|
31
88
|
|
|
@@ -38,35 +95,93 @@ describe("loadSiteBrandTokens — the real content/_site.mdx", () => {
|
|
|
38
95
|
else process.env.AILK_CONTENT_ROOT = originalRoot;
|
|
39
96
|
});
|
|
40
97
|
|
|
41
|
-
it("translates the
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
98
|
+
it("translates every colour the file declares into its mapped token", () => {
|
|
99
|
+
const overlay = loadSiteBrandTokens();
|
|
100
|
+
// The `theme:` block exists and produced an overlay — the claim the
|
|
101
|
+
// hard-coded form made by naming values, kept without naming them.
|
|
102
|
+
expect(overlay).toBeDefined();
|
|
103
|
+
|
|
104
|
+
// `BrandTokenOverrides` is deliberately `Readonly<Record<string, unknown>>`
|
|
105
|
+
// — `@working-theory/theme` cannot name the overlay's shape without
|
|
106
|
+
// pinning it to one token set — so the test states the shape it asserts
|
|
107
|
+
// about. The hard-coded form sidestepped this by comparing whole objects.
|
|
108
|
+
const colors = (overlay?.["colors"] ?? {}) as Partial<
|
|
109
|
+
Record<(typeof MODES)[number], Record<string, string>>
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
let asserted = 0;
|
|
113
|
+
for (const mode of MODES) {
|
|
114
|
+
const expectedTokens = declaredTokens(mode);
|
|
115
|
+
// Exactly the declared slots, nothing invented and nothing dropped.
|
|
116
|
+
expect(colors[mode] ?? {}).toEqual(expectedTokens);
|
|
117
|
+
asserted += Object.keys(expectedTokens).length;
|
|
118
|
+
}
|
|
119
|
+
// Non-vacuity: a file that declared nothing would satisfy the loop above.
|
|
120
|
+
expect(asserted).toBeGreaterThan(0);
|
|
48
121
|
});
|
|
49
122
|
|
|
50
123
|
it("reaches the emitted CSS custom properties", () => {
|
|
51
124
|
const brand = loadSiteBrandTokens();
|
|
52
125
|
const css = themeToCssVarsBothModes(brand ? { brand } : {});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
126
|
+
const blockFor = { light: lightBlock(css), dark: darkBlock(css) };
|
|
127
|
+
|
|
128
|
+
let asserted = 0;
|
|
129
|
+
for (const mode of MODES) {
|
|
130
|
+
for (const [tokenKey, value] of Object.entries(declaredTokens(mode))) {
|
|
131
|
+
expect(blockFor[mode]).toContain(`${cssVar(tokenKey)}: ${value};`);
|
|
132
|
+
asserted += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
expect(asserted).toBeGreaterThan(0);
|
|
57
136
|
});
|
|
58
137
|
|
|
59
138
|
it("changes exactly the declared variables and nothing else", () => {
|
|
60
139
|
const brand = loadSiteBrandTokens();
|
|
61
140
|
const themed = themeToCssVarsBothModes(brand ? { brand } : {});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
141
|
+
|
|
142
|
+
// Rebuild the expectation from the DEFAULT CSS by overwriting, per mode
|
|
143
|
+
// block, only the variables this site declares. Anything the overlay
|
|
144
|
+
// touched beyond them makes the final equality fail — which is the whole
|
|
145
|
+
// point of this case.
|
|
146
|
+
const base = themeToCssVarsBothModes();
|
|
147
|
+
const rewritten = { light: lightBlock(base), dark: darkBlock(base) };
|
|
148
|
+
for (const mode of MODES) {
|
|
149
|
+
for (const [tokenKey, value] of Object.entries(declaredTokens(mode))) {
|
|
150
|
+
const pattern = new RegExp(`(${cssVar(tokenKey)}: )[^;]+;`);
|
|
151
|
+
// The variable must exist in the default block for the overwrite to
|
|
152
|
+
// mean anything — a replace with no target would weaken the equality
|
|
153
|
+
// below into a tautology. Presence is the right check rather than
|
|
154
|
+
// "the text changed": a site may legitimately declare a value that
|
|
155
|
+
// already MATCHES the default (the black-on-white base declares
|
|
156
|
+
// `bg: #ffffff`, which light mode already is), and that no-op is
|
|
157
|
+
// correct, not vacuous.
|
|
158
|
+
expect(rewritten[mode]).toMatch(pattern);
|
|
159
|
+
rewritten[mode] = rewritten[mode].replace(pattern, `$1${value};`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// `--color-surface-raised` is DERIVED, not independent: the emitter
|
|
163
|
+
// resolves it from `surfaceSecondary` in light mode and from `surface`
|
|
164
|
+
// in dark (`packages/theme/src/theme-to-css.ts`, and the rule is
|
|
165
|
+
// documented at `site-theme.ts`'s header). So a site that declares
|
|
166
|
+
// either of those moves it too, and the equality below has to account
|
|
167
|
+
// for it — re-derived here from whatever the block now carries rather
|
|
168
|
+
// than restated as a value, so the two cannot drift.
|
|
169
|
+
//
|
|
170
|
+
// Kept inside the total equality instead of excluded from it: this
|
|
171
|
+
// variable is SUPPOSED to move, and excluding it would stop the test
|
|
172
|
+
// noticing if it ever stopped moving.
|
|
173
|
+
const derivedFrom = mode === "light" ? "surfaceSecondary" : "surface";
|
|
174
|
+
const sourceValue = new RegExp(`${cssVar(derivedFrom)}: ([^;]+);`).exec(
|
|
175
|
+
rewritten[mode],
|
|
176
|
+
)?.[1];
|
|
177
|
+
expect(sourceValue).toBeDefined();
|
|
178
|
+
rewritten[mode] = rewritten[mode].replace(
|
|
179
|
+
/(--color-surface-raised: )[^;]+;/,
|
|
180
|
+
`$1${sourceValue};`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
expect(themed).toBe(rewritten.light + rewritten.dark);
|
|
70
185
|
});
|
|
71
186
|
|
|
72
187
|
it("treats a bare `theme:` key as no theme, rather than throwing", () => {
|
|
@@ -108,14 +108,10 @@ export const SITE_BASE_URL =
|
|
|
108
108
|
process.env.NEXT_PUBLIC_BASE_URL ?? siteFrontmatter?.baseUrl ?? FALLBACK_BASE_URL;
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
* Shared brand mark — the
|
|
112
|
-
* `
|
|
111
|
+
* Shared brand mark — the square logomark (the central brand library's
|
|
112
|
+
* `logomark/loop/icon.svg`). The Nav pairs it with `/wordmark.svg`.
|
|
113
|
+
* RSC-safe (no hooks, no client state).
|
|
113
114
|
*/
|
|
114
115
|
export function siteLogo(): ReactElement {
|
|
115
|
-
return
|
|
116
|
-
<>
|
|
117
|
-
<img src="/logo-light.svg" alt={SITE_NAME} height={28} className="block dark:hidden" />
|
|
118
|
-
<img src="/logo-dark.svg" alt={SITE_NAME} height={28} className="hidden dark:block" />
|
|
119
|
-
</>
|
|
120
|
-
);
|
|
116
|
+
return <img src="/logomark.svg" alt={SITE_NAME} width={28} height={28} className="block" />;
|
|
121
117
|
}
|
|
@@ -79,7 +79,7 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
79
79
|
// The trailing `.*\\..*` alternative excludes any path whose last segment
|
|
80
80
|
// contains a dot, i.e. every file with an extension. Without it the negative
|
|
81
81
|
// lookahead named only favicon.ico, so next-intl's locale rewrite swallowed
|
|
82
|
-
// every OTHER public/ asset —
|
|
82
|
+
// every OTHER public/ asset — logomark.svg, wordmark.svg, site.webmanifest,
|
|
83
83
|
// og-default.png — and each 404'd (#4090 item 4). robots.txt and sitemap.xml are
|
|
84
84
|
// route handlers rather than public/ files, but they carry extensions too and
|
|
85
85
|
// are equally not locale-scoped, so the same rule covers them.
|
|
@@ -43,13 +43,45 @@
|
|
|
43
43
|
*/
|
|
44
44
|
let registration: Promise<boolean> | undefined;
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The paid package's specifier, held in a CONST rather than written inline at
|
|
48
|
+
* the `import()` call (#6440 AC2).
|
|
49
|
+
*
|
|
50
|
+
* `webpackIgnore` defers only WEBPACK's resolution; `tsc` resolves a dynamic
|
|
51
|
+
* `import()` whose argument is a string LITERAL exactly as eagerly as a static
|
|
52
|
+
* one, so the literal form failed a keyless scaffold's type-check with TS2307
|
|
53
|
+
* "Cannot find module '@working-theory/ui-commerce'" — the build error #6440
|
|
54
|
+
* was filed for. This monorepo never saw it: `packages-paid/ui-commerce` is on
|
|
55
|
+
* disk here and `apps/web/package.json` declares it `workspace:*`, so the
|
|
56
|
+
* specifier resolves and the defect is reachable only from a scaffold.
|
|
57
|
+
*
|
|
58
|
+
* A non-literal specifier is the whole fix. `tsc` does not attempt module
|
|
59
|
+
* resolution on an `import()` it cannot constant-fold, so the paid package's
|
|
60
|
+
* ABSENCE stops being a type error — while webpack still honours the magic
|
|
61
|
+
* comment beside it and Node still performs the real resolution at runtime,
|
|
62
|
+
* leaving the `catch` below as the one and only thing that observes whether
|
|
63
|
+
* the package is installed. The behaviour is unchanged in both directions:
|
|
64
|
+
* a licensed deployment registers the sections; a keyless one returns false.
|
|
65
|
+
*
|
|
66
|
+
* The file deliberately stays in the BASE TREE rather than moving into the
|
|
67
|
+
* commerce slice: `/purchase/success` and `/purchase/cancel` import it
|
|
68
|
+
* statically, so slicing it out would take the return pages with it and
|
|
69
|
+
* regress #6084 D12 (disposed again on #6440, 2026-09-22).
|
|
70
|
+
*/
|
|
71
|
+
const COMMERCE_UI_PACKAGE = "@working-theory/ui-commerce";
|
|
72
|
+
|
|
73
|
+
/** The one export this module needs from the paid package. */
|
|
74
|
+
interface CommerceSectionsModule {
|
|
75
|
+
registerCommerceSections: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
export function ensureCommerceSectionsRegistered(): Promise<boolean> {
|
|
47
79
|
if (!registration) {
|
|
48
80
|
registration = (async () => {
|
|
49
81
|
try {
|
|
50
|
-
const mod = await import(
|
|
51
|
-
/* webpackIgnore: true */
|
|
52
|
-
);
|
|
82
|
+
const mod = (await import(
|
|
83
|
+
/* webpackIgnore: true */ COMMERCE_UI_PACKAGE
|
|
84
|
+
)) as CommerceSectionsModule;
|
|
53
85
|
mod.registerCommerceSections();
|
|
54
86
|
return true;
|
|
55
87
|
} catch {
|
|
@@ -17,10 +17,25 @@ analytics:
|
|
|
17
17
|
theme:
|
|
18
18
|
colors:
|
|
19
19
|
light:
|
|
20
|
-
bg: "#
|
|
21
|
-
|
|
20
|
+
bg: "#ffffff"
|
|
21
|
+
bgMuted: "#ffffff"
|
|
22
|
+
surface: "#ffffff"
|
|
23
|
+
border: "#000000"
|
|
24
|
+
text: "#000000"
|
|
25
|
+
textMuted: "#333333"
|
|
26
|
+
primary: "#000000"
|
|
27
|
+
primaryFg: "#ffffff"
|
|
28
|
+
accent: "#000000"
|
|
22
29
|
dark:
|
|
23
|
-
bg: "#
|
|
30
|
+
bg: "#000000"
|
|
31
|
+
bgMuted: "#000000"
|
|
32
|
+
surface: "#000000"
|
|
33
|
+
border: "#ffffff"
|
|
34
|
+
text: "#ffffff"
|
|
35
|
+
textMuted: "#cccccc"
|
|
36
|
+
primary: "#ffffff"
|
|
37
|
+
primaryFg: "#000000"
|
|
38
|
+
accent: "#ffffff"
|
|
24
39
|
---
|
|
25
40
|
|
|
26
41
|
<!--
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@working-theory/database",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"db:seed:test": "tsx seed-test.ts",
|
|
61
61
|
"db:studio": "prisma studio",
|
|
62
62
|
"test": "jest --config jest.config.cjs",
|
|
63
|
-
"test:watch": "jest --config jest.config.cjs --watch"
|
|
63
|
+
"test:watch": "jest --config jest.config.cjs --watch",
|
|
64
|
+
"postinstall": "prisma generate"
|
|
64
65
|
},
|
|
65
66
|
"dependencies": {
|
|
66
67
|
"@prisma/adapter-pg": "^7.8.0",
|