create-halation 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mandip Adhikari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # create-halation
2
+
3
+ Start a React project with Halation already in place.
4
+
5
+ ```bash
6
+ npm create halation my-app
7
+ cd my-app
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ You get a Vite and React app with the Halation styles and components, an opening on a phenomenon, working examples, and an agent kit: the Halation skill, a hook that lints every file an agent edits, and an AGENTS.md other tools read too.
13
+
14
+ MIT
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // create-halation: starts a new React project with Halation.
3
+ //
4
+ // npm create halation my-app
5
+ // pnpm create halation my-app --name "My app" --phenomenon caustics
6
+
7
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"
8
+ import { basename, join, relative, resolve } from "node:path"
9
+ import { fileURLToPath } from "node:url"
10
+ import { parseArgs } from "node:util"
11
+
12
+ export const PHENOMENA = ["rays", "blinds", "caustics", "halation", "stir", "ink", "ripple", "silk", "foil", "growth"]
13
+
14
+ const TEMPLATE = fileURLToPath(new URL("../template", import.meta.url))
15
+ const DEFAULT_NAME = "My project"
16
+ const DEFAULT_PHENOMENON = "rays"
17
+ // Files a fresh directory may already hold without counting as "not empty".
18
+ const HARMLESS = new Set([".DS_Store", ".git"])
19
+
20
+ const USAGE = `Start a new project with Halation.
21
+
22
+ Usage
23
+ npm create halation <directory> [-- --name "Project name"] [--phenomenon rays]
24
+ pnpm create halation <directory> [--name "Project name"] [--phenomenon rays]
25
+
26
+ Options
27
+ --name <name> The project's name, shown on the page and used for its seal.
28
+ Defaults to the directory name.
29
+ --phenomenon <name> The light behind the opening section. Defaults to rays.
30
+ One of: ${PHENOMENA.join(", ")}.
31
+ -h, --help Show this help.
32
+ `
33
+
34
+ /** "acme-notes" becomes "Acme Notes". */
35
+ export function titleFromDir(dir) {
36
+ const words = basename(resolve(dir)).split(/[\s._-]+/).filter(Boolean)
37
+ if (words.length === 0) return DEFAULT_NAME
38
+ return words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ")
39
+ }
40
+
41
+ /** A valid npm package name from a directory name. */
42
+ export function packageNameFromDir(dir) {
43
+ const name = basename(resolve(dir))
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9._~-]+/g, "-")
46
+ .replace(/-+/g, "-")
47
+ .replace(/^[._-]+|[-]+$/g, "")
48
+ return name || "halation-app"
49
+ }
50
+
51
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
52
+
53
+ function replaceOnce(file, from, to) {
54
+ const text = readFileSync(file, "utf8")
55
+ if (!text.includes(from)) throw new Error(`The template changed: ${basename(file)} no longer contains ${JSON.stringify(from)}.`)
56
+ writeFileSync(file, text.replace(from, () => to))
57
+ }
58
+
59
+ /**
60
+ * Creates a project in `dir` from the template. Throws with a plain message
61
+ * when the directory isn't empty or an option is wrong.
62
+ */
63
+ export function create(dir, { name, phenomenon = DEFAULT_PHENOMENON } = {}) {
64
+ const target = resolve(dir)
65
+ const projectName = (name ?? titleFromDir(target)).trim() || DEFAULT_NAME
66
+ if (!PHENOMENA.includes(phenomenon)) {
67
+ throw new Error(`There's no phenomenon called "${phenomenon}". Choose one of: ${PHENOMENA.join(", ")}.`)
68
+ }
69
+ if (existsSync(target)) {
70
+ const inside = readdirSync(target).filter((f) => !HARMLESS.has(f))
71
+ if (inside.length > 0) {
72
+ throw new Error(`${relative(process.cwd(), target) || "This directory"} already has files in it. Choose a new or empty directory so nothing gets overwritten.`)
73
+ }
74
+ }
75
+ mkdirSync(target, { recursive: true })
76
+ cpSync(TEMPLATE, target, { recursive: true })
77
+ // npm drops .gitignore files from published packages, so the template carries it as _gitignore.
78
+ renameSync(join(target, "_gitignore"), join(target, ".gitignore"))
79
+
80
+ const pkgFile = join(target, "package.json")
81
+ const pkg = JSON.parse(readFileSync(pkgFile, "utf8"))
82
+ pkg.name = packageNameFromDir(target)
83
+ writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n")
84
+
85
+ const projectFile = join(target, "src", "project.ts")
86
+ replaceOnce(projectFile, `name: ${JSON.stringify(DEFAULT_NAME)},`, `name: ${JSON.stringify(projectName)},`)
87
+ replaceOnce(projectFile, `phenomenon: ${JSON.stringify(DEFAULT_PHENOMENON)},`, `phenomenon: ${JSON.stringify(phenomenon)},`)
88
+ replaceOnce(join(target, "index.html"), `<title>${DEFAULT_NAME}</title>`, `<title>${escapeHtml(projectName)}</title>`)
89
+ replaceOnce(join(target, "README.md"), `# ${DEFAULT_NAME}`, `# ${projectName}`)
90
+
91
+ return { target, name: projectName, packageName: pkg.name, phenomenon }
92
+ }
93
+
94
+ /** The package manager that ran us, so the next steps use the same one. */
95
+ function packageManager() {
96
+ const agent = process.env.npm_config_user_agent ?? ""
97
+ if (agent.startsWith("pnpm")) return "pnpm"
98
+ if (agent.startsWith("yarn")) return "yarn"
99
+ if (agent.startsWith("bun")) return "bun"
100
+ return "npm"
101
+ }
102
+
103
+ function main(argv) {
104
+ let parsed
105
+ try {
106
+ parsed = parseArgs({
107
+ args: argv,
108
+ allowPositionals: true,
109
+ options: {
110
+ name: { type: "string" },
111
+ phenomenon: { type: "string" },
112
+ help: { type: "boolean", short: "h" },
113
+ },
114
+ })
115
+ } catch (error) {
116
+ console.error(`${error.message}\n\n${USAGE}`)
117
+ return 1
118
+ }
119
+ const { values, positionals } = parsed
120
+ if (values.help) {
121
+ console.log(USAGE)
122
+ return 0
123
+ }
124
+ if (positionals.length > 1) {
125
+ console.error(`Give one directory, not ${positionals.length}. If the name has spaces, put it in quotes or pass it with --name.\n\n${USAGE}`)
126
+ return 1
127
+ }
128
+ const dir = positionals[0] ?? "my-project"
129
+
130
+ let result
131
+ try {
132
+ result = create(dir, { name: values.name, phenomenon: values.phenomenon ?? DEFAULT_PHENOMENON })
133
+ } catch (error) {
134
+ console.error(error.message)
135
+ return 1
136
+ }
137
+
138
+ const pm = packageManager()
139
+ const run = pm === "npm" ? "npm run" : pm
140
+ const where = relative(process.cwd(), result.target)
141
+ const steps = [where && `cd ${where.includes(" ") ? JSON.stringify(where) : where}`, `${pm} install`, `${run} dev`].filter(Boolean)
142
+ console.log(`
143
+ Created ${result.name} in ${where || "this directory"}.
144
+
145
+ Next, install and start it:
146
+
147
+ ${steps.map((s) => ` ${s}`).join("\n")}
148
+
149
+ Then open the address it prints. Edit src/App.tsx and the page updates as you save.
150
+
151
+ Claude Code is already set up here: CLAUDE.md and AGENTS.md explain how to work
152
+ in the project, the Halation skill is in .claude/skills, and a hook runs
153
+ halation lint after every edit. Run ${run} lint to check the house rules yourself.
154
+ `)
155
+ return 0
156
+ }
157
+
158
+ // Run directly or through a bin symlink (npm create, pnpm create); not when imported by tests.
159
+ const realpath = (p) => {
160
+ try {
161
+ return realpathSync(p)
162
+ } catch {
163
+ return p
164
+ }
165
+ }
166
+ if (process.argv[1] && realpath(process.argv[1]) === realpath(fileURLToPath(import.meta.url))) {
167
+ process.exitCode = main(process.argv.slice(2))
168
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "create-halation",
3
+ "version": "0.1.0",
4
+ "description": "Start a new React project with Halation: the styles, the components, and an agent kit that keeps every page on-brand.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-halation": "./bin/create-halation.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "template"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/mandipadk/halation.git",
20
+ "directory": "packages/create"
21
+ },
22
+ "homepage": "https://halation.mandip.dev",
23
+ "bugs": {
24
+ "url": "https://github.com/mandipadk/halation/issues"
25
+ },
26
+ "scripts": {
27
+ "build": "node ../cli/bin/halation.js skill > template/.claude/skills/halation/SKILL.md",
28
+ "test": "node --test \"test/*.test.js\""
29
+ }
30
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|MultiEdit",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "npx --no-install halation lint --hook"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,351 @@
1
+ ---
2
+ name: halation
3
+ description: The Halation design system's rules and React components for this project. Use when building, styling, reviewing or writing copy for any UI here (components, pages, CSS, Tailwind classes, interface text), and before adding a color, font size, gradient, badge, status indicator, card, icon or animation.
4
+ ---
5
+
6
+ # Halation
7
+
8
+ This project's look is a set of rules, enforced in code. Build with the components, the ten text styles and the color roles, and the result stays on brand without guessing. `halation lint` and a hook check source as you edit; `halation check` measures the rendered page.
9
+
10
+ ## Set up
11
+
12
+ ```tsx
13
+ import "@halation/react/styles.css"
14
+ import { HalationProvider } from "@halation/react"
15
+
16
+ export function App({ children }) {
17
+ return <HalationProvider name="Your project">{children}</HalationProvider>
18
+ }
19
+ ```
20
+
21
+ - The name seeds the project's seal, grain, share card and greeting. Optional: `accent` (vermilion, cobalt, jade, amber), `tempo` (calm, crisp, lively), `theme` (system, light, dark).
22
+ - With Tailwind v4, import `@halation/core/tailwind.css` instead of the styles: only the system's values exist, so `text-sm` or `bg-purple-500` don't generate. Use `text-body-sm`, `bg-surface`, `text-fg-muted`, `border-line`.
23
+ - Before you finish: `npx halation lint` (exits 1 on an error) and, with the app running, `npx halation check http://localhost:3000`.
24
+ - A deliberate exception gets a comment saying `halation-ignore R9` (the rule id) on that line or the line above.
25
+
26
+ ## Principles
27
+
28
+ - Ink, not color. One accent, spent only where it means something; the main action is ink (R1, R2).
29
+ - Light, not paint. Depth comes from an atmosphere, lit edges and shadows, never decorative gradients (R3, R15).
30
+ - Structure, not strings. Facts get their own lines, states get a word and a shape (R9, R10).
31
+ - Few sizes, plain case. Ten text styles, sentence case, no added tracking (R5, R6, R7).
32
+ - Lines before boxes. A hairline or space first; a box only for an object, never a box in a box (R13).
33
+ - Motion explains a change. Under 300 ms, exits faster than enters, nothing moving while idle (R14).
34
+ - Plain words. Sentence case, short labels; errors say what's wrong and how to fix it.
35
+
36
+ ## Rules
37
+
38
+ ### R1: One accent, used as a signal: identity, focus, selection, live state.
39
+
40
+ - Don't: Accent-filled cards, headings, icons and buttons all over a page.
41
+ - Do: Neutral surfaces; `text-accent` or `<Button variant="accent">` only for identity, focus, selection or live state.
42
+ - Why: Color that means something is only noticed when it's rare.
43
+ - Instead: Neutral surfaces and ink; the accent only where it carries meaning.
44
+ - Caught by: Theme, page check.
45
+
46
+ ### R2: The one main action is ink: white on dark, black on light.
47
+
48
+ - Don't: `<Button variant="accent">Save</Button>` next to two other filled buttons.
49
+ - Do: `<Button variant="ink">Save</Button>`, one per view; the rest secondary or ghost.
50
+ - Why: Keeps the accent free to mean something, and the main action obvious in both modes.
51
+ - Instead: An ink button; accent fills only for identity moments.
52
+ - Caught by: Page check.
53
+
54
+ ### R3: No gradients as decoration. Depth comes from light: atmospheres, lit edges, shadows.
55
+
56
+ - Don't: `className="bg-gradient-to-r from-purple-500 to-pink-500"`, or `background: linear-gradient(...)`.
57
+ - Do: `<Stage phenomenon="rays">` behind the page, or `<Surface elevation="raised">`.
58
+ - Why: Decorative gradients are the most common sign of generated design.
59
+ - Instead: An atmosphere behind the page, or a raised surface.
60
+ - Caught by: Theme, lint.
61
+
62
+ ### R4: Every text and background pair passes WCAG 2 in both modes.
63
+
64
+ - Don't: Gray text on a tinted panel, checked in light mode only.
65
+ - Do: The text roles (`text-fg`, `text-fg-muted`, `text-fg-subtle`) on the surface roles.
66
+ - Why: Legibility isn't a style choice.
67
+ - Instead: Use the text roles; the forge solves their contrast.
68
+ - Caught by: Forge, page check.
69
+
70
+ ### R5: Sentence case everywhere. No uppercase labels.
71
+
72
+ - Don't: `className="uppercase"`, `text-transform: uppercase`, or typing a label in capitals.
73
+ - Do: `<Text size="caption" tone="muted">Recent projects</Text>`
74
+ - Why: Capitals shout, and spaced-out capitals are a generated-design cliché.
75
+ - Instead: A smaller or lighter text style.
76
+ - Caught by: Lint, page check.
77
+
78
+ ### R6: No letter-spacing beyond what the text styles set.
79
+
80
+ - Don't: `tracking-widest`, `tracking-[0.2em]`, `letter-spacing: 0.1em`.
81
+ - Do: The text style alone; each one sets its own tracking.
82
+ - Why: Tracking is tuned per size already; extra spacing is how eyebrows get made.
83
+ - Instead: The text style for that size.
84
+ - Caught by: Theme.
85
+
86
+ ### R7: Ten named text styles; no other sizes.
87
+
88
+ - Don't: `text-sm`, `text-2xl`, `text-[13px]`, `font-size: 15px`.
89
+ - Do: `<Text size="body-sm">`, `className="text-body-sm"` (Tailwind) or `hl-text-body-sm`.
90
+ - Why: A small scale is what makes pages feel composed.
91
+ - Instead: The nearest style.
92
+ - Caught by: Theme, lint.
93
+
94
+ ### R8: At most one serif phrase in a headline, and only at title sizes and up.
95
+
96
+ - Don't: Two serif phrases in a headline, or a serif word in body text.
97
+ - Do: `<Heading level={1}>Every app, <Serif>twice</Serif></Heading>`
98
+ - Why: It's an accent in the voice; twice is a costume.
99
+ - Instead: Plain type for everything else.
100
+ - Caught by: Page check.
101
+
102
+ ### R9: Facts are never joined with dots or bars.
103
+
104
+ - Don't: `<p>Copy of Claude · 412 MB</p>` or `{name} | {size}` in text. <!-- halation-ignore R9 -->
105
+ - Do: `<Facts items={[["Name", "Copy of Claude"], ["Size", "412 MB"]]} />`
106
+ - Why: A joined string has to be parsed; structure can be scanned.
107
+ - Instead: The Facts component, or separate lines with their own weight.
108
+ - Caught by: Lint, page check.
109
+
110
+ ### R10: No status dots, and no tinted pills with a dot and same-hue text.
111
+
112
+ - Don't: `<span className="size-2 rounded-full bg-green-500" />` before a word, or a tinted pill with a dot.
113
+ - Do: `<State kind="done">Synced</State>`
114
+ - Why: It reads as generated, and color alone doesn't say what the state is.
115
+ - Instead: A word and a shape; only the shape carries color.
116
+ - Caught by: Lint, page check.
117
+
118
+ ### R11: Monospace only inside a surface: keys, code wells, token names on cards.
119
+
120
+ - Don't: Monospace headings, labels or numbers loose on the page.
121
+ - Do: Monospace inside `<Kbd>`, `<code>` or a surface; numbers in `<Value>`.
122
+ - Why: Loose monospace on the page reads as a terminal, and as generated.
123
+ - Instead: Geist, with tabular figures for numbers.
124
+ - Caught by: Page check.
125
+
126
+ ### R12: Numbers that sit in columns or change use tabular figures.
127
+
128
+ - Don't: Proportional digits in a table column or a live counter.
129
+ - Do: `<Value>` or `<Count>`, which set tabular figures.
130
+ - Why: They line up and don't jitter as they change.
131
+ - Instead: The value style, which sets tabular figures.
132
+ - Caught by: Theme, page check.
133
+
134
+ ### R13: Hairlines separate; a box only when something must read as an object. Never a box inside a box.
135
+
136
+ - Don't: Every section in a bordered, rounded box; a card inside a card.
137
+ - Do: `<Divider />` or spacing; `<Surface>` only for a real object.
138
+ - Why: Card soup flattens hierarchy.
139
+ - Instead: A divider or spacing.
140
+ - Caught by: Page check, critic.
141
+
142
+ ### R14: Interface motion stays under 300 ms, exits take 70% of the enter, nothing loops while idle.
143
+
144
+ - Don't: `duration-500` on a menu, a looping pulse, a shimmering skeleton.
145
+ - Do: The moves and duration tokens below; `<Skeleton>` stays still.
146
+ - Why: Motion should explain a change, never make anyone wait.
147
+ - Instead: The moves and their durations.
148
+ - Caught by: Theme, page check.
149
+
150
+ ### R15: One atmosphere per page, and it dims behind content.
151
+
152
+ - Don't: Two `<Stage>`s on one page, or a phenomenon at full strength behind body text.
153
+ - Do: One `<Stage>`, with `data-quiet` on the headline it sits behind.
154
+ - Why: An atmosphere is a place, not a decoration.
155
+ - Instead: The page's one light, with its quiet zone.
156
+ - Caught by: Page check.
157
+
158
+ ### R17: Colors come from tokens, never from literal values in markup.
159
+
160
+ - Don't: `className="bg-[#7c3aed]"`, `style={{ color: "#333" }}`.
161
+ - Do: Color roles: `bg-surface`, `text-fg-muted`, `border-line`, `bg-accent`.
162
+ - Why: A literal color is a color the system can't check, theme or keep in contrast.
163
+ - Instead: A color role: bg-surface, text-fg-muted, border-line and so on.
164
+ - Caught by: Theme, lint.
165
+
166
+ ### R18: Type comes from the project's lens: its display, text and code faces.
167
+
168
+ - Don't: `font-family: Inter`, `font-['Roboto']`, a bare system stack.
169
+ - Do: `var(--font-sans)`, `var(--font-serif)`, `var(--font-mono)` (`font-sans` in Tailwind).
170
+ - Why: Inter, Roboto and system stacks are the default look of generated design.
171
+ - Instead: The --font-sans, --font-serif and --font-mono tokens.
172
+ - Caught by: Theme, lint.
173
+
174
+ ### R16: Keys in a combination keep a visible gap.
175
+
176
+ - Don't: `<kbd>⌘</kbd><kbd>K</kbd>` with no gap, or `⌘K` as one string.
177
+ - Do: `<Keys keys={["⌘", "K"]} />`
178
+ - Why: Each key should read as its own key.
179
+ - Instead: The Keys component.
180
+ - Caught by: Lint.
181
+
182
+ ## What to use for what
183
+
184
+ | Need | Use |
185
+ | --- | --- |
186
+ | Show a state (running, done, failed) | `<State kind="running">Syncing</State>` |
187
+ | Metadata about a thing | `<Facts items={[["Size", "412 MB"]]} />` |
188
+ | A keyboard shortcut | `<Keys keys={["⌘", "K"]} />` |
189
+ | Hero with atmosphere | `<Stage phenomenon="rays">`, with `data-quiet` on the headline |
190
+ | The main action | `<Button variant="ink">`, once per view |
191
+ | Any other action | `<Button>` or `<Button variant="ghost">` |
192
+ | Destructive action | `<Button variant="critical">`, confirmed in a `Dialog` |
193
+ | Headline with an accent phrase | `<Heading level={1}>Every app, <Serif>twice</Serif></Heading>` |
194
+ | Running text or a label | `<Text size="body-sm" tone="muted">` |
195
+ | A number in a column, or with a unit | `<Value unit="MB">412</Value>` |
196
+ | A number that changes | `<Count value={n} />` |
197
+ | Separate two groups | `<Divider />` or spacing |
198
+ | Something that must read as an object | `<Surface elevation="raised">` |
199
+ | Rows of items | `<List>` of `<ListRow title detail trailing />` |
200
+ | A form control with a label and error | `<Field label error><Input /></Field>` |
201
+ | On or off, applied at once | `<Switch label="..." />` |
202
+ | One of a few views | `<SegmentedControl>` or `<Tabs>` |
203
+ | One of many options | `<Select options={...} />` |
204
+ | Extra actions behind a button | `<Menu>` |
205
+ | A label on hover | `<Tooltip content="..." keys={...}>` |
206
+ | A decision before going on | `<Dialog>` |
207
+ | A pull-away panel | `<Sheet>` |
208
+ | Confirm something finished | `useToast()` |
209
+ | Loading placeholder | `<Skeleton />`, still, no shimmer |
210
+ | Entrance on scroll | `<Rise>` |
211
+ | A person | `<Avatar name="..." src={...} />` |
212
+ | An icon | `<icons.CheckIcon />` and the rest, never emoji |
213
+ | The project's mark, credits, share image | `<Seal>`, `<Colophon>`, `<ShareCard>` |
214
+
215
+ ## Components
216
+
217
+ All from `@halation/react`.
218
+
219
+ - **HalationProvider**: Wrap the app once. Sets accent, tempo and theme, hosts tooltips and toasts, and puts on the project's signature. `<HalationProvider name="Lumen" accent="vermilion">{children}</HalationProvider>`
220
+ - **Text**: Any running text, in one of the ten styles (size) with an optional tone: muted, subtle, accent or critical. `<Text size="body-sm" tone="muted">Synced a minute ago</Text>`
221
+ - **Heading**: Headlines. level sets the element and its default style (1 is display-xl, 3 is title-1); size overrides the style. `<Heading level={3}>Storage</Heading>`
222
+ - **Serif**: The one italic serif phrase inside a headline, at title sizes and up (R8). `<Heading level={1}>Every app, <Serif>twice</Serif></Heading>`
223
+ - **Value**: A number with tabular figures and a quieter unit. `<Value unit="MB">412</Value>`
224
+ - **Button**: Actions. One ink button per view for the main action; secondary (the default) or ghost for the rest; critical for destructive ones. busy/busyLabel and done/doneLabel change the label in place. `<Button variant="ink" busy={saving} busyLabel="Saving" done={saved} doneLabel="Saved">Save changes</Button>`
225
+ - **Kbd**: A single key. `<Kbd>Esc</Kbd>`
226
+ - **Keys**: A keyboard shortcut; each key on its own cap with a gap (R16). `<Keys keys={["⌘", "K"]} />`
227
+ - **State**: A status as a word and a glyph, never a colored dot (R10). Kinds: running, done, selected, info, draft, warning, critical. `<State kind="running">Syncing</State>`
228
+ - **Facts**: Metadata about a thing: labels and values on their own lines, instead of facts joined with dots (R9). `<Facts items={[["Size", "412 MB"], ["Updated", "Today"]]} />`
229
+ - **Fact**: One label and value inside Facts, when the value is richer than a string. `<Facts><Fact label="Size"><Value unit="MB">412</Value></Fact></Facts>`
230
+ - **Surface**: A box, only when something must read as an object (R13). elevation: flat, raised or overlay. Never put one inside another. `<Surface elevation="raised"><Text>Drop files here</Text></Surface>`
231
+ - **Divider**: A hairline between groups; the first choice before a box. `<Divider />`
232
+ - **List**: A list of rows separated by hairlines. `<List><ListRow title="Claude" detail="412 MB" /></List>`
233
+ - **ListRow**: A row: something leading, a title with an optional detail, and something trailing. selected marks it chosen. `<ListRow leading={<Avatar name="Ada Lovelace" />} title="Ada Lovelace" detail="Owner" trailing={<Button variant="ghost" size="sm">Remove</Button>} />`
234
+ - **Field**: A labelled control with an optional description and an error that says what's wrong and how to fix it. `<Field label="Project name" description="Shown on the seal." error={error}><Input name="name" /></Field>`
235
+ - **Input**: A single-line text input, usually inside Field. `<Input name="email" type="email" />`
236
+ - **Textarea**: Multi-line text, inside Field. `<Field label="Notes"><Textarea rows={4} /></Field>`
237
+ - **Checkbox**: An independent yes or no that takes effect when the form is saved. `<Checkbox label="Email me when it's done" defaultChecked />`
238
+ - **Radio**: One option in a RadioGroup. `<Radio value="weekly" label="Weekly" />`
239
+ - **RadioGroup**: One choice out of a few, with each option visible. `<RadioGroup defaultValue="weekly"><Radio value="daily" label="Daily" /><Radio value="weekly" label="Weekly" /></RadioGroup>`
240
+ - **Switch**: An on or off setting that takes effect at once. `<Switch label="Sync automatically" defaultChecked />`
241
+ - **Slider**: A value picked from a range. `<Slider defaultValue={40} min={0} max={100} />`
242
+ - **SegmentedControl**: One of two to five views or modes, side by side. `<SegmentedControl aria-label="View" defaultValue="list" options={[{ value: "list", label: "List" }, { value: "grid", label: "Grid" }]} />`
243
+ - **Tabs**: Switching between panels of one page; the indicator glides between tabs. `<Tabs.Root defaultValue="files"><Tabs.List><Tabs.Tab value="files">Files</Tabs.Tab><Tabs.Tab value="activity">Activity</Tabs.Tab></Tabs.List><Tabs.Panel value="files">{files}</Tabs.Panel><Tabs.Panel value="activity">{activity}</Tabs.Panel></Tabs.Root>`
244
+ - **Menu**: Actions tucked behind a button. Items take an icon, keys for their shortcut, and tone="critical". `<Menu.Root><Menu.Trigger render={<Button>Options</Button>} /><Menu.Popup><Menu.Item keys={["⌘", "D"]}>Duplicate</Menu.Item><Menu.Separator /><Menu.Item tone="critical">Delete</Menu.Item></Menu.Popup></Menu.Root>`
245
+ - **Popover**: More detail or a small form anchored to a control. `<Popover.Root><Popover.Trigger render={<Button>Details</Button>} /><Popover.Popup title="Storage" description="What this project keeps on disk.">{details}</Popover.Popup></Popover.Root>`
246
+ - **Tooltip**: A short label for a control on hover or focus, with an optional shortcut. `<Tooltip content="Duplicate" keys={["⌘", "D"]}><Button>Duplicate</Button></Tooltip>`
247
+ - **TooltipProvider**: Shares tooltip timing. HalationProvider already includes it; use it only for tooltips rendered outside the provider. `<TooltipProvider>{children}</TooltipProvider>`
248
+ - **Select**: One choice out of many, in a list. `<Select aria-label="Accent" defaultValue="vermilion" options={[{ value: "vermilion", label: "Vermilion" }, { value: "cobalt", label: "Cobalt" }]} />`
249
+ - **Dialog**: A decision that has to be made before going on. The page behind softens. `<Dialog.Root><Dialog.Trigger render={<Button variant="critical">Delete project</Button>} /><Dialog.Popup title="Delete this project?" description="Its files stay on disk." actions={<><Dialog.Close render={<Button>Cancel</Button>} /><Button variant="critical">Delete</Button></>} /></Dialog.Root>`
250
+ - **Sheet**: A panel you can pull away, from the bottom or the right, for filters, details or mobile menus. `<Sheet.Root side="right"><Sheet.Trigger render={<Button>Filters</Button>} /><Sheet.Popup title="Filters" side="right">{filters}</Sheet.Popup></Sheet.Root>`
251
+ - **ToastProvider**: Hosts toasts. HalationProvider already includes it. `<ToastProvider>{children}</ToastProvider>`
252
+ - **useToast**: Confirms something finished, or reports a failure, without interrupting. `const toast = useToast(); toast({ title: "Saved", type: "success" })`
253
+ - **Avatar**: A person, as a photo or their initials. `<Avatar name="Ada Lovelace" src={photo} />`
254
+ - **Skeleton**: A still placeholder while content loads. It never shimmers. `<Skeleton width={160} />`
255
+ - **Count**: A number that changes; its digits roll to the new value. `<Count value={downloads} />`
256
+ - **Rise**: Content that arrives in reading order out of a soft blur, once, when first seen. `<Rise as="section"><Heading>What it does</Heading><Text>{summary}</Text></Rise>`
257
+ - **Stage**: A hero with atmosphere: one phenomenon behind content, one per page (R15). Mark the headline data-quiet so it dims behind it. `<Stage phenomenon="caustics" settleAfter={12}><Heading level={1} data-quiet>Light on water</Heading></Stage>`
258
+ - **Seal**: The project's mark, generated from its name. Alt, Shift and a click opens the darkroom. `<Seal name="Lumen" chime />`
259
+ - **Colophon**: The credits in a footer: what the project is made of. `<Colophon name="Lumen" character={character} />`
260
+ - **Grain**: Still film grain seeded by the name, laid over a stage or image. `<Grain name="Lumen" />`
261
+ - **ShareCard**: The social share image, drawn from the name, a line and the accent. `<ShareCard name="Lumen" line="Every app, twice" />`
262
+ - **useDevelop**: Develops an element like a print, once per visitor. For a landing hero. `const hero = useRef(null); useDevelop(hero)`
263
+ - **darkroom**: Opens or closes the darkroom inspector from code. `darkroom.enter()`
264
+ - **useDarkroom**: Whether the darkroom is open, for a button that enters and leaves it. `const open = useDarkroom()`
265
+ - **icons**: The system's glyphs: CheckIcon, PlayIcon, WrenchIcon, StopIcon, PencilIcon, InfoIcon, AlertIcon, ChevronIcon, CloseIcon. Use these or a real icon set, never emoji. `<icons.CheckIcon />`
266
+
267
+ ## The ten text styles
268
+
269
+ Use `<Text size>`, `<Heading size>`, `hl-text-<name>`, or `text-<name>` in Tailwind. No other sizes.
270
+
271
+ | Style | Size | For |
272
+ | --- | --- | --- |
273
+ | display-xl | 52 to 116 px, fluid | The one hero line on a page. |
274
+ | display | 36 to 60 px, fluid | Section headlines on marketing pages. |
275
+ | title-1 | 28 to 36 px, fluid | Page titles in products; dialog heroes. |
276
+ | title-2 | 24 px | Section titles inside a page. |
277
+ | title-3 | 20 px | Card and group titles. |
278
+ | body-lg | 18 px | Lead paragraphs under a headline. |
279
+ | body | 16 px | Reading text. |
280
+ | body-sm | 14 px | Interface text: controls, lists, tables. |
281
+ | caption | 13 px | Metadata, helper text, footnotes. |
282
+ | micro | 12 px | Badges, keyboard keys, counters. Never sentences. |
283
+
284
+ ## Phenomena
285
+
286
+ The atmosphere behind a hero: `<Stage phenomenon="...">`. One per page, dimmed behind the element marked `data-quiet`. Foil presses the heading marked `data-press`. `daylight` follows the visitor's time of day; `settleAfter` (seconds) eases the motion to a stop. Each holds a still frame for reduced motion.
287
+
288
+ | Name | Group | What it is |
289
+ | --- | --- | --- |
290
+ | rays | Light | Crepuscular rays from above the frame |
291
+ | blinds | Light | Late sun through a window with blinds |
292
+ | caustics | Light | Light on a pool floor |
293
+ | halation | Light | The glow film gives bright things |
294
+ | stir | Air | A shaft of light, and the air in it |
295
+ | ink | Air | A fluid you stir |
296
+ | ripple | Water | Still water over a tiled floor |
297
+ | silk | Matter | A satin drape |
298
+ | foil | Matter | Letterpress and foil |
299
+ | growth | Life | Reaction-diffusion |
300
+
301
+ ## Motion
302
+
303
+ - Interface motion stays under 300 ms. Exits take 70% of the enter. Nothing loops while idle; skeletons don't shimmer.
304
+ - Ease out is the default. There's no ease-in: things leave the way they arrive, only faster.
305
+ - Stagger 50 ms between items, and stagger at most 6.
306
+ - Reduced motion turns moves into short fades (200 ms at most).
307
+ - Tempo scales every duration: calm 1.25x, crisp 1x, lively 0.85x. Set it on the provider.
308
+ - Scripted motion comes from `@halation/core/motion`: `play`, `spring` (carries velocity, retargets mid-flight), `rise`, `setTempo`.
309
+
310
+ | Duration token | ms | For |
311
+ | --- | --- | --- |
312
+ | --duration-press | 120 | Press feedback (scale to 0.97). |
313
+ | --duration-hover | 150 | Hover color and fill changes. |
314
+ | --duration-popover | 180 | Tooltips, menus, popovers; exits of anything. |
315
+ | --duration-dialog | 240 | Dialogs, toasts, panels. |
316
+ | --duration-sheet | 500 | Sheets and drawers that follow a drag (with ease-drawer). |
317
+ | --duration-reveal | 600 | The one-time blur rise on page entrance and scroll. |
318
+
319
+ | Easing token | For |
320
+ | --- | --- |
321
+ | --ease-out | Anything entering, leaving or responding. The default. |
322
+ | --ease-in-out | Something moving or morphing between two places on screen. |
323
+ | --ease-drawer | Sheets and drawers that follow a drag. |
324
+ | --ease-spring | Things that snap into place: toggles, drag release. Never menus or text. |
325
+
326
+ The moves, built into the components:
327
+
328
+ - Press: A pressed control scales to 0.97.
329
+ - Unfold: Menus and popovers grow out of the control that opened them.
330
+ - Focus pull: The page behind a dialog softens while the dialog comes into focus.
331
+ - Glide: The tab indicator moves between tabs on the in-out curve.
332
+ - Settle: A switch thumb springs across.
333
+ - Bloom: A button blooms once in the halation color when its work is done.
334
+ - Stack: Toasts stack with depth and fan out when pointed at.
335
+ - Rise: Content arrives in reading order out of a soft blur, once.
336
+
337
+ ## Tells of generated design
338
+
339
+ Never ship these:
340
+
341
+ - Purple or violet gradients, gradient text, glowing blobs behind cards (R3, R1).
342
+ - Inter, Roboto or a bare system stack as the typeface (R18).
343
+ - Uppercase, letter-spaced eyebrows above headlines (R5, R6).
344
+ - Facts joined in one string with a middle dot, bullet or bar (R9).
345
+ - Status dots, and tinted pills with a dot and same-hue text (R10).
346
+ - Card soup: every section in a bordered, rounded box, boxes inside boxes (R13).
347
+ - Emoji as icons. Use `icons` from @halation/react or a real icon set.
348
+ - Several filled, colored buttons competing for the main action (R1, R2).
349
+ - Monospace sprinkled on labels and numbers for a technical look (R11).
350
+ - Shimmering skeletons, pulsing badges, anything looping while idle (R14).
351
+ - Arbitrary sizes such as `text-[13px]` between the styles (R7).
@@ -0,0 +1,30 @@
1
+ # Working in this project
2
+
3
+ This is a React app built with Halation, a design system that holds the project's taste as rules. Keep every change inside those rules.
4
+
5
+ ## Before you change the interface
6
+
7
+ Read the Halation skill in `.claude/skills/halation/SKILL.md`. It lists the components, the text styles, the tokens and the rules, with what to use instead of each thing the rules forbid.
8
+
9
+ Build with the components from `@halation/react` before writing your own markup. The project's name and its phenomenon live in `src/project.ts`.
10
+
11
+ ## After every change
12
+
13
+ Run `pnpm lint` (or `npm run lint`). It checks `src` against the rules and says how to fix what it finds. In Claude Code, a hook runs it after every edit, so fix what it reports before moving on.
14
+
15
+ With the dev server running, `pnpm check` measures the rendered page in light and dark mode: contrast, stray capitals, dots, status dots and slow motion.
16
+
17
+ Run `pnpm build` before you call a change done. TypeScript must pass with no errors.
18
+
19
+ ## The rules in short
20
+
21
+ - Sentence case everywhere. No uppercase labels and no letter-spaced eyebrows.
22
+ - Facts go in `Facts` or on separate lines. Never join them with `·`, `•` or a bar.
23
+ - Show a state with `State`, never a colored dot.
24
+ - Text comes in ten styles: `display-xl`, `display`, `title-1`, `title-2`, `title-3`, `body-lg`, `body`, `body-sm`, `caption` and `micro`. Use `Text`, `Heading` or the `hl-text-*` classes, not other sizes.
25
+ - Colors come from the tokens (`var(--color-fg)`, `var(--color-accent)` and the rest), never from literal values.
26
+ - No gradients as decoration. Depth comes from the phenomenon on the `Stage`, lit edges and shadows.
27
+ - One ink button per view: the main action. Everything else is secondary or ghost.
28
+ - At most one `Serif` phrase in a headline.
29
+ - One phenomenon per page, and it dims behind content marked `data-quiet`.
30
+ - Write plain, specific copy. A button says what happens when you press it.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,20 @@
1
+ # My project
2
+
3
+ A React app built with [Halation](https://www.npmjs.com/package/@halation/react), Vite and TypeScript.
4
+
5
+ ## Commands
6
+
7
+ - `pnpm dev` starts the app and reloads it as you save.
8
+ - `pnpm build` checks the types and builds the app into `dist`.
9
+ - `pnpm preview` serves the built app.
10
+ - `pnpm lint` checks `src` against Halation's rules.
11
+ - `pnpm check` measures the running page in light and dark mode.
12
+
13
+ Use `npm run` in place of `pnpm` if you installed with npm.
14
+
15
+ ## Where things are
16
+
17
+ - `src/project.ts` holds the project's name and the phenomenon behind the opening section.
18
+ - `src/App.tsx` is the page. Start here.
19
+ - `src/app.css` lays the page out, using Halation's tokens.
20
+ - `AGENTS.md`, `CLAUDE.md` and `.claude/` set up coding agents to follow the same rules you do.
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ .DS_Store
4
+ *.log
5
+ *.local
6
+ .claude/settings.local.json
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>My project</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400..700&family=Geist+Mono:wght@400..500&family=Instrument+Serif:ital@0;1&display=swap" />
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.tsx"></script>
14
+ </body>
15
+ </html>
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "my-project",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "preview": "vite preview",
10
+ "lint": "halation lint src",
11
+ "check": "halation check http://localhost:5173"
12
+ },
13
+ "dependencies": {
14
+ "@halation/react": "^0.1.0",
15
+ "react": "^19.3.0",
16
+ "react-dom": "^19.3.0"
17
+ },
18
+ "devDependencies": {
19
+ "@halation/cli": "^0.1.0",
20
+ "@types/react": "^19.2.18",
21
+ "@types/react-dom": "^19.2.7",
22
+ "@vitejs/plugin-react": "^6.1.1",
23
+ "playwright-core": "^1.63.0",
24
+ "typescript": "~6.0.2",
25
+ "vite": "^8.3.1"
26
+ }
27
+ }
@@ -0,0 +1,134 @@
1
+ import { Button, Count, Facts, Heading, Keys, List, ListRow, Rise, Seal, Serif, Stage, State, Surface, Text, useToast, type StateKind } from "@halation/react"
2
+ import { useEffect, useState } from "react"
3
+ import { NewTaskDialog } from "./NewTaskDialog.tsx"
4
+ import { project } from "./project.ts"
5
+
6
+ type Task = { id: number; title: string; detail: string; status: "done" | "doing" | "todo" }
7
+
8
+ const STATUS: Record<Task["status"], { kind: StateKind; label: string }> = {
9
+ done: { kind: "done", label: "Done" },
10
+ doing: { kind: "running", label: "In progress" },
11
+ todo: { kind: "draft", label: "Not started" },
12
+ }
13
+
14
+ const light = project.phenomenon[0].toUpperCase() + project.phenomenon.slice(1)
15
+
16
+ const FIRST_TASKS: Task[] = [
17
+ { id: 1, title: "Name the project", detail: "It lives in src/project.ts", status: "done" },
18
+ { id: 2, title: "Choose the light behind the opening", detail: `${light}, following the visitor's time of day`, status: "done" },
19
+ { id: 3, title: "Write the first real page", detail: "Replace this one in src/App.tsx", status: "doing" },
20
+ { id: 4, title: "Check the house rules", detail: "pnpm lint reads src and says what to fix", status: "todo" },
21
+ ]
22
+
23
+ export function App() {
24
+ const toast = useToast()
25
+ const [tasks, setTasks] = useState(FIRST_TASKS)
26
+ const [adding, setAdding] = useState(false)
27
+ const done = tasks.filter((t) => t.status === "done").length
28
+
29
+ // N adds a task from anywhere on the page, unless you're typing.
30
+ useEffect(() => {
31
+ const onKey = (e: KeyboardEvent) => {
32
+ if (e.key.toLowerCase() !== "n" || e.metaKey || e.ctrlKey || e.altKey || adding) return
33
+ const target = e.target
34
+ if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
35
+ e.preventDefault()
36
+ setAdding(true)
37
+ }
38
+ window.addEventListener("keydown", onKey)
39
+ return () => window.removeEventListener("keydown", onKey)
40
+ }, [adding])
41
+
42
+ const addTask = (title: string) => {
43
+ setTasks((all) => [...all, { id: Date.now(), title, detail: "Added just now", status: "todo" }])
44
+ toast({ title: "Task added", description: title, type: "success" })
45
+ }
46
+
47
+ const markDone = (task: Task) => {
48
+ setTasks((all) => all.map((t) => (t.id === task.id ? { ...t, status: "done" } : t)))
49
+ toast({ title: "Marked done", description: task.title, type: "success" })
50
+ }
51
+
52
+ return (
53
+ <>
54
+ <Stage phenomenon={project.phenomenon} daylight className="hero">
55
+ <Rise className="hero-copy" data-quiet="">
56
+ <Heading level={1} data-press={project.phenomenon === "foil" ? "" : undefined}>
57
+ <Serif>{project.name}</Serif> starts here
58
+ </Heading>
59
+ <Text size="body-lg" tone="muted" className="lead">
60
+ A React app with Halation already in place: the styles, the components, and the rules that keep every page consistent. Edit src/App.tsx and this page changes as you save.
61
+ </Text>
62
+ <div className="actions">
63
+ <Button variant="ink" shape="pill" size="lg" onClick={() => setAdding(true)}>
64
+ Add a task
65
+ </Button>
66
+ <Button shape="pill" size="lg" onClick={() => document.getElementById("checklist")?.scrollIntoView({ behavior: "smooth" })}>
67
+ Go to the checklist
68
+ </Button>
69
+ </div>
70
+ </Rise>
71
+ </Stage>
72
+
73
+ <main className="page">
74
+ <section id="checklist" className="checklist" aria-labelledby="checklist-title">
75
+ <div className="section-head">
76
+ <Heading level={2} size="title-2" id="checklist-title">
77
+ Getting started
78
+ </Heading>
79
+ <Text size="body-sm" tone="muted">
80
+ <Count value={done} /> of {tasks.length} done
81
+ </Text>
82
+ </div>
83
+ <Surface elevation="raised" padding="none">
84
+ <List>
85
+ {tasks.map((task) => (
86
+ <ListRow
87
+ key={task.id}
88
+ title={task.title}
89
+ detail={task.detail}
90
+ trailing={
91
+ <div className="row-end">
92
+ {task.status === "done" ? null : (
93
+ <Button variant="ghost" size="sm" onClick={() => markDone(task)}>
94
+ Mark done
95
+ </Button>
96
+ )}
97
+ <State kind={STATUS[task.status].kind}>{STATUS[task.status].label}</State>
98
+ </div>
99
+ }
100
+ />
101
+ ))}
102
+ </List>
103
+ </Surface>
104
+ <Text size="caption" tone="muted">
105
+ Press <Keys keys={["N"]} /> to add a task from anywhere on the page.
106
+ </Text>
107
+ </section>
108
+
109
+ <aside className="about" aria-labelledby="about-title">
110
+ <Heading level={2} size="title-3" id="about-title">
111
+ About this project
112
+ </Heading>
113
+ <Facts
114
+ items={[
115
+ ["Name", project.name],
116
+ ["Light", light],
117
+ ["Built with", "React, Vite and Halation"],
118
+ ["House rules", "Checked by pnpm lint"],
119
+ ]}
120
+ />
121
+ </aside>
122
+ </main>
123
+
124
+ <footer className="footer">
125
+ <Seal name={project.name} size={32} />
126
+ <Text size="caption" tone="muted">
127
+ This seal is drawn from the project's name. Hold <Keys keys={["Alt", "Shift"]} /> and click it to see how the page is put together.
128
+ </Text>
129
+ </footer>
130
+
131
+ <NewTaskDialog open={adding} onOpenChange={setAdding} onAdd={addTask} />
132
+ </>
133
+ )
134
+ }
@@ -0,0 +1,54 @@
1
+ import { Button, Dialog, Field, Input } from "@halation/react"
2
+ import { useState, type FormEvent } from "react"
3
+
4
+ type Props = {
5
+ open: boolean
6
+ onOpenChange: (open: boolean) => void
7
+ onAdd: (title: string) => void
8
+ }
9
+
10
+ /** A dialog is its own view, so its one main action is ink. */
11
+ export function NewTaskDialog({ open, onOpenChange, onAdd }: Props) {
12
+ const [title, setTitle] = useState("")
13
+ const [error, setError] = useState<string>()
14
+
15
+ const close = (next: boolean) => {
16
+ onOpenChange(next)
17
+ if (!next) setError(undefined)
18
+ }
19
+
20
+ const submit = (e: FormEvent<HTMLFormElement>) => {
21
+ e.preventDefault()
22
+ const name = title.trim()
23
+ if (!name) {
24
+ setError("Give the task a name first, for example “Write the pricing page”.")
25
+ return
26
+ }
27
+ onAdd(name)
28
+ setTitle("")
29
+ close(false)
30
+ }
31
+
32
+ return (
33
+ <Dialog.Root open={open} onOpenChange={close}>
34
+ <Dialog.Popup
35
+ title="Add a task"
36
+ description="It goes to the end of the checklist."
37
+ actions={
38
+ <>
39
+ <Dialog.Close render={<Button variant="ghost" />}>Cancel</Dialog.Close>
40
+ <Button variant="ink" type="submit" form="new-task">
41
+ Add task
42
+ </Button>
43
+ </>
44
+ }
45
+ >
46
+ <form id="new-task" onSubmit={submit} noValidate>
47
+ <Field label="Task" error={error}>
48
+ <Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Write the pricing page" autoFocus />
49
+ </Field>
50
+ </form>
51
+ </Dialog.Popup>
52
+ </Dialog.Root>
53
+ )
54
+ }
@@ -0,0 +1,101 @@
1
+ /* The page's layout. Color, type and motion come from Halation; this file
2
+ only places things, using the spacing token (--spacing is 4px). */
3
+
4
+ .hero {
5
+ display: grid;
6
+ align-items: end;
7
+ min-height: min(80svh, 760px);
8
+ padding: calc(var(--spacing) * 28) calc(var(--spacing) * 6) calc(var(--spacing) * 16);
9
+ }
10
+
11
+ .hero-copy,
12
+ .page,
13
+ .footer {
14
+ width: 100%;
15
+ max-width: 1080px;
16
+ margin-inline: auto;
17
+ }
18
+
19
+ .hero-copy {
20
+ display: grid;
21
+ justify-items: start;
22
+ gap: calc(var(--spacing) * 6);
23
+ }
24
+
25
+ .lead {
26
+ max-width: 38rem;
27
+ }
28
+
29
+ .actions {
30
+ display: flex;
31
+ flex-wrap: wrap;
32
+ gap: calc(var(--spacing) * 3);
33
+ padding-top: calc(var(--spacing) * 2);
34
+ }
35
+
36
+ .page {
37
+ display: grid;
38
+ grid-template-columns: minmax(0, 1fr) 18rem;
39
+ align-items: start;
40
+ gap: calc(var(--spacing) * 16);
41
+ padding: calc(var(--spacing) * 16) calc(var(--spacing) * 6);
42
+ }
43
+
44
+ .checklist {
45
+ display: grid;
46
+ gap: calc(var(--spacing) * 4);
47
+ scroll-margin-top: calc(var(--spacing) * 8);
48
+ }
49
+
50
+ .section-head {
51
+ display: flex;
52
+ flex-wrap: wrap;
53
+ align-items: baseline;
54
+ justify-content: space-between;
55
+ gap: calc(var(--spacing) * 4);
56
+ }
57
+
58
+ .row-end {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ align-items: center;
62
+ justify-content: flex-end;
63
+ gap: calc(var(--spacing) * 2);
64
+ }
65
+
66
+ .about {
67
+ display: grid;
68
+ gap: calc(var(--spacing) * 4);
69
+ }
70
+
71
+ .footer {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: calc(var(--spacing) * 3);
75
+ padding: calc(var(--spacing) * 8) calc(var(--spacing) * 6) calc(var(--spacing) * 12);
76
+ box-shadow: inset 0 1px 0 var(--color-line);
77
+ }
78
+
79
+ @media (max-width: 760px) {
80
+ .hero {
81
+ min-height: 70svh;
82
+ padding-inline: calc(var(--spacing) * 4);
83
+ }
84
+ .page {
85
+ grid-template-columns: minmax(0, 1fr);
86
+ gap: calc(var(--spacing) * 12);
87
+ padding-inline: calc(var(--spacing) * 4);
88
+ }
89
+ .footer {
90
+ align-items: flex-start;
91
+ padding-inline: calc(var(--spacing) * 4);
92
+ }
93
+ }
94
+
95
+ @media (max-width: 520px) {
96
+ .row-end {
97
+ flex-direction: column-reverse;
98
+ align-items: flex-end;
99
+ gap: var(--spacing);
100
+ }
101
+ }
@@ -0,0 +1,15 @@
1
+ import "@halation/react/styles.css"
2
+ import "./app.css"
3
+ import { HalationProvider } from "@halation/react"
4
+ import { StrictMode } from "react"
5
+ import { createRoot } from "react-dom/client"
6
+ import { App } from "./App.tsx"
7
+ import { project } from "./project.ts"
8
+
9
+ createRoot(document.getElementById("root")!).render(
10
+ <StrictMode>
11
+ <HalationProvider name={project.name}>
12
+ <App />
13
+ </HalationProvider>
14
+ </StrictMode>,
15
+ )
@@ -0,0 +1,10 @@
1
+ import type { PhenomenonName } from "@halation/react"
2
+
3
+ /**
4
+ * The project's name and the light behind its opening section. The seal,
5
+ * the grain and the console greeting are all drawn from the name.
6
+ */
7
+ export const project: { name: string; phenomenon: PhenomenonName } = {
8
+ name: "My project",
9
+ phenomenon: "rays",
10
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "types": ["vite/client"],
9
+ "strict": true,
10
+ "noEmit": true,
11
+ "skipLibCheck": true,
12
+ "isolatedModules": true,
13
+ "verbatimModuleSyntax": true,
14
+ "allowImportingTsExtensions": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true
18
+ },
19
+ "include": ["src"]
20
+ }
@@ -0,0 +1,6 @@
1
+ import react from "@vitejs/plugin-react"
2
+ import { defineConfig } from "vite"
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ })