diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 6735bbb..246207d 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -105,6 +105,12 @@ jobs:
- name: Check the example manifest
run: npm run examples:check
+ # Fails when a generated copy of the palette (the landing page's and the
+ # docs' tokens, the social cards, the Flutter demos, the logos) no longer
+ # matches palette.json; `npm run palette:generate` rewrites them.
+ - name: Check the palette
+ run: npm run palette:check
+
- name: Generate the example sources
run: npm run examples:generate
@@ -128,6 +134,14 @@ jobs:
run: dart pub get
working-directory: landing
+ # The components declare part files holding their class-name scopes, and
+ # main.server.dart imports the options jaspr_builder writes from the
+ # `@css` getters. Both are gitignored, so both have to exist before
+ # analysis.
+ - name: Generate the landing page's part files
+ run: dart run build_runner build --delete-conflicting-outputs
+ working-directory: landing
+
- name: Analyze the landing page
run: dart analyze --fatal-infos
working-directory: landing
diff --git a/docs_app/.prettierignore b/docs_app/.prettierignore
index c895d6c..bc90e49 100644
--- a/docs_app/.prettierignore
+++ b/docs_app/.prettierignore
@@ -14,6 +14,9 @@ flutter/
# Build output and codegen.
public/flutter-examples/
lib/flutter-examples/manifest.generated.ts
+# The palette, written by `npm run palette:generate` from palette.json.
+app/af-tokens.css
+lib/palette.generated.ts
# The landing page, built by Jaspr in ../landing and copied here.
public/index.html
public/landing.*
diff --git a/docs_app/README.md b/docs_app/README.md
index 28eb6d5..c63087e 100644
--- a/docs_app/README.md
+++ b/docs_app/README.md
@@ -7,10 +7,9 @@ AI-Provenance:
# advanced-forms-docs
The documentation site for `advanced_forms`, under `/docs`: built with [Fumadocs](https://fumadocs.dev) on Next.js,
-styled after the LeanCode design system used on [ciach.leancode.co](https://ciach.leancode.co), and running its own code
-examples in Flutter, in the browser. The landing page at `/` is a separate static site written in
-[Jaspr](https://jaspr.site), in the repo-root `landing/` folder; this app serves its build output from `public/`, so the
-two ship as one deployment on one domain.
+styled after the LeanCode design system, and running its own code examples in Flutter, in the browser. The landing page
+at `/` is a separate static site written in [Jaspr](https://jaspr.site), in the repo-root `landing/` folder; this app
+serves its build output from `public/`, so the two ship as one deployment on one domain.
MDX for the docs lives in the repo-root `docs/` folder; the landing page's live demos live in `content/landing/`. From
this directory:
@@ -25,17 +24,18 @@ changes; `npm run dev` without it serves the docs and 404s on `/`.
## Layout
-| Path | What lives there |
-| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
-| `../docs/*.mdx`, `../docs/meta.json` | The documentation pages and their order. Section separators are `---Name---` entries in `meta.json`. |
-| `content/landing/*.mdx` | The landing page's live demos: the Dart the Jaspr site shows and compiles, written like any docs example. |
-| `../landing/` | The landing page, a Jaspr static site. `scripts/landing.mjs` builds it and copies the result into `public/`. |
-| `app/docs/` | The notebook layout and the page renderer for `/docs/*`. |
-| `app/global.css` | The design system: tokens (`--af-*`), the Fumadocs variables they map onto, the example frame. |
-| `lib/shared.ts` | Routes, external URLs, site copy. |
-| `lib/layout.shared.tsx` | The header: logo (`components/nav-title.tsx`, a plain link to the landing page), links, GitHub. |
-| `components/mdx.tsx` | The components MDX can use: Fumadocs' set, plus `Tabs`, `Steps`, `Accordions`, `TypeTable` and `AdvancedFormsExample`. |
-| `app/og/` | Branded Open Graph images, one per docs page. |
+| Path | What lives there |
+| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| `../docs/*.mdx`, `../docs/meta.json` | The documentation pages and their order. Section separators are `---Name---` entries in `meta.json`. |
+| `content/landing/*.mdx` | The landing page's live demos: the Dart the Jaspr site shows and compiles, written like any docs example. |
+| `../landing/` | The landing page, a Jaspr static site. `scripts/landing.mjs` builds it and copies the result into `public/`. |
+| `app/docs/` | The notebook layout and the page renderer for `/docs/*`. |
+| `palette.json`, `scripts/palette.mjs` | The palette — the LeanCode swatches and the light and dark `--af-*` tokens — and the generator that writes every copy of it. |
+| `app/global.css` | The stylesheet: the Fumadocs variables mapped onto the `--af-*` tokens (generated into `app/af-tokens.css`), the example frame. |
+| `lib/shared.ts` | Routes, external URLs, site copy. |
+| `lib/layout.shared.tsx` | The header: logo (`components/nav-title.tsx`, a plain link to the landing page), links, GitHub. |
+| `components/mdx.tsx` | The components MDX can use: Fumadocs' set, plus `Tabs`, `Steps`, `Accordions`, `TypeTable` and `AdvancedFormsExample`. |
+| `app/og/` | Branded Open Graph images, one per docs page. |
## Live Flutter examples
@@ -134,14 +134,19 @@ debug asset server sends no CORS headers, unlike the release one.
## Design
-The stylesheet is hand-written on top of the Fumadocs preset. Tokens are CSS custom properties prefixed `--af-` at the
-top of `app/global.css` — the ciach palette: `#050505` ground, `#101013` surfaces, `#edff2f` accent, Space Grotesk and
-JetBrains Mono — and the `--color-fd-*` variables Fumadocs paints with are re-pointed at them. Dark is the design and
-the default; light is a paper variant where the accent is a fill with dark ink on it, the way leancode.co does it.
-
-Code blocks and live examples share one window frame: a title bar with three dots (the first one lime), a mono title,
-and the content below. The landing page in `../landing/web/landing.css` uses the same tokens and the same frame, so the
-homepage and the docs read as one site — change a colour in one place and mirror it in the other.
+The colors are the LeanCode design system — black ground, warm surfaces, one CTA yellow `#f0ff00` — and live in one
+place, `palette.json`: the swatches as the design system names them, and the light and dark themes as `--af-*` tokens
+built from them. `npm run palette:generate` writes every copy a consumer needs (`app/af-tokens.css` for this stylesheet,
+`lib/palette.generated.ts` for the social cards and the logo, `flutter/lib/support/palette.generated.dart` for the live
+demos, `../landing/lib/palette.generated.dart` for the landing page, which writes its own `--af-*` variables from it)
+and recolors the shapes marked `data-palette` in the logo and icon SVGs; `npm run palette:check` fails CI when they
+drift. The stylesheet is hand-written on top of the Fumadocs preset, with the `--color-fd-*` variables Fumadocs paints
+with pointed at the tokens. Space Grotesk and JetBrains Mono. Dark is the design and the default; light is a paper
+variant where the accent is a fill with black ink on it, the way leancode.co does it.
+
+Code blocks and live examples share one window frame: a title bar with three dots (the first one yellow), a mono title,
+and the content below. The landing page (`../landing/lib/styles.dart` writes the tokens from the generated palette, the
+components style the frame) uses the same tokens and the same frame, so the homepage and the docs read as one site.
## Deployment
diff --git a/docs_app/app/af-tokens.css b/docs_app/app/af-tokens.css
new file mode 100644
index 0000000..12761db
--- /dev/null
+++ b/docs_app/app/af-tokens.css
@@ -0,0 +1,82 @@
+/* Generated by docs_app/scripts/palette.mjs from docs_app/palette.json — do not edit. Run `npm run palette:generate` in docs_app after changing the palette. */
+
+:root {
+ /* Page background. */
+ --af-bg: #ffffff;
+
+ /* Section background: footer, code panels. */
+ --af-bg-2: #f7f7f5;
+
+ /* Cards and frames. */
+ --af-surface: #ffffff;
+
+ /* Bars and chips on a surface. */
+ --af-surface-2: #f2f2ef;
+
+ /* The hairline. */
+ --af-border: #e5e5e5;
+
+ /* The stronger hairline, for controls. */
+ --af-border-2: #cfcfcb;
+
+ /* Headings and primary text. */
+ --af-text: #000000;
+
+ /* Body copy. */
+ --af-text-2: #3f3f3c;
+
+ /* Captions and metadata. */
+ --af-muted: #6f6f6f;
+
+ /* The CTA yellow, as a fill. */
+ --af-accent: #f0ff00;
+
+ /* Text on the accent. */
+ --af-accent-ink: #000000;
+
+ /* The accent as text. */
+ --af-accent-text: #606a00;
+
+ /* The primary button's hover fill. */
+ --af-accent-hover: #f5ff4d;
+
+ /* The accent as a wash behind icons, pills and glows. */
+ --af-accent-soft: rgba(240, 255, 0, 0.45);
+
+ /* The docs' primary action fill. */
+ --af-primary: #000000;
+
+ /* Text on the primary fill. */
+ --af-primary-ink: #f0ff00;
+
+ /* The scrim behind dialogs. */
+ --af-overlay: rgba(0, 0, 0, 0.3);
+
+ /* Error signal. */
+ --af-danger: #be2119;
+
+ /* Success signal. */
+ --af-ok: #527b28;
+}
+
+.dark {
+ --af-bg: #000000;
+ --af-bg-2: #0a0a08;
+ --af-surface: #151513;
+ --af-surface-2: #1d1d1a;
+ --af-border: rgba(255, 255, 255, 0.12);
+ --af-border-2: rgba(255, 255, 255, 0.2);
+ --af-text: #ffffff;
+ --af-text-2: #d8d8d4;
+ --af-muted: #a3a3a0;
+ --af-accent: #f0ff00;
+ --af-accent-ink: #000000;
+ --af-accent-text: #f0ff00;
+ --af-accent-hover: #f5ff4d;
+ --af-accent-soft: rgba(240, 255, 0, 0.06);
+ --af-primary: #f0ff00;
+ --af-primary-ink: #000000;
+ --af-overlay: rgba(0, 0, 0, 0.6);
+ --af-danger: #e64239;
+ --af-ok: #80c340;
+}
diff --git a/docs_app/app/global.css b/docs_app/app/global.css
index e958534..63ab357 100644
--- a/docs_app/app/global.css
+++ b/docs_app/app/global.css
@@ -3,35 +3,20 @@
* model: Claude Fable 5.1
* harness: Claude Code
*
- * The LeanCode design system as used on ciach.leancode.co — near-black
- * surfaces, one lime accent, Space Grotesk and JetBrains Mono — mapped onto
- * the variables Fumadocs paints with. Dark is the design; light is a paper
- * variant that keeps the same accent as a fill (black ink on lime), the way
- * leancode.co does it.
+ * The LeanCode design system — black surfaces, one CTA yellow, Space Grotesk
+ * and JetBrains Mono — mapped onto the variables Fumadocs paints with. Dark is
+ * the design; light is a paper variant that keeps the same accent as a fill
+ * (black ink on yellow), the way leancode.co does it. The colors themselves
+ * are the `--af-*` variables of af-tokens.css, generated from ../palette.json
+ * and shared with the landing page; this file holds everything else.
*/
@import "tailwindcss";
@import "fumadocs-ui/css/preset.css";
+@import "./af-tokens.css";
/* ---------- Tokens ---------- */
:root {
- --af-bg: #f6f6f2;
- --af-bg-2: #ffffff;
- --af-surface: #ffffff;
- --af-surface-2: #efefe9;
- --af-border: #e2e2dc;
- --af-border-2: #cfcfc7;
- --af-text: #0b0b0d;
- --af-text-2: #4b4b52;
- --af-muted: #7d7d84;
- --af-accent: #edff2f;
- --af-accent-2: #c9dc00;
- --af-accent-ink: #0b0c00;
- /* Accent used *as text* on a light ground has to be darker to read. */
- --af-accent-text: #5f6e00;
- --af-accent-soft: rgba(237, 255, 47, 0.45);
- --af-danger: #d93636;
- --af-ok: #1f9d55;
--af-shadow: 0 24px 60px -32px rgba(0, 0, 0, 0.25);
--af-radius: 14px;
--af-radius-sm: 8px;
@@ -40,78 +25,43 @@
}
.dark {
- --af-bg: #050505;
- --af-bg-2: #0b0b0d;
- --af-surface: #101013;
- --af-surface-2: #16161b;
- --af-border: #23232b;
- --af-border-2: #33333e;
- --af-text: #f4f4f1;
- --af-text-2: #b7b7b3;
- --af-muted: #7d7d84;
- --af-accent: #edff2f;
- --af-accent-2: #c9dc00;
- --af-accent-ink: #0b0c00;
- --af-accent-text: #edff2f;
- --af-accent-soft: rgba(237, 255, 47, 0.12);
- --af-danger: #ff5d5d;
- --af-ok: #58e08a;
--af-shadow: 0 24px 60px -32px rgba(0, 0, 0, 0.9);
}
-/* Fumadocs paints with these. Light values in @theme so Tailwind emits the
- utilities; the dark block re-points the same variables. */
-@theme {
- --color-fd-background: #f6f6f2;
- --color-fd-foreground: #0b0b0d;
- --color-fd-muted: #efefe9;
- --color-fd-muted-foreground: #55555c;
- --color-fd-popover: #ffffff;
- --color-fd-popover-foreground: #0b0b0d;
- --color-fd-card: #ffffff;
- --color-fd-card-foreground: #0b0b0d;
- --color-fd-border: #e2e2dc;
- --color-fd-primary: #0b0b0d;
- --color-fd-primary-foreground: #edff2f;
- --color-fd-secondary: #efefe9;
- --color-fd-secondary-foreground: #0b0b0d;
- --color-fd-accent: rgba(11, 11, 13, 0.06);
- --color-fd-accent-foreground: #0b0b0d;
- --color-fd-ring: #0b0b0d;
- --color-fd-overlay: rgba(0, 0, 0, 0.3);
-}
-
-.dark {
- --color-fd-background: #050505;
- --color-fd-foreground: #f4f4f1;
- --color-fd-muted: #16161b;
- --color-fd-muted-foreground: #a8a8a4;
- --color-fd-popover: #101013;
- --color-fd-popover-foreground: #f4f4f1;
- --color-fd-card: #101013;
- --color-fd-card-foreground: #f4f4f1;
- --color-fd-border: #23232b;
- --color-fd-primary: #edff2f;
- --color-fd-primary-foreground: #0b0c00;
- --color-fd-secondary: #16161b;
- --color-fd-secondary-foreground: #f4f4f1;
- --color-fd-accent: rgba(255, 255, 255, 0.06);
- --color-fd-accent-foreground: #f4f4f1;
- --color-fd-ring: #edff2f;
- --color-fd-overlay: rgba(0, 0, 0, 0.6);
-}
-
-@theme static {
+/* Fumadocs paints with these. Inline, so the utilities point at the `--af-*`
+ variables and follow the theme with them; no dark block needed. */
+@theme inline {
+ --color-fd-background: var(--af-bg);
+ --color-fd-foreground: var(--af-text);
+ --color-fd-muted: var(--af-surface-2);
+ --color-fd-muted-foreground: var(--af-muted);
+ --color-fd-popover: var(--af-surface);
+ --color-fd-popover-foreground: var(--af-text);
+ --color-fd-card: var(--af-surface);
+ --color-fd-card-foreground: var(--af-text);
+ --color-fd-border: var(--af-border);
+ --color-fd-primary: var(--af-primary);
+ --color-fd-primary-foreground: var(--af-primary-ink);
+ --color-fd-secondary: var(--af-surface-2);
+ --color-fd-secondary-foreground: var(--af-text);
+ --color-fd-accent: color-mix(in srgb, var(--af-text) 6%, transparent);
+ --color-fd-accent-foreground: var(--af-text);
+ --color-fd-ring: var(--af-primary);
+ --color-fd-overlay: var(--af-overlay);
+}
+
+@theme inline static {
+ /* Info and warning are the code palette's blue and amber, not brand colors. */
--color-fd-info: #82aaff;
--color-fd-warning: #ffcb6b;
- --color-fd-error: #ff5d5d;
- --color-fd-success: #58e08a;
- --color-fd-idea: #edff2f;
+ --color-fd-error: var(--af-danger);
+ --color-fd-success: var(--af-ok);
+ --color-fd-idea: var(--af-accent);
/* Diff markers in code blocks; the preset's Shiki styles apply these. */
- --color-fd-diff-remove: rgba(255, 93, 93, 0.12);
- --color-fd-diff-remove-symbol: #ff5d5d;
- --color-fd-diff-add: rgba(88, 224, 138, 0.12);
- --color-fd-diff-add-symbol: #58e08a;
+ --color-fd-diff-remove: color-mix(in srgb, var(--af-danger) 12%, transparent);
+ --color-fd-diff-remove-symbol: var(--af-danger);
+ --color-fd-diff-add: color-mix(in srgb, var(--af-ok) 12%, transparent);
+ --color-fd-diff-add-symbol: var(--af-ok);
}
@theme inline {
@@ -247,7 +197,7 @@ kbd {
content: none;
}
-/* ---------- Code blocks: the ciach window frame ---------- */
+/* ---------- Code blocks: the window frame ---------- */
.prose figure.shiki,
.af-landing figure.shiki {
diff --git a/docs_app/app/icon.svg b/docs_app/app/icon.svg
index ba21bea..8c45ed3 100644
--- a/docs_app/app/icon.svg
+++ b/docs_app/app/icon.svg
@@ -2,6 +2,6 @@
-
-
+
+
diff --git a/docs_app/app/og/[...slug]/route.tsx b/docs_app/app/og/[...slug]/route.tsx
index eb1fec4..24b9be7 100644
--- a/docs_app/app/og/[...slug]/route.tsx
+++ b/docs_app/app/og/[...slug]/route.tsx
@@ -7,16 +7,12 @@ import { getPageImageUrl, source } from "@/lib/source"
import { notFound } from "next/navigation"
import { ImageResponse } from "next/og"
import { LogoMark } from "@/components/logo"
+import { dark, withAlpha } from "@/lib/palette"
import { appName } from "@/lib/shared"
export const revalidate = false
-const ink = "#050505"
-const surface = "#101013"
-const border = "#23232b"
-const text = "#f4f4f1"
-const muted = "#b7b7b3"
-const accent = "#edff2f"
+const { bg: ink, surface, border, text, muted, accent } = dark
export async function GET(_req: Request, { params }: RouteContext<"/og/[...slug]">) {
const { slug } = await params
@@ -32,7 +28,7 @@ export async function GET(_req: Request, { params }: RouteContext<"/og/[...slug]
flexDirection: "column",
justifyContent: "space-between",
padding: 64,
- background: `radial-gradient(60% 50% at 85% 10%, rgba(237, 255, 47, 0.18), transparent 65%), ${ink}`,
+ background: `radial-gradient(60% 50% at 85% 10%, ${withAlpha(accent, 0.18)}, transparent 65%), ${ink}`,
color: text,
fontFamily: "sans-serif",
}}>
diff --git a/docs_app/app/og/landing.png/route.tsx b/docs_app/app/og/landing.png/route.tsx
index ff178dd..5094dd2 100644
--- a/docs_app/app/og/landing.png/route.tsx
+++ b/docs_app/app/og/landing.png/route.tsx
@@ -5,6 +5,7 @@
*/
import { ImageResponse } from "next/og"
import { LogoMark } from "@/components/logo"
+import { dark, withAlpha } from "@/lib/palette"
import { appName, description } from "@/lib/shared"
/**
@@ -14,12 +15,7 @@ import { appName, description } from "@/lib/shared"
*/
export const dynamic = "force-static"
-const ink = "#050505"
-const surface = "#101013"
-const border = "#23232b"
-const text = "#f4f4f1"
-const muted = "#b7b7b3"
-const accent = "#edff2f"
+const { bg: ink, surface, border, text, muted, accent } = dark
export function GET() {
return new ImageResponse(
@@ -31,7 +27,7 @@ export function GET() {
flexDirection: "column",
justifyContent: "space-between",
padding: 64,
- background: `radial-gradient(60% 50% at 85% 10%, rgba(237, 255, 47, 0.18), transparent 65%), ${ink}`,
+ background: `radial-gradient(60% 50% at 85% 10%, ${withAlpha(accent, 0.18)}, transparent 65%), ${ink}`,
color: text,
fontFamily: "sans-serif",
}}>
diff --git a/docs_app/components/logo.tsx b/docs_app/components/logo.tsx
index a7ad58a..0968d0f 100644
--- a/docs_app/components/logo.tsx
+++ b/docs_app/components/logo.tsx
@@ -5,6 +5,7 @@
*/
import logoDark from "@/public/logo-dark.svg"
import logoLight from "@/public/logo-light.svg"
+import { swatches } from "@/lib/palette"
import { appName } from "@/lib/shared"
import { cn } from "@/lib/cn"
@@ -48,8 +49,8 @@ export function LogoMark({ size = 56 }: { size?: number }) {
transform="matrix(0.5 0.866025 0.866025 -0.5 209.527 183.354)"
fill="white"
/>
-
-
+
+
)
}
diff --git a/docs_app/eslint.config.mjs b/docs_app/eslint.config.mjs
index f9c05de..72cce56 100644
--- a/docs_app/eslint.config.mjs
+++ b/docs_app/eslint.config.mjs
@@ -23,6 +23,7 @@ const config = [
"flutter/**",
"public/flutter-examples/**",
"lib/flutter-examples/manifest.generated.ts",
+ "lib/palette.generated.ts",
// The landing page, built by Jaspr in ../landing and copied here.
"public/index.html",
"public/landing.*",
diff --git a/docs_app/flutter/lib/support/island_frame.dart b/docs_app/flutter/lib/support/island_frame.dart
index c367fb4..2b1f8cb 100644
--- a/docs_app/flutter/lib/support/island_frame.dart
+++ b/docs_app/flutter/lib/support/island_frame.dart
@@ -5,6 +5,7 @@
* edited-by: Claude Fable 5.1 (Claude Code)
*/
import 'package:advanced_forms_docs_islands/support/example_log.dart';
+import 'package:advanced_forms_docs_islands/support/palette.generated.dart';
import 'package:flutter/material.dart';
/// The part of an island that has nothing to do with the browser.
@@ -88,32 +89,36 @@ class _IslandFrameState extends State {
/// The LeanCode design system, as a Material theme.
///
-/// The same tokens the docs page paints with: near-black surfaces, one lime
-/// accent, rounded filled inputs. In the light variant the accent becomes a
-/// fill with dark ink on it — a lime *text* would not be legible on paper.
+/// The same `--af-*` tokens the docs page paints with, from
+/// `palette.generated.dart`: black surfaces, one CTA yellow, rounded filled
+/// inputs. In the light variant the accent becomes a fill with black ink on
+/// it — a yellow *text* would not be legible on paper.
ThemeData islandTheme(Brightness brightness) {
final dark = brightness == Brightness.dark;
-
- const accent = Color(0xFFEDFF2F);
- const accentInk = Color(0xFF0B0C00);
- final ink = dark ? const Color(0xFFF4F4F1) : const Color(0xFF0B0B0D);
- final ink2 = dark ? const Color(0xFFB7B7B3) : const Color(0xFF4B4B52);
- const muted = Color(0xFF7D7D84);
- final surface = dark ? const Color(0xFF101013) : Colors.white;
- final surface2 = dark ? const Color(0xFF16161B) : const Color(0xFFEFEFE9);
- final border = dark ? const Color(0xFF33333E) : const Color(0xFFCFCFC7);
- final borderSoft = dark ? const Color(0xFF23232B) : const Color(0xFFE2E2DC);
- final danger = dark ? const Color(0xFFFF5D5D) : const Color(0xFFD93636);
- final ok = dark ? const Color(0xFF58E08A) : const Color(0xFF1F9D55);
-
- final primary = dark ? accent : const Color(0xFF0B0B0D);
- final onPrimary = dark ? accentInk : accent;
+ final AfTheme(
+ :accent,
+ :accentInk,
+ :accentSoft,
+ text: ink,
+ text2: ink2,
+ :muted,
+ :surface,
+ :surface2,
+ border2: border,
+ border: borderSoft,
+ :danger,
+ :ok,
+ :primary,
+ primaryInk: onPrimary,
+ ) = dark
+ ? afDark
+ : afLight;
final scheme = ColorScheme(
brightness: brightness,
primary: primary,
onPrimary: onPrimary,
- primaryContainer: accent.withValues(alpha: dark ? 0.16 : 0.45),
+ primaryContainer: accentSoft,
onPrimaryContainer: ink,
secondary: accent,
onSecondary: accentInk,
@@ -137,7 +142,7 @@ ThemeData islandTheme(Brightness brightness) {
scrim: Colors.black,
inverseSurface: ink,
onInverseSurface: surface,
- inversePrimary: dark ? const Color(0xFF0B0B0D) : accent,
+ inversePrimary: (dark ? afLight : afDark).primary,
);
final rounded = RoundedRectangleBorder(
@@ -161,10 +166,10 @@ ThemeData islandTheme(Brightness brightness) {
fillColor: surface2,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
- hintStyle: const TextStyle(color: muted),
+ hintStyle: TextStyle(color: muted),
labelStyle: TextStyle(color: ink2),
floatingLabelStyle: TextStyle(color: primary),
- helperStyle: const TextStyle(color: muted),
+ helperStyle: TextStyle(color: muted),
errorStyle: TextStyle(color: danger),
prefixIconColor: muted,
suffixIconColor: muted,
diff --git a/docs_app/flutter/lib/support/palette.generated.dart b/docs_app/flutter/lib/support/palette.generated.dart
new file mode 100644
index 0000000..7f47af6
--- /dev/null
+++ b/docs_app/flutter/lib/support/palette.generated.dart
@@ -0,0 +1,210 @@
+// Generated by docs_app/scripts/palette.mjs from docs_app/palette.json — do
+// not edit. Run `npm run palette:generate` in docs_app after changing the
+// palette.
+
+import 'dart:ui' show Color;
+
+/// The LeanCode swatches, as `palette.json` names them, each a packed
+/// `0xRRGGBB`.
+enum Palette {
+ /// Primary surface. LeanCode is black-first; yellow is the exception.
+ black(0x000000),
+
+ /// Text and headings on black; the light document surface.
+ white(0xffffff),
+
+ /// Call to action and emphasis only. Text on it is always black.
+ ctaYellow(0xf0ff00),
+
+ /// Body copy on dark surfaces.
+ bodyGray(0xd8d8d4),
+
+ /// Secondary text, captions, metadata on dark.
+ mutedGray(0xa3a3a0),
+
+ /// Secondary text, captions, metadata on light: the design system's #757575,
+ /// a step darker so it also reads AA on paper.
+ mutedOnLight(0x6f6f6f),
+
+ /// Cards and panels on black, warm-tinted.
+ surface(0x151513),
+
+ /// A second surface step on black.
+ surface2(0x1d1d1a),
+
+ /// The 1px hairline on white.
+ lineOnLight(0xe5e5e5),
+
+ /// Error signal, sparingly.
+ error(0xe64239),
+
+ /// Success signal, sparingly.
+ success(0x80c340),
+
+ /// Site tint: a section background a step above black.
+ nearBlack(0x0a0a08),
+
+ /// Site tint: a section background a step below white.
+ paper(0xf7f7f5),
+
+ /// Site tint: a second surface step on white.
+ paper2(0xf2f2ef),
+
+ /// Site tint: body copy on light surfaces.
+ bodyOnLight(0x3f3f3c),
+
+ /// Site tint: the stronger hairline on white, for controls.
+ lineStrongOnLight(0xcfcfcb),
+
+ /// Site tint: the CTA yellow lifted, for the primary button's hover.
+ ctaYellowLight(0xf5ff4d),
+
+ /// Site tint: the CTA yellow as text on white, which yellow itself never is.
+ ctaYellowOnLight(0x606a00),
+
+ /// Site tint: the error signal as text on white.
+ errorOnLight(0xbe2119),
+
+ /// Site tint: the success signal as text on white.
+ successOnLight(0x527b28);
+
+ const Palette(this.rgb);
+
+ final int rgb;
+
+ Color get color => Color(0xff000000 | rgb);
+
+ /// `#rrggbb`.
+ String get hex => '#${rgb.toRadixString(16).padLeft(6, '0')}';
+
+ Color alpha(double alpha) => color.withValues(alpha: alpha);
+}
+
+/// The site's `--af-*` tokens for one theme, as the docs and the landing
+/// page paint them.
+class AfTheme {
+ const AfTheme({
+ required this.bg,
+ required this.bg2,
+ required this.surface,
+ required this.surface2,
+ required this.border,
+ required this.border2,
+ required this.text,
+ required this.text2,
+ required this.muted,
+ required this.accent,
+ required this.accentInk,
+ required this.accentText,
+ required this.accentHover,
+ required this.accentSoft,
+ required this.primary,
+ required this.primaryInk,
+ required this.overlay,
+ required this.danger,
+ required this.ok,
+ });
+
+ /// Page background.
+ final Color bg;
+
+ /// Section background: footer, code panels.
+ final Color bg2;
+
+ /// Cards and frames.
+ final Color surface;
+
+ /// Bars and chips on a surface.
+ final Color surface2;
+
+ /// The hairline.
+ final Color border;
+
+ /// The stronger hairline, for controls.
+ final Color border2;
+
+ /// Headings and primary text.
+ final Color text;
+
+ /// Body copy.
+ final Color text2;
+
+ /// Captions and metadata.
+ final Color muted;
+
+ /// The CTA yellow, as a fill.
+ final Color accent;
+
+ /// Text on the accent.
+ final Color accentInk;
+
+ /// The accent as text.
+ final Color accentText;
+
+ /// The primary button's hover fill.
+ final Color accentHover;
+
+ /// The accent as a wash behind icons, pills and glows.
+ final Color accentSoft;
+
+ /// The docs' primary action fill.
+ final Color primary;
+
+ /// Text on the primary fill.
+ final Color primaryInk;
+
+ /// The scrim behind dialogs.
+ final Color overlay;
+
+ /// Error signal.
+ final Color danger;
+
+ /// Success signal.
+ final Color ok;
+}
+
+/// The light theme.
+final afLight = AfTheme(
+ bg: Palette.white.color,
+ bg2: Palette.paper.color,
+ surface: Palette.white.color,
+ surface2: Palette.paper2.color,
+ border: Palette.lineOnLight.color,
+ border2: Palette.lineStrongOnLight.color,
+ text: Palette.black.color,
+ text2: Palette.bodyOnLight.color,
+ muted: Palette.mutedOnLight.color,
+ accent: Palette.ctaYellow.color,
+ accentInk: Palette.black.color,
+ accentText: Palette.ctaYellowOnLight.color,
+ accentHover: Palette.ctaYellowLight.color,
+ accentSoft: Palette.ctaYellow.alpha(0.45),
+ primary: Palette.black.color,
+ primaryInk: Palette.ctaYellow.color,
+ overlay: Palette.black.alpha(0.3),
+ danger: Palette.errorOnLight.color,
+ ok: Palette.successOnLight.color,
+);
+
+/// The dark theme.
+final afDark = AfTheme(
+ bg: Palette.black.color,
+ bg2: Palette.nearBlack.color,
+ surface: Palette.surface.color,
+ surface2: Palette.surface2.color,
+ border: Palette.white.alpha(0.12),
+ border2: Palette.white.alpha(0.2),
+ text: Palette.white.color,
+ text2: Palette.bodyGray.color,
+ muted: Palette.mutedGray.color,
+ accent: Palette.ctaYellow.color,
+ accentInk: Palette.black.color,
+ accentText: Palette.ctaYellow.color,
+ accentHover: Palette.ctaYellowLight.color,
+ accentSoft: Palette.ctaYellow.alpha(0.06),
+ primary: Palette.ctaYellow.color,
+ primaryInk: Palette.black.color,
+ overlay: Palette.black.alpha(0.6),
+ danger: Palette.error.color,
+ ok: Palette.success.color,
+);
diff --git a/docs_app/lib/palette.generated.ts b/docs_app/lib/palette.generated.ts
new file mode 100644
index 0000000..0d11156
--- /dev/null
+++ b/docs_app/lib/palette.generated.ts
@@ -0,0 +1,135 @@
+// Generated by scripts/palette.mjs from palette.json — do not edit. Run `npm run palette:generate` in docs_app after changing the palette.
+
+/** The LeanCode swatches, as `#rrggbb`. */
+export const swatches = {
+ /** Primary surface. LeanCode is black-first; yellow is the exception. */
+ black: "#000000",
+ /** Text and headings on black; the light document surface. */
+ white: "#ffffff",
+ /** Call to action and emphasis only. Text on it is always black. */
+ ctaYellow: "#f0ff00",
+ /** Body copy on dark surfaces. */
+ bodyGray: "#d8d8d4",
+ /** Secondary text, captions, metadata on dark. */
+ mutedGray: "#a3a3a0",
+ /** Secondary text, captions, metadata on light: the design system's #757575, a step darker so it also reads AA on paper. */
+ mutedOnLight: "#6f6f6f",
+ /** Cards and panels on black, warm-tinted. */
+ surface: "#151513",
+ /** A second surface step on black. */
+ surface2: "#1d1d1a",
+ /** The 1px hairline on white. */
+ lineOnLight: "#e5e5e5",
+ /** Error signal, sparingly. */
+ error: "#e64239",
+ /** Success signal, sparingly. */
+ success: "#80c340",
+ /** Site tint: a section background a step above black. */
+ nearBlack: "#0a0a08",
+ /** Site tint: a section background a step below white. */
+ paper: "#f7f7f5",
+ /** Site tint: a second surface step on white. */
+ paper2: "#f2f2ef",
+ /** Site tint: body copy on light surfaces. */
+ bodyOnLight: "#3f3f3c",
+ /** Site tint: the stronger hairline on white, for controls. */
+ lineStrongOnLight: "#cfcfcb",
+ /** Site tint: the CTA yellow lifted, for the primary button's hover. */
+ ctaYellowLight: "#f5ff4d",
+ /** Site tint: the CTA yellow as text on white, which yellow itself never is. */
+ ctaYellowOnLight: "#606a00",
+ /** Site tint: the error signal as text on white. */
+ errorOnLight: "#be2119",
+ /** Site tint: the success signal as text on white. */
+ successOnLight: "#527b28",
+} as const
+
+/** `#rrggbb` at an alpha, as `rgba()` — the one color form every renderer of ours understands. */
+export function withAlpha(hex: string, alpha: number): string {
+ const [r, g, b] = [1, 3, 5].map(i => Number.parseInt(hex.slice(i, i + 2), 16))
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
+}
+
+/** The site's `--af-*` tokens per theme, built from the swatches. */
+export const themes = {
+ light: {
+ /** Page background. */
+ bg: swatches.white,
+ /** Section background: footer, code panels. */
+ bg2: swatches.paper,
+ /** Cards and frames. */
+ surface: swatches.white,
+ /** Bars and chips on a surface. */
+ surface2: swatches.paper2,
+ /** The hairline. */
+ border: swatches.lineOnLight,
+ /** The stronger hairline, for controls. */
+ border2: swatches.lineStrongOnLight,
+ /** Headings and primary text. */
+ text: swatches.black,
+ /** Body copy. */
+ text2: swatches.bodyOnLight,
+ /** Captions and metadata. */
+ muted: swatches.mutedOnLight,
+ /** The CTA yellow, as a fill. */
+ accent: swatches.ctaYellow,
+ /** Text on the accent. */
+ accentInk: swatches.black,
+ /** The accent as text. */
+ accentText: swatches.ctaYellowOnLight,
+ /** The primary button's hover fill. */
+ accentHover: swatches.ctaYellowLight,
+ /** The accent as a wash behind icons, pills and glows. */
+ accentSoft: withAlpha(swatches.ctaYellow, 0.45),
+ /** The docs' primary action fill. */
+ primary: swatches.black,
+ /** Text on the primary fill. */
+ primaryInk: swatches.ctaYellow,
+ /** The scrim behind dialogs. */
+ overlay: withAlpha(swatches.black, 0.3),
+ /** Error signal. */
+ danger: swatches.errorOnLight,
+ /** Success signal. */
+ ok: swatches.successOnLight,
+ },
+ dark: {
+ /** Page background. */
+ bg: swatches.black,
+ /** Section background: footer, code panels. */
+ bg2: swatches.nearBlack,
+ /** Cards and frames. */
+ surface: swatches.surface,
+ /** Bars and chips on a surface. */
+ surface2: swatches.surface2,
+ /** The hairline. */
+ border: withAlpha(swatches.white, 0.12),
+ /** The stronger hairline, for controls. */
+ border2: withAlpha(swatches.white, 0.2),
+ /** Headings and primary text. */
+ text: swatches.white,
+ /** Body copy. */
+ text2: swatches.bodyGray,
+ /** Captions and metadata. */
+ muted: swatches.mutedGray,
+ /** The CTA yellow, as a fill. */
+ accent: swatches.ctaYellow,
+ /** Text on the accent. */
+ accentInk: swatches.black,
+ /** The accent as text. */
+ accentText: swatches.ctaYellow,
+ /** The primary button's hover fill. */
+ accentHover: swatches.ctaYellowLight,
+ /** The accent as a wash behind icons, pills and glows. */
+ accentSoft: withAlpha(swatches.ctaYellow, 0.06),
+ /** The docs' primary action fill. */
+ primary: swatches.ctaYellow,
+ /** Text on the primary fill. */
+ primaryInk: swatches.black,
+ /** The scrim behind dialogs. */
+ overlay: withAlpha(swatches.black, 0.6),
+ /** Error signal. */
+ danger: swatches.error,
+ /** Success signal. */
+ ok: swatches.success,
+ },
+} as const
diff --git a/docs_app/lib/palette.ts b/docs_app/lib/palette.ts
new file mode 100644
index 0000000..3d1c2f6
--- /dev/null
+++ b/docs_app/lib/palette.ts
@@ -0,0 +1,11 @@
+/*
+ * AI-Provenance:
+ * model: Claude Fable 5.1
+ * harness: Claude Code
+ */
+import { themes } from "@/lib/palette.generated"
+
+export { swatches, themes, withAlpha } from "@/lib/palette.generated"
+
+/** The dark theme: what the social cards are drawn in, whatever the reader's theme. */
+export const dark = themes.dark
diff --git a/docs_app/package.json b/docs_app/package.json
index 1301013..a8f28bc 100644
--- a/docs_app/package.json
+++ b/docs_app/package.json
@@ -18,7 +18,9 @@
"examples:format:check": "node scripts/format-snippets.mjs --check",
"examples:build": "node scripts/flutter-examples.mjs build",
"examples:serve": "cd flutter && flutter run -d web-server --web-port 5333",
- "landing:build": "node scripts/landing.mjs"
+ "landing:build": "node scripts/landing.mjs",
+ "palette:generate": "node scripts/palette.mjs generate",
+ "palette:check": "node scripts/palette.mjs check"
},
"dependencies": {
"cnfast": "^0.1.0",
diff --git a/docs_app/palette.json b/docs_app/palette.json
new file mode 100644
index 0000000..c798401
--- /dev/null
+++ b/docs_app/palette.json
@@ -0,0 +1,96 @@
+{
+ "swatches": {
+ "black": { "hex": "#000000", "note": "Primary surface. LeanCode is black-first; yellow is the exception." },
+ "white": { "hex": "#ffffff", "note": "Text and headings on black; the light document surface." },
+ "ctaYellow": { "hex": "#f0ff00", "note": "Call to action and emphasis only. Text on it is always black." },
+ "bodyGray": { "hex": "#d8d8d4", "note": "Body copy on dark surfaces." },
+ "mutedGray": { "hex": "#a3a3a0", "note": "Secondary text, captions, metadata on dark." },
+ "mutedOnLight": {
+ "hex": "#6f6f6f",
+ "note": "Secondary text, captions, metadata on light: the design system's #757575, a step darker so it also reads AA on paper."
+ },
+ "surface": { "hex": "#151513", "note": "Cards and panels on black, warm-tinted." },
+ "surface2": { "hex": "#1d1d1a", "note": "A second surface step on black." },
+ "lineOnLight": { "hex": "#e5e5e5", "note": "The 1px hairline on white." },
+ "error": { "hex": "#e64239", "note": "Error signal, sparingly." },
+ "success": { "hex": "#80c340", "note": "Success signal, sparingly." },
+
+ "nearBlack": { "hex": "#0a0a08", "note": "Site tint: a section background a step above black." },
+ "paper": { "hex": "#f7f7f5", "note": "Site tint: a section background a step below white." },
+ "paper2": { "hex": "#f2f2ef", "note": "Site tint: a second surface step on white." },
+ "bodyOnLight": { "hex": "#3f3f3c", "note": "Site tint: body copy on light surfaces." },
+ "lineStrongOnLight": { "hex": "#cfcfcb", "note": "Site tint: the stronger hairline on white, for controls." },
+ "ctaYellowLight": { "hex": "#f5ff4d", "note": "Site tint: the CTA yellow lifted, for the primary button's hover." },
+ "ctaYellowOnLight": {
+ "hex": "#606a00",
+ "note": "Site tint: the CTA yellow as text on white, which yellow itself never is."
+ },
+ "errorOnLight": { "hex": "#be2119", "note": "Site tint: the error signal as text on white." },
+ "successOnLight": { "hex": "#527b28", "note": "Site tint: the success signal as text on white." }
+ },
+ "tokens": {
+ "bg": "Page background.",
+ "bg2": "Section background: footer, code panels.",
+ "surface": "Cards and frames.",
+ "surface2": "Bars and chips on a surface.",
+ "border": "The hairline.",
+ "border2": "The stronger hairline, for controls.",
+ "text": "Headings and primary text.",
+ "text2": "Body copy.",
+ "muted": "Captions and metadata.",
+ "accent": "The CTA yellow, as a fill.",
+ "accentInk": "Text on the accent.",
+ "accentText": "The accent as text.",
+ "accentHover": "The primary button's hover fill.",
+ "accentSoft": "The accent as a wash behind icons, pills and glows.",
+ "primary": "The docs' primary action fill.",
+ "primaryInk": "Text on the primary fill.",
+ "overlay": "The scrim behind dialogs.",
+ "danger": "Error signal.",
+ "ok": "Success signal."
+ },
+ "themes": {
+ "light": {
+ "bg": "white",
+ "bg2": "paper",
+ "surface": "white",
+ "surface2": "paper2",
+ "border": "lineOnLight",
+ "border2": "lineStrongOnLight",
+ "text": "black",
+ "text2": "bodyOnLight",
+ "muted": "mutedOnLight",
+ "accent": "ctaYellow",
+ "accentInk": "black",
+ "accentText": "ctaYellowOnLight",
+ "accentHover": "ctaYellowLight",
+ "accentSoft": { "of": "ctaYellow", "alpha": 0.45 },
+ "primary": "black",
+ "primaryInk": "ctaYellow",
+ "overlay": { "of": "black", "alpha": 0.3 },
+ "danger": "errorOnLight",
+ "ok": "successOnLight"
+ },
+ "dark": {
+ "bg": "black",
+ "bg2": "nearBlack",
+ "surface": "surface",
+ "surface2": "surface2",
+ "border": { "of": "white", "alpha": 0.12 },
+ "border2": { "of": "white", "alpha": 0.2 },
+ "text": "white",
+ "text2": "bodyGray",
+ "muted": "mutedGray",
+ "accent": "ctaYellow",
+ "accentInk": "black",
+ "accentText": "ctaYellow",
+ "accentHover": "ctaYellowLight",
+ "accentSoft": { "of": "ctaYellow", "alpha": 0.06 },
+ "primary": "ctaYellow",
+ "primaryInk": "black",
+ "overlay": { "of": "black", "alpha": 0.6 },
+ "danger": "error",
+ "ok": "success"
+ }
+ }
+}
diff --git a/docs_app/public/logo-dark.svg b/docs_app/public/logo-dark.svg
index ab96eb7..b532098 100644
--- a/docs_app/public/logo-dark.svg
+++ b/docs_app/public/logo-dark.svg
@@ -3,6 +3,6 @@
-
-
+
+
diff --git a/docs_app/public/logo-light.svg b/docs_app/public/logo-light.svg
index f02ae69..65b21b6 100644
--- a/docs_app/public/logo-light.svg
+++ b/docs_app/public/logo-light.svg
@@ -3,6 +3,6 @@
-
-
+
+
diff --git a/docs_app/scripts/landing.mjs b/docs_app/scripts/landing.mjs
index 31bf584..95b7180 100644
--- a/docs_app/scripts/landing.mjs
+++ b/docs_app/scripts/landing.mjs
@@ -28,8 +28,9 @@ const output = path.join(landing, "build", "jaspr")
const publicDir = path.join(docsApp, "public")
/**
- * What ends up in public/. Everything else in build/jaspr is build tooling —
- * including landing.css, which the page inlines into its .
+ * What ends up in public/. Everything else in build/jaspr is build tooling.
+ * There is no stylesheet: the styles are Dart (`@css` getters next to the
+ * components), and Jaspr inlines them into the page's .
*/
const artifacts = ["index.html", "landing.js", "landing-icon.svg", "fonts"]
diff --git a/docs_app/scripts/palette.mjs b/docs_app/scripts/palette.mjs
new file mode 100644
index 0000000..45bb399
--- /dev/null
+++ b/docs_app/scripts/palette.mjs
@@ -0,0 +1,314 @@
+#!/usr/bin/env node
+/*
+ * AI-Provenance:
+ * model: Claude Fable 5.1
+ * harness: Claude Code
+ */
+
+/**
+ * Writes every copy of the site's palette from palette.json, so the landing
+ * page, the docs, the social cards, the Flutter demos and the logos cannot
+ * disagree about a color. palette.json names the LeanCode swatches and maps
+ * them onto the `--af-*` tokens of the light and dark themes; this script
+ * turns that into each consumer's language.
+ *
+ * generate write the generated files and recolor the marked SVG elements
+ * check fail if any of them differs from what palette.json says
+ *
+ * Generated (committed, so every checkout builds without running this):
+ * app/af-tokens.css the tokens as CSS variables, for the docs' stylesheet
+ * lib/palette.generated.ts the swatches and tokens for the OG cards and the logo
+ * flutter/lib/support/palette.generated.dart the swatches and tokens as Flutter Colors, for the live demos
+ * ../landing/lib/palette.generated.dart the same as Jaspr Colors; the landing page writes its
+ * `--af-*` variables and theme-color from it at build time
+ *
+ * Recolored in place: SVG elements carrying `data-palette=""` get that
+ * swatch as their fill. The logos and icons stay single files of artwork; the
+ * attribute marks which shapes are brand color.
+ */
+import { readFile, writeFile } from "node:fs/promises"
+import { join, relative, resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const docsApp = resolve(fileURLToPath(new URL(".", import.meta.url)), "..")
+const repoRoot = resolve(docsApp, "..")
+const landing = join(repoRoot, "landing")
+const sourceFile = join(docsApp, "palette.json")
+
+const palette = JSON.parse(await readFile(sourceFile, "utf8"))
+const tokenNames = Object.keys(palette.tokens)
+
+for (const [theme, tokens] of Object.entries(palette.themes)) {
+ const missing = tokenNames.filter(name => !(name in tokens))
+ const extra = Object.keys(tokens).filter(name => !(name in palette.tokens))
+ if (missing.length || extra.length) {
+ throw new Error(
+ `palette.json: theme "${theme}" must define exactly the tokens listed under "tokens"` +
+ (missing.length ? `; missing ${missing.join(", ")}` : "") +
+ (extra.length ? `; unknown ${extra.join(", ")}` : ""),
+ )
+ }
+}
+
+// ---------- Colors ----------
+
+function swatch(name) {
+ const entry = palette.swatches[name]
+ if (!entry) throw new Error(`palette.json: unknown swatch "${name}"`)
+ if (!/^#[0-9a-f]{6}$/.test(entry.hex)) throw new Error(`palette.json: swatch "${name}" must be lowercase #rrggbb`)
+ return entry
+}
+
+/** A token's value: the swatch's hex, and the alpha it is used at (1 for solid). */
+function color(value) {
+ return typeof value === "string"
+ ? { hex: swatch(value).hex, alpha: 1 }
+ : { hex: swatch(value.of).hex, alpha: value.alpha }
+}
+
+const channels = hex => [1, 3, 5].map(i => Number.parseInt(hex.slice(i, i + 2), 16))
+
+const cssColor = ({ hex, alpha }) => (alpha === 1 ? hex : `rgba(${channels(hex).join(", ")}, ${alpha})`)
+
+const cssName = name => `--af-${name.replaceAll(/([a-z])([A-Z0-9])/g, "$1-$2").toLowerCase()}`
+
+// ---------- Outputs ----------
+
+const doNotEdit = (from, here) =>
+ `Generated by ${from} from ${here} — do not edit. Run \`npm run palette:generate\` in docs_app after changing the palette.`
+
+/** The same notice, wrapped for Dart's 80 columns. */
+const dartHeader = lines => lines.map(line => `// ${line}\n`).join("")
+
+/** `text` as a Dart doc comment at `indent`, wrapped to 80 columns. */
+function doc(text, indent = " ") {
+ const width = 80 - indent.length - "/// ".length
+ const lines = []
+ let line = ""
+ for (const word of text.split(" ")) {
+ if (line && line.length + 1 + word.length > width) {
+ lines.push(line)
+ line = word
+ } else {
+ line = line ? `${line} ${word}` : word
+ }
+ }
+ if (line) lines.push(line)
+ return lines.map(l => `${indent}/// ${l}\n`).join("")
+}
+
+function tokensCss() {
+ const block = (selector, tokens, notes) =>
+ `${selector} {\n` +
+ tokenNames
+ .map(
+ name =>
+ `${notes ? ` /* ${palette.tokens[name]} */\n` : ""} ${cssName(name)}: ${cssColor(color(tokens[name]))};\n`,
+ )
+ .join(notes ? "\n" : "") +
+ "}\n"
+ return (
+ `/* ${doNotEdit("docs_app/scripts/palette.mjs", "docs_app/palette.json")} */\n\n` +
+ block(":root", palette.themes.light, true) +
+ "\n" +
+ block(".dark", palette.themes.dark, false)
+ )
+}
+
+function paletteTs() {
+ const swatches = Object.entries(palette.swatches)
+ .map(([name, { hex, note }]) => ` /** ${note} */\n ${name}: "${hex}",\n`)
+ .join("")
+ const theme = tokens =>
+ tokenNames
+ .map(name => {
+ const value = tokens[name]
+ const swatch =
+ typeof value === "string" ? `swatches.${value}` : `withAlpha(swatches.${value.of}, ${value.alpha})`
+ return ` /** ${palette.tokens[name]} */\n ${name}: ${swatch},\n`
+ })
+ .join("")
+ return (
+ `// ${doNotEdit("scripts/palette.mjs", "palette.json")}\n\n` +
+ `/** The LeanCode swatches, as \`#rrggbb\`. */\n` +
+ `export const swatches = {\n${swatches}} as const\n\n` +
+ `/** \`#rrggbb\` at an alpha, as \`rgba()\` — the one color form every renderer of ours understands. */\n` +
+ `export function withAlpha(hex: string, alpha: number): string {\n` +
+ ` const [r, g, b] = [1, 3, 5].map(i => Number.parseInt(hex.slice(i, i + 2), 16))\n` +
+ ` return \`rgba(\${r}, \${g}, \${b}, \${alpha})\`\n` +
+ `}\n\n` +
+ `/** The site's \`--af-*\` tokens per theme, built from the swatches. */\n` +
+ `export const themes = {\n` +
+ ` light: {\n${theme(palette.themes.light)} },\n` +
+ ` dark: {\n${theme(palette.themes.dark)} },\n` +
+ `} as const\n`
+ )
+}
+
+/**
+ * The palette as Dart: `enum Palette` names the swatches, `AfTheme` holds one
+ * theme's tokens, and `afLight`/`afDark` build them from the enum. `members`
+ * are the enum's color accessors,
+ * which differ between Flutter's and Jaspr's `Color`; `themeMembers` are extra
+ * members of `AfTheme`.
+ */
+function dart({ header, import: importLine, members, themeMembers = "", trailer = "" }) {
+ const swatches = Object.entries(palette.swatches)
+ .map(([name, { hex, note }]) => `${doc(note)} ${name}(0x${hex.slice(1)}),\n`)
+ .join("\n")
+ .replace(/,\n$/, ";\n")
+ const fields = tokenNames.map(name => `${doc(palette.tokens[name])} final Color ${name};\n`).join("\n")
+ const params = tokenNames.map(name => ` required this.${name},\n`).join("")
+ const theme = tokens =>
+ tokenNames
+ .map(name => {
+ const value = tokens[name]
+ const swatch =
+ typeof value === "string" ? `Palette.${value}.color` : `Palette.${value.of}.alpha(${value.alpha})`
+ return ` ${name}: ${swatch},\n`
+ })
+ .join("")
+ return (
+ dartHeader(header) +
+ `\n${importLine}\n\n` +
+ `/// The LeanCode swatches, as \`palette.json\` names them, each a packed\n` +
+ `/// \`0xRRGGBB\`.\n` +
+ `enum Palette {\n${swatches}\n` +
+ ` const Palette(this.rgb);\n\n` +
+ ` final int rgb;\n\n` +
+ `${members}}\n\n` +
+ `/// The site's \`--af-*\` tokens for one theme, as the docs and the landing\n` +
+ `/// page paint them.\n` +
+ `class AfTheme {\n` +
+ ` const AfTheme({\n${params} });\n\n` +
+ `${fields}${themeMembers}}\n\n` +
+ `/// The light theme.\n` +
+ `final afLight = AfTheme(\n${theme(palette.themes.light)});\n\n` +
+ `/// The dark theme.\n` +
+ `final afDark = AfTheme(\n${theme(palette.themes.dark)});\n` +
+ trailer
+ )
+}
+
+function flutterDart() {
+ return dart({
+ header: [
+ "Generated by docs_app/scripts/palette.mjs from docs_app/palette.json — do",
+ "not edit. Run `npm run palette:generate` in docs_app after changing the",
+ "palette.",
+ ],
+ import: "import 'dart:ui' show Color;",
+ members:
+ ` Color get color => Color(0xff000000 | rgb);\n\n` +
+ ` /// \`#rrggbb\`.\n` +
+ ` String get hex => '#\${rgb.toRadixString(16).padLeft(6, '0')}';\n\n` +
+ ` Color alpha(double alpha) => color.withValues(alpha: alpha);\n`,
+ })
+}
+
+function landingDart() {
+ return dart({
+ header: [
+ "Generated by ../../docs_app/scripts/palette.mjs from",
+ "../../docs_app/palette.json — do not edit. Run `npm run palette:generate`",
+ "in docs_app after changing the palette.",
+ ],
+ import: "import 'package:jaspr/dom.dart';",
+ members:
+ ` Color get color => Color.value(rgb);\n\n` +
+ ` /// \`#rrggbb\`.\n` +
+ ` String get hex => color.value;\n\n` +
+ ` Color alpha(double alpha) =>\n` +
+ ` Color.rgba(rgb >> 16 & 0xff, rgb >> 8 & 0xff, rgb & 0xff, alpha);\n`,
+ themeMembers:
+ `\n /// The theme as the \`--af-*\` custom properties the stylesheet reads.\n` +
+ ` Map get cssVariables => {\n` +
+ tokenNames.map(name => ` '${cssName(name)}': ${name},\n`).join("") +
+ ` };\n`,
+ trailer:
+ `\n/// The dark theme's page background, for \`\`.\n` +
+ `final themeColor = afDark.bg.value;\n`,
+ })
+}
+
+/** Recolors the elements of an SVG that carry `data-palette=""`. */
+function recolorSvg(file, svg) {
+ let marked = 0
+ const result = svg.replaceAll(/<[a-zA-Z][^>]*\bdata-palette="([^"]+)"[^>]*>/g, (tag, name) => {
+ marked += 1
+ if (!/\bfill="[^"]*"/.test(tag)) throw new Error(`${file}: the element marked data-palette="${name}" has no fill`)
+ return tag.replace(/\bfill="[^"]*"/, `fill="${swatch(name).hex}"`)
+ })
+ if (marked === 0) throw new Error(`${file}: no element carries data-palette; mark the brand-color shapes`)
+ return result
+}
+
+const generated = [
+ { file: join(docsApp, "app", "af-tokens.css"), content: tokensCss },
+ { file: join(docsApp, "lib", "palette.generated.ts"), content: paletteTs },
+ { file: join(docsApp, "flutter", "lib", "support", "palette.generated.dart"), content: flutterDart },
+ { file: join(landing, "lib", "palette.generated.dart"), content: landingDart },
+]
+
+const svgs = [
+ join(docsApp, "app", "icon.svg"),
+ join(docsApp, "public", "logo-dark.svg"),
+ join(docsApp, "public", "logo-light.svg"),
+ join(landing, "web", "landing-icon.svg"),
+]
+
+/** Every output with what it should contain and what it contains now. */
+async function outputs() {
+ const current = async file => {
+ try {
+ return await readFile(file, "utf8")
+ } catch (error) {
+ if (error.code === "ENOENT") return null
+ throw error
+ }
+ }
+ const files = []
+ for (const { file, content } of generated) {
+ files.push({ file, expected: content(), actual: await current(file) })
+ }
+ for (const file of svgs) {
+ const actual = await current(file)
+ if (actual === null) throw new Error(`${file} is missing`)
+ files.push({ file, expected: recolorSvg(relative(repoRoot, file), actual), actual })
+ }
+ return files
+}
+
+const label = file => relative(repoRoot, file)
+
+async function generate() {
+ for (const { file, expected, actual } of await outputs()) {
+ if (expected === actual) continue
+ await writeFile(file, expected)
+ console.log(`wrote ${label(file)}`)
+ }
+ console.log(`palette: ${generated.length} generated files and ${svgs.length} SVGs follow palette.json`)
+}
+
+async function check() {
+ const files = await outputs()
+ const stale = files.filter(({ expected, actual }) => expected !== actual)
+ if (stale.length === 0) {
+ console.log(`palette: ${generated.length} generated files and ${svgs.length} SVGs are up to date`)
+ return
+ }
+ throw new Error(
+ "The palette's generated files are out of date — run `npm run palette:generate` in docs_app:\n" +
+ stale.map(({ file }) => ` ${label(file)}`).join("\n"),
+ )
+}
+
+const command = process.argv[2]
+try {
+ if (command === "generate") await generate()
+ else if (command === "check") await check()
+ else throw new Error("usage: node scripts/palette.mjs ")
+} catch (error) {
+ console.error(error instanceof Error ? error.message : error)
+ process.exit(1)
+}
diff --git a/landing/.gitignore b/landing/.gitignore
index cfe7145..79c320e 100644
--- a/landing/.gitignore
+++ b/landing/.gitignore
@@ -3,3 +3,5 @@ build/
# Generated by jaspr_builder on every build.
lib/main.server.options.dart
lib/main.client.options.dart
+# Generated by jaspr_class_scope_builder on every build.
+lib/**/*.scopes.dart
diff --git a/landing/README.md b/landing/README.md
index 2a061bd..8e29223 100644
--- a/landing/README.md
+++ b/landing/README.md
@@ -18,31 +18,52 @@ jaspr serve # http://localhost:8080, rebuilds on change
jaspr build # build/jaspr/{index.html,landing.js,landing-icon.svg,fonts/}
```
-From `../docs_app`, `npm run landing:build` runs the build and copies those four into `public/`. The stylesheet is
-inlined into `index.html`, so it is not copied on its own.
+From `../docs_app`, `npm run landing:build` runs the build and copies those four into `public/`. There is no
+stylesheet to copy: the styles are Dart, and the build inlines them into `index.html`.
## Layout
-| Path | What lives there |
-| ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
-| `lib/main.server.dart` | The entrypoint: the `` (font preloads, the inlined stylesheet, theme bootstrap, Open Graph) and the `App`. |
-| `lib/app.dart` | The page: header, hero, three sections with a live demo each, features, agent skill band, footer. |
-| `lib/components/` | One file per section; `example_frame.dart` is the window around a live demo. |
-| `lib/examples.dart` | Reads the demos from `../docs_app/content/landing/*.mdx` and maps them to the compiled bundle. |
-| `lib/highlight.dart` | Build-time Dart syntax highlighting (`syntax_highlight_lite`) into `tk-*` spans. |
-| `lib/site.dart` | URLs, copy, the package version from `../pubspec.yaml`. `SITE_URL` (a `--dart-define`) for previews. |
-| `web/landing.css` | The stylesheet: the same `--af-*` tokens as `docs_app/app/global.css`, dark by default. Inlined at build. |
-| `web/fonts/` | Space Grotesk and JetBrains Mono, self-hosted (see its README), so no third-party request blocks paint. |
-| `web/landing.js` | The client: Flutter islands, copy buttons, theme toggle. No framework, no build step. |
+| Path | What lives there |
+| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `lib/main.server.dart` | The entrypoint: the `` (font preloads, theme bootstrap, Open Graph) and the `App`. |
+| `lib/app.dart` | The page: header, hero, three sections with a live demo each, features, agent skill band, footer. |
+| `lib/components/` | One file per section or shared piece, each with its styles in a `@css` getter; `example_frame.dart` is the window around a demo. |
+| `lib/examples.dart` | Reads the demos from `../docs_app/content/landing/*.mdx` and maps them to the compiled bundle. |
+| `lib/highlight.dart` | Build-time Dart syntax highlighting (`syntax_highlight_lite`) into `tk-*` spans, and their colours. |
+| `lib/site.dart` | URLs, copy, the package version from `../pubspec.yaml`. `SITE_URL` (a `--dart-define`) for previews. |
+| `lib/palette.generated.dart` | The palette — `enum Palette`, `afLight`/`afDark` — generated from `../docs_app/palette.json` by `npm run palette:generate` there. |
+| `lib/styles.dart` | Fonts, the `--af-*` tokens (written from the palette), the reset and the container. See "Styles" below. |
+| `web/fonts/` | Space Grotesk and JetBrains Mono, self-hosted (see its README), so no third-party request blocks paint. |
+| `web/landing.js` | The client: Flutter islands, copy buttons, theme toggle. No framework, no build step. |
+
+## Styles
+
+There is no stylesheet file. Every component declares the rules for the classes it renders in a
+`@css static List get styles` getter next to its `build`; `lib/styles.dart` holds what is not any one
+component's: the `@font-face` rules, the `--af-*` tokens for the light (`:root`) and dark (`.dark`) themes — written
+from `afLight` and `afDark` in `lib/palette.generated.dart`, so the colors come from `docs_app/palette.json` like the
+docs' — the reset, the `af-container` column and the reduced-motion rule. `jaspr_builder` collects every `@css` getter
+into `lib/main.server.options.dart` (generated, not committed), and Jaspr renders them as one `');
- }
-
- /// Drops comments and the whitespace the grammar does not need. Strings in
- /// the sheet are font names and URLs, which a single space cannot harm.
- static String _minify(String css) => css
- .replaceAll(RegExp(r'/\*[\s\S]*?\*/'), '')
- .replaceAll(RegExp(r'\s+'), ' ')
- .replaceAllMapped(RegExp(r'\s*([{};,>])\s*'), (match) => match[1]!)
- .replaceAll(RegExp(r':\s+'), ':')
- .trim();
-}
-
Map _structuredData(String version) {
const publisher = {
'@type': 'Organization',
diff --git a/landing/lib/palette.generated.dart b/landing/lib/palette.generated.dart
new file mode 100644
index 0000000..fa6d292
--- /dev/null
+++ b/landing/lib/palette.generated.dart
@@ -0,0 +1,237 @@
+// Generated by ../../docs_app/scripts/palette.mjs from
+// ../../docs_app/palette.json — do not edit. Run `npm run palette:generate`
+// in docs_app after changing the palette.
+
+import 'package:jaspr/dom.dart';
+
+/// The LeanCode swatches, as `palette.json` names them, each a packed
+/// `0xRRGGBB`.
+enum Palette {
+ /// Primary surface. LeanCode is black-first; yellow is the exception.
+ black(0x000000),
+
+ /// Text and headings on black; the light document surface.
+ white(0xffffff),
+
+ /// Call to action and emphasis only. Text on it is always black.
+ ctaYellow(0xf0ff00),
+
+ /// Body copy on dark surfaces.
+ bodyGray(0xd8d8d4),
+
+ /// Secondary text, captions, metadata on dark.
+ mutedGray(0xa3a3a0),
+
+ /// Secondary text, captions, metadata on light: the design system's #757575,
+ /// a step darker so it also reads AA on paper.
+ mutedOnLight(0x6f6f6f),
+
+ /// Cards and panels on black, warm-tinted.
+ surface(0x151513),
+
+ /// A second surface step on black.
+ surface2(0x1d1d1a),
+
+ /// The 1px hairline on white.
+ lineOnLight(0xe5e5e5),
+
+ /// Error signal, sparingly.
+ error(0xe64239),
+
+ /// Success signal, sparingly.
+ success(0x80c340),
+
+ /// Site tint: a section background a step above black.
+ nearBlack(0x0a0a08),
+
+ /// Site tint: a section background a step below white.
+ paper(0xf7f7f5),
+
+ /// Site tint: a second surface step on white.
+ paper2(0xf2f2ef),
+
+ /// Site tint: body copy on light surfaces.
+ bodyOnLight(0x3f3f3c),
+
+ /// Site tint: the stronger hairline on white, for controls.
+ lineStrongOnLight(0xcfcfcb),
+
+ /// Site tint: the CTA yellow lifted, for the primary button's hover.
+ ctaYellowLight(0xf5ff4d),
+
+ /// Site tint: the CTA yellow as text on white, which yellow itself never is.
+ ctaYellowOnLight(0x606a00),
+
+ /// Site tint: the error signal as text on white.
+ errorOnLight(0xbe2119),
+
+ /// Site tint: the success signal as text on white.
+ successOnLight(0x527b28);
+
+ const Palette(this.rgb);
+
+ final int rgb;
+
+ Color get color => Color.value(rgb);
+
+ /// `#rrggbb`.
+ String get hex => color.value;
+
+ Color alpha(double alpha) =>
+ Color.rgba(rgb >> 16 & 0xff, rgb >> 8 & 0xff, rgb & 0xff, alpha);
+}
+
+/// The site's `--af-*` tokens for one theme, as the docs and the landing
+/// page paint them.
+class AfTheme {
+ const AfTheme({
+ required this.bg,
+ required this.bg2,
+ required this.surface,
+ required this.surface2,
+ required this.border,
+ required this.border2,
+ required this.text,
+ required this.text2,
+ required this.muted,
+ required this.accent,
+ required this.accentInk,
+ required this.accentText,
+ required this.accentHover,
+ required this.accentSoft,
+ required this.primary,
+ required this.primaryInk,
+ required this.overlay,
+ required this.danger,
+ required this.ok,
+ });
+
+ /// Page background.
+ final Color bg;
+
+ /// Section background: footer, code panels.
+ final Color bg2;
+
+ /// Cards and frames.
+ final Color surface;
+
+ /// Bars and chips on a surface.
+ final Color surface2;
+
+ /// The hairline.
+ final Color border;
+
+ /// The stronger hairline, for controls.
+ final Color border2;
+
+ /// Headings and primary text.
+ final Color text;
+
+ /// Body copy.
+ final Color text2;
+
+ /// Captions and metadata.
+ final Color muted;
+
+ /// The CTA yellow, as a fill.
+ final Color accent;
+
+ /// Text on the accent.
+ final Color accentInk;
+
+ /// The accent as text.
+ final Color accentText;
+
+ /// The primary button's hover fill.
+ final Color accentHover;
+
+ /// The accent as a wash behind icons, pills and glows.
+ final Color accentSoft;
+
+ /// The docs' primary action fill.
+ final Color primary;
+
+ /// Text on the primary fill.
+ final Color primaryInk;
+
+ /// The scrim behind dialogs.
+ final Color overlay;
+
+ /// Error signal.
+ final Color danger;
+
+ /// Success signal.
+ final Color ok;
+
+ /// The theme as the `--af-*` custom properties the stylesheet reads.
+ Map get cssVariables => {
+ '--af-bg': bg,
+ '--af-bg-2': bg2,
+ '--af-surface': surface,
+ '--af-surface-2': surface2,
+ '--af-border': border,
+ '--af-border-2': border2,
+ '--af-text': text,
+ '--af-text-2': text2,
+ '--af-muted': muted,
+ '--af-accent': accent,
+ '--af-accent-ink': accentInk,
+ '--af-accent-text': accentText,
+ '--af-accent-hover': accentHover,
+ '--af-accent-soft': accentSoft,
+ '--af-primary': primary,
+ '--af-primary-ink': primaryInk,
+ '--af-overlay': overlay,
+ '--af-danger': danger,
+ '--af-ok': ok,
+ };
+}
+
+/// The light theme.
+final afLight = AfTheme(
+ bg: Palette.white.color,
+ bg2: Palette.paper.color,
+ surface: Palette.white.color,
+ surface2: Palette.paper2.color,
+ border: Palette.lineOnLight.color,
+ border2: Palette.lineStrongOnLight.color,
+ text: Palette.black.color,
+ text2: Palette.bodyOnLight.color,
+ muted: Palette.mutedOnLight.color,
+ accent: Palette.ctaYellow.color,
+ accentInk: Palette.black.color,
+ accentText: Palette.ctaYellowOnLight.color,
+ accentHover: Palette.ctaYellowLight.color,
+ accentSoft: Palette.ctaYellow.alpha(0.45),
+ primary: Palette.black.color,
+ primaryInk: Palette.ctaYellow.color,
+ overlay: Palette.black.alpha(0.3),
+ danger: Palette.errorOnLight.color,
+ ok: Palette.successOnLight.color,
+);
+
+/// The dark theme.
+final afDark = AfTheme(
+ bg: Palette.black.color,
+ bg2: Palette.nearBlack.color,
+ surface: Palette.surface.color,
+ surface2: Palette.surface2.color,
+ border: Palette.white.alpha(0.12),
+ border2: Palette.white.alpha(0.2),
+ text: Palette.white.color,
+ text2: Palette.bodyGray.color,
+ muted: Palette.mutedGray.color,
+ accent: Palette.ctaYellow.color,
+ accentInk: Palette.black.color,
+ accentText: Palette.ctaYellow.color,
+ accentHover: Palette.ctaYellowLight.color,
+ accentSoft: Palette.ctaYellow.alpha(0.06),
+ primary: Palette.ctaYellow.color,
+ primaryInk: Palette.black.color,
+ overlay: Palette.black.alpha(0.6),
+ danger: Palette.error.color,
+ ok: Palette.success.color,
+);
+
+/// The dark theme's page background, for ``.
+final themeColor = afDark.bg.value;
diff --git a/landing/lib/styles.dart b/landing/lib/styles.dart
new file mode 100644
index 0000000..42ec3d0
--- /dev/null
+++ b/landing/lib/styles.dart
@@ -0,0 +1,276 @@
+/// Design tokens and the site-wide rules: the fonts, the reset, typography and
+/// the one layout utility. Everything else lives next to the component it
+/// styles, in `@css` getters; Jaspr collects all of them into one stylesheet,
+/// global rules first, and inlines it into the page's ``.
+///
+/// The LeanCode design system: black surfaces, one CTA yellow, Space Grotesk
+/// and JetBrains Mono. Dark is the design; light is a paper variant where the
+/// accent is a fill with black ink on it. The colors come from
+/// `docs_app/palette.json` through `palette.generated.dart`, and the docs app
+/// under /docs paints with the same `--af-*` tokens, so the two read as one
+/// site.
+library;
+
+import 'package:advanced_forms_landing/palette.generated.dart';
+import 'package:jaspr/dom.dart';
+import 'package:jaspr_class_scope/jaspr_class_scope.dart';
+
+export 'package:jaspr_class_scope/jaspr_class_scope.dart';
+
+/// Centers the content column: `min(100% - 2.5rem, var(--af-container))`
+/// wide. The one class any component may put on an element of its own.
+const container = ClassName.shared('af-container');
+
+// ---------- Tokens ----------
+//
+// The palette and metrics are CSS custom properties: light on `:root`, dark
+// where `` carries the `dark` class. The values below are the variables;
+// the definitions sit in [styles], the colors written from [afLight] and
+// [afDark].
+
+const bgColor = Color.variable('--af-bg');
+const bg2Color = Color.variable('--af-bg-2');
+const surfaceColor = Color.variable('--af-surface');
+const surface2Color = Color.variable('--af-surface-2');
+const borderColor = Color.variable('--af-border');
+const border2Color = Color.variable('--af-border-2');
+const textColor = Color.variable('--af-text');
+const text2Color = Color.variable('--af-text-2');
+const mutedColor = Color.variable('--af-muted');
+const accentColor = Color.variable('--af-accent');
+const accentInkColor = Color.variable('--af-accent-ink');
+
+/// The primary button's hover fill.
+const accentHoverColor = Color.variable('--af-accent-hover');
+
+/// The accent as text: the accent itself on dark, a readable olive on light.
+const accentTextColor = Color.variable('--af-accent-text');
+const accentSoftColor = Color.variable('--af-accent-soft');
+const dangerColor = Color.variable('--af-danger');
+const okColor = Color.variable('--af-ok');
+
+const fontSans = FontFamily.variable('--font-sans');
+const fontMono = FontFamily.variable('--font-mono');
+
+const radius = Unit.variable('--af-radius');
+const headerHeight = Unit.variable('--af-header-h');
+
+/// `var(--af-shadow)`, for the `box-shadow` slot of a `raw` map: `BoxShadow`
+/// has no variable form.
+const shadow = 'var(--af-shadow)';
+
+/// Marks a link inside running text by more than its colour: underlined in
+/// the border colour, a little below the baseline.
+const underlinedLink = {
+ 'text-decoration': 'underline',
+ 'text-decoration-color': 'var(--af-border-2)',
+ 'text-underline-offset': '3px',
+};
+
+/// A 1px solid border in [color].
+Border hairline(Color color) => Border.all(color: color, width: 1.px);
+
+/// One side of a [hairline], for `Border.only`.
+BorderSide hairlineSide(Color color) => BorderSide(color: color, width: 1.px);
+
+/// `color-mix(in srgb, %, )`: [color] at [percent]
+/// opacity over [with], which defaults to transparent. Jaspr has no typed form,
+/// so it goes into `raw` maps.
+String colorMix(Color color, int percent, {Color? with_}) =>
+ 'color-mix(in srgb, ${color.value} $percent%, '
+ '${with_?.value ?? 'transparent'})';
+
+/// Everything the page needs before any component draws: fonts, tokens, the
+/// reset, the container and the motion preference.
+@css
+List get styles => [
+ ..._fonts,
+ ..._tokens,
+ ..._reset,
+ css(container.selector).styles(
+ width: const .expression('min(100% - 2.5rem, var(--af-container))'),
+ raw: {'margin-inline': 'auto'},
+ ),
+ ..._motion,
+];
+
+// ---------- Fonts ----------
+
+const _latin =
+ 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, '
+ 'U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, '
+ 'U+2212, U+2215, U+FEFF, U+FFFD';
+const _latinExt =
+ 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, '
+ 'U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, '
+ 'U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF';
+
+/// The two variable fonts, self-hosted from `web/fonts/` (SIL Open Font
+/// License, the texts sit alongside the files), so no third-party stylesheet
+/// blocks the first paint. Latin and Latin Extended subsets cover the site's
+/// text; `swap` shows the fallback stack until they arrive, and the ``
+/// preloads the Latin two.
+List get _fonts => [
+ _fontFace('Space Grotesk', '300 700', 'space-grotesk-latin', _latin),
+ _fontFace('Space Grotesk', '300 700', 'space-grotesk-latin-ext', _latinExt),
+ _fontFace('JetBrains Mono', '100 800', 'jetbrains-mono-latin', _latin),
+ _fontFace('JetBrains Mono', '100 800', 'jetbrains-mono-latin-ext', _latinExt),
+];
+
+/// One `@font-face`. Jaspr's `css.fontFace` knows neither weight ranges,
+/// `font-display` nor `unicode-range`, so the at-rule is spelled out: `css()`
+/// takes any selector text, and an at-rule with a declaration block renders
+/// the same way a rule does.
+StyleRule _fontFace(String family, String weights, String file, String range) =>
+ css('@font-face').styles(
+ raw: {
+ 'font-family': '"$family"',
+ 'font-style': 'normal',
+ 'font-weight': weights,
+ 'font-display': 'swap',
+ 'src': 'url("/fonts/$file.woff2") format("woff2")',
+ 'unicode-range': range,
+ },
+ );
+
+// ---------- Tokens ----------
+
+/// A theme's `--af-*` colors as declarations, for a `raw` map.
+Map _colors(AfTheme theme) => {
+ for (final MapEntry(:key, :value) in theme.cssVariables.entries)
+ key: value.value,
+};
+
+List get _tokens => [
+ css(':root').styles(
+ raw: {
+ ..._colors(afLight),
+ '--af-shadow': '0 24px 60px -32px rgba(0, 0, 0, 0.25)',
+ '--af-radius': '14px',
+ '--af-header-h': '3.5rem',
+ '--af-container': '1160px',
+ '--font-sans':
+ '"Space Grotesk", ui-sans-serif, system-ui, -apple-system, '
+ '"Segoe UI", Roboto, sans-serif',
+ '--font-mono':
+ '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, '
+ 'monospace',
+ // Code tokens: GitHub Light.
+ '--tk-keyword': '#cf222e',
+ '--tk-type': '#953800',
+ '--tk-string': '#0a3069',
+ '--tk-number': '#0550ae',
+ '--tk-comment': '#656d76',
+ '--tk-annotation': '#8250df',
+ '--tk-function': '#8250df',
+ '--tk-operator': '#0550ae',
+ 'color-scheme': 'light',
+ },
+ ),
+ css('.dark').styles(
+ raw: {
+ ..._colors(afDark),
+ '--af-shadow': '0 24px 60px -32px rgba(0, 0, 0, 0.9)',
+ // Code tokens: Material Palenight.
+ '--tk-keyword': '#c792ea',
+ '--tk-type': '#ffcb6b',
+ '--tk-string': '#c3e88d',
+ '--tk-number': '#f78c6c',
+ '--tk-comment': '#767eaa',
+ '--tk-annotation': '#ffcb6b',
+ '--tk-function': '#82aaff',
+ '--tk-operator': '#89ddff',
+ 'color-scheme': 'dark',
+ },
+ ),
+];
+
+// ---------- Base ----------
+
+List get _reset => [
+ css('*, *::before, *::after').styles(boxSizing: .borderBox),
+ css('html').styles(
+ raw: {
+ 'scroll-behavior': 'smooth',
+ 'scroll-padding-top': 'calc(var(--af-header-h) + 16px)',
+ '-webkit-text-size-adjust': '100%',
+ 'scrollbar-gutter': 'stable',
+ },
+ ),
+ css('body').styles(
+ margin: .zero,
+ color: textColor,
+ fontFamily: fontSans,
+ fontSize: 1.0625.rem,
+ lineHeight: const .expression('1.6'),
+ backgroundColor: bgColor,
+ raw: {
+ '-webkit-font-smoothing': 'antialiased',
+ 'text-rendering': 'optimizeLegibility',
+ },
+ ),
+ css('h1, h2, h3').styles(
+ margin: .zero,
+ fontWeight: .w600,
+ letterSpacing: (-0.02).em,
+ lineHeight: const .expression('1.15'),
+ raw: {'text-wrap': 'balance'},
+ ),
+ css('p').styles(margin: .zero),
+ css('a').styles(color: .inherit, textDecoration: .none),
+ css('ul, ol').styles(padding: .zero, margin: .zero, listStyle: .none),
+ css('button').styles(
+ padding: .zero,
+ cursor: .pointer,
+ color: .inherit,
+ raw: {'font': 'inherit', 'background': 'none', 'border': '0'},
+ ),
+ css('svg').styles(
+ display: .inlineBlock,
+ flex: .none,
+ raw: {'vertical-align': 'middle'},
+ ),
+ css(
+ 'code, pre, kbd',
+ ).styles(fontFamily: fontMono, raw: {'font-feature-settings': '"liga" 0'}),
+ // Inline code chips.
+ css('p code, li code, h3 code').styles(
+ padding: .symmetric(vertical: 0.1.em, horizontal: 0.4.em),
+ border: hairline(borderColor),
+ radius: .circular(6.px),
+ color: textColor,
+ fontSize: 0.85.em,
+ whiteSpace: .noWrap,
+ backgroundColor: surface2Color,
+ ),
+ css(':focus-visible').styles(
+ radius: .circular(4.px),
+ outline: Outline(
+ color: accentTextColor,
+ style: .solid,
+ width: OutlineWidth(2.px),
+ offset: 3.px,
+ ),
+ ),
+ css(
+ '::selection',
+ ).styles(color: accentInkColor, backgroundColor: accentColor),
+];
+
+// ---------- Motion ----------
+
+List get _motion => [
+ // The live dot in the hero and the "attaching" status of an example frame.
+ css.keyframes('af-pulse', {'50%': const Styles(opacity: 0.3)}),
+ // Motion is a garnish here; readers who asked for less of it get none.
+ css.media(const MediaQuery.raw('(prefers-reduced-motion: reduce)'), [
+ css('html').styles(raw: {'scroll-behavior': 'auto'}),
+ css('*, *::before, *::after').styles(
+ raw: {
+ 'animation-duration': '0.01ms !important',
+ 'animation-iteration-count': '1 !important',
+ 'transition-duration': '0.01ms !important',
+ },
+ ),
+ ]),
+];
diff --git a/landing/pubspec.lock b/landing/pubspec.lock
index e583826..7398719 100644
--- a/landing/pubspec.lock
+++ b/landing/pubspec.lock
@@ -281,6 +281,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.23.4"
+ jaspr_class_scope:
+ dependency: "direct main"
+ description:
+ name: jaspr_class_scope
+ sha256: "8714085bbb61fa824bdab2f802136f585f637824cad298f6d6a795bf0605fc1d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.0"
+ jaspr_class_scope_builder:
+ dependency: "direct dev"
+ description:
+ name: jaspr_class_scope_builder
+ sha256: b1fe58ff87fb953224030901746d1cafb03f8668e416a4f551ec186a89569aa3
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.0"
json_annotation:
dependency: transitive
description:
diff --git a/landing/pubspec.yaml b/landing/pubspec.yaml
index de89d6f..b504b57 100644
--- a/landing/pubspec.yaml
+++ b/landing/pubspec.yaml
@@ -10,6 +10,7 @@ environment:
dependencies:
jaspr: ^0.23.4
+ jaspr_class_scope: ^0.1.0
syntax_highlight_lite: ^0.0.1
dev_dependencies:
@@ -20,6 +21,7 @@ dev_dependencies:
build_web_compilers: '>=4.8.5 <4.8.6'
dart_style: '>=3.1.8 <3.1.9'
jaspr_builder: ^0.23.4
+ jaspr_class_scope_builder: ^0.1.0
leancode_lint: ^27.0.0
dependency_overrides:
diff --git a/landing/web/landing-icon.svg b/landing/web/landing-icon.svg
index ba21bea..8c45ed3 100644
--- a/landing/web/landing-icon.svg
+++ b/landing/web/landing-icon.svg
@@ -2,6 +2,6 @@
-
-
+
+
diff --git a/landing/web/landing.css b/landing/web/landing.css
deleted file mode 100644
index 27b79c8..0000000
--- a/landing/web/landing.css
+++ /dev/null
@@ -1,1351 +0,0 @@
-/*
- * AI-Provenance:
- * model: Claude Fable 5.1
- * harness: Claude Code
- *
- * The advanced_forms homepage — the LeanCode design system as used on
- * ciach.leancode.co: near-black surfaces, one lime accent, Space Grotesk and
- * JetBrains Mono. Dark is the design; light is a paper variant where the
- * accent is a fill with dark ink on it. The docs app under /docs uses the same
- * tokens, so the two read as one site.
- */
-
-/* ---------- Fonts ---------- */
-
-/* The two variable fonts, self-hosted from fonts/ (SIL Open Font License, the
- texts sit alongside the files), so no third-party stylesheet blocks the first
- paint. Latin and Latin Extended subsets cover the site's text; `swap` shows
- the fallback stack until they arrive, and the preloads the Latin two. */
-@font-face {
- font-family: "Space Grotesk";
- font-style: normal;
- font-weight: 300 700;
- font-display: swap;
- src: url("/fonts/space-grotesk-latin.woff2") format("woff2");
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
- U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-
-@font-face {
- font-family: "Space Grotesk";
- font-style: normal;
- font-weight: 300 700;
- font-display: swap;
- src: url("/fonts/space-grotesk-latin-ext.woff2") format("woff2");
- unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
- U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-
-@font-face {
- font-family: "JetBrains Mono";
- font-style: normal;
- font-weight: 100 800;
- font-display: swap;
- src: url("/fonts/jetbrains-mono-latin.woff2") format("woff2");
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
- U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-
-@font-face {
- font-family: "JetBrains Mono";
- font-style: normal;
- font-weight: 100 800;
- font-display: swap;
- src: url("/fonts/jetbrains-mono-latin-ext.woff2") format("woff2");
- unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
- U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-
-/* ---------- Tokens ---------- */
-
-:root {
- --af-bg: #f6f6f2;
- --af-bg-2: #ffffff;
- --af-surface: #ffffff;
- --af-surface-2: #efefe9;
- --af-border: #e2e2dc;
- --af-border-2: #cfcfc7;
- --af-text: #0b0b0d;
- --af-text-2: #4b4b52;
- --af-muted: #66666d;
- --af-accent: #edff2f;
- --af-accent-2: #c9dc00;
- --af-accent-ink: #0b0c00;
- --af-accent-text: #5f6e00;
- --af-accent-soft: rgba(237, 255, 47, 0.45);
- --af-danger: #d93636;
- --af-ok: #15773f;
- --af-shadow: 0 24px 60px -32px rgba(0, 0, 0, 0.25);
- --af-radius: 14px;
- --af-header-h: 3.5rem;
- --af-container: 1160px;
-
- --font-sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
- --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
-
- /* Code tokens: GitHub Light. */
- --tk-keyword: #cf222e;
- --tk-type: #953800;
- --tk-string: #0a3069;
- --tk-number: #0550ae;
- --tk-comment: #656d76;
- --tk-annotation: #8250df;
- --tk-function: #8250df;
- --tk-operator: #0550ae;
-
- color-scheme: light;
-}
-
-.dark {
- --af-bg: #050505;
- --af-bg-2: #0b0b0d;
- --af-surface: #101013;
- --af-surface-2: #16161b;
- --af-border: #23232b;
- --af-border-2: #33333e;
- --af-text: #f4f4f1;
- --af-text-2: #b7b7b3;
- --af-muted: #84848d;
- --af-accent-text: #edff2f;
- --af-accent-soft: rgba(237, 255, 47, 0.12);
- --af-danger: #ff5d5d;
- --af-ok: #58e08a;
- --af-shadow: 0 24px 60px -32px rgba(0, 0, 0, 0.9);
-
- /* Code tokens: Material Palenight, the palette ciach uses. */
- --tk-keyword: #c792ea;
- --tk-type: #ffcb6b;
- --tk-string: #c3e88d;
- --tk-number: #f78c6c;
- --tk-comment: #767eaa;
- --tk-annotation: #ffcb6b;
- --tk-function: #82aaff;
- --tk-operator: #89ddff;
-
- color-scheme: dark;
-}
-
-/* ---------- Base ---------- */
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
-
-html {
- scroll-behavior: smooth;
- scroll-padding-top: calc(var(--af-header-h) + 16px);
- -webkit-text-size-adjust: 100%;
- scrollbar-gutter: stable;
-}
-
-body {
- margin: 0;
- background: var(--af-bg);
- color: var(--af-text);
- font-family: var(--font-sans);
- font-size: 1.0625rem;
- line-height: 1.6;
- -webkit-font-smoothing: antialiased;
- text-rendering: optimizeLegibility;
-}
-
-h1,
-h2,
-h3 {
- margin: 0;
- font-weight: 600;
- letter-spacing: -0.02em;
- line-height: 1.15;
- text-wrap: balance;
-}
-
-p {
- margin: 0;
-}
-
-a {
- color: inherit;
- text-decoration: none;
-}
-
-ul,
-ol {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-button {
- font: inherit;
- color: inherit;
- background: none;
- border: 0;
- padding: 0;
- cursor: pointer;
-}
-
-svg {
- display: inline-block;
- vertical-align: middle;
- flex: none;
-}
-
-code,
-pre,
-kbd {
- font-family: var(--font-mono);
- font-feature-settings: "liga" 0;
-}
-
-p code,
-li code,
-h3 code {
- padding: 0.1em 0.4em;
- border: 1px solid var(--af-border);
- border-radius: 6px;
- background: var(--af-surface-2);
- font-size: 0.85em;
- color: var(--af-text);
- white-space: nowrap;
-}
-
-:focus-visible {
- outline: 2px solid var(--af-accent-text);
- outline-offset: 3px;
- border-radius: 4px;
-}
-
-::selection {
- background: var(--af-accent);
- color: var(--af-accent-ink);
-}
-
-/* Theme-specific children: the logo has one file per theme. */
-.af-theme-dark {
- display: none;
-}
-
-.dark .af-theme-light {
- display: none;
-}
-
-.dark .af-theme-dark {
- display: inline;
-}
-
-.af-container {
- width: min(100% - 2.5rem, var(--af-container));
- margin-inline: auto;
-}
-
-.af-landing > *,
-.af-hero-grid > *,
-.af-feature-grid > *,
-.af-example-body > * {
- min-width: 0;
-}
-
-.af-skip-link {
- position: fixed;
- top: 12px;
- left: 12px;
- z-index: 100;
- padding: 0.6rem 1rem;
- border-radius: 8px;
- background: var(--af-accent);
- color: var(--af-accent-ink);
- font-weight: 600;
- transform: translateY(-200%);
- transition: transform 0.2s ease;
-}
-
-.af-skip-link:focus {
- transform: translateY(0);
-}
-
-.af-accent {
- color: var(--af-accent-text);
-}
-
-/* ---------- Header ---------- */
-
-.af-header {
- position: sticky;
- top: 0;
- z-index: 50;
- height: var(--af-header-h);
- border-bottom: 1px solid var(--af-border);
- background: color-mix(in srgb, var(--af-bg) 80%, transparent);
- backdrop-filter: saturate(140%) blur(14px);
- -webkit-backdrop-filter: saturate(140%) blur(14px);
-}
-
-.af-nav {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
- height: 100%;
-}
-
-.af-brand {
- display: inline-flex;
- align-items: center;
-}
-
-.af-logo {
- display: inline-flex;
- align-items: center;
-}
-
-.af-logo img {
- height: 34px;
- width: auto;
- vertical-align: middle;
-}
-
-.af-footer-brand .af-logo img {
- height: 44px;
-}
-
-.af-nav-links {
- display: flex;
- align-items: center;
- gap: 0.25rem;
-}
-
-.af-nav-links a,
-.af-theme-toggle {
- display: inline-flex;
- align-items: center;
- gap: 0.45rem;
- padding: 0.5rem 0.85rem;
- border-radius: 999px;
- font-size: 0.95rem;
- font-weight: 500;
- color: var(--af-text-2);
- transition:
- background-color 0.15s ease,
- color 0.15s ease;
-}
-
-.af-nav-links a:hover,
-.af-theme-toggle:hover {
- background: var(--af-surface-2);
- color: var(--af-text);
-}
-
-.af-theme-toggle {
- padding: 0.5rem;
-}
-
-.af-theme-moon {
- display: none;
-}
-
-.dark .af-theme-sun {
- display: none;
-}
-
-.dark .af-theme-moon {
- display: inline-flex;
-}
-
-/* ---------- Shared vocabulary ---------- */
-
-.af-eyebrow {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- margin-bottom: 1rem;
- font-family: var(--font-mono);
- font-size: 0.78rem;
- font-weight: 600;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--af-accent-text);
-}
-
-.af-eyebrow::before {
- content: "";
- width: 1.5rem;
- height: 2px;
- background: var(--af-accent);
-}
-
-.af-pill {
- display: inline-flex;
- align-items: center;
- gap: 0.35rem;
- padding: 0.3rem 0.7rem;
- border: 1px solid var(--af-border-2);
- border-radius: 999px;
- font-size: 0.8rem;
- font-weight: 500;
- color: var(--af-text-2);
- background: rgba(127, 127, 127, 0.04);
- white-space: nowrap;
-}
-
-.af-pill-accent {
- border-color: color-mix(in srgb, var(--af-accent) 45%, var(--af-border-2));
- background: var(--af-accent-soft);
- color: var(--af-accent-text);
-}
-
-a.af-pill-accent:hover {
- border-color: var(--af-accent-2);
-}
-
-.af-button {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.75rem 1.2rem;
- border: 1px solid transparent;
- border-radius: 999px;
- font-weight: 600;
- font-size: 0.95rem;
- line-height: 1;
- white-space: nowrap;
- cursor: pointer;
- transition:
- transform 0.15s ease,
- background-color 0.15s ease,
- border-color 0.15s ease,
- color 0.15s ease;
-}
-
-.af-button:hover {
- transform: translateY(-1px);
-}
-
-.af-button-primary {
- background: var(--af-accent);
- color: var(--af-accent-ink);
-}
-
-.af-button-primary:hover {
- background: #f6ff6b;
-}
-
-.af-button-secondary {
- border-color: var(--af-border-2);
- background: var(--af-surface);
- color: var(--af-text);
-}
-
-.af-button-secondary:hover {
- border-color: var(--af-accent-2);
-}
-
-.af-card {
- padding: 1.5rem;
- border: 1px solid var(--af-border);
- border-radius: var(--af-radius);
- background: var(--af-surface);
- transition:
- border-color 0.2s ease,
- transform 0.2s ease;
-}
-
-.af-card h3 {
- margin: 0 0 0.5rem;
- font-size: 1.15rem;
- font-weight: 600;
-}
-
-.af-card p {
- margin: 0;
- font-size: 0.95rem;
- color: var(--af-text-2);
-}
-
-/* ---------- Hero ---------- */
-
-.af-hero {
- position: relative;
- padding: clamp(3rem, 8vw, 6rem) 0 clamp(3rem, 6vw, 4.5rem);
- overflow: hidden;
- isolation: isolate;
-}
-
-.af-hero-bg {
- position: absolute;
- inset: 0;
- z-index: -1;
- background:
- radial-gradient(55% 45% at 72% 18%, rgba(237, 255, 47, 0.55), transparent 65%),
- radial-gradient(40% 40% at 10% 90%, rgba(237, 255, 47, 0.25), transparent 60%),
- repeating-linear-gradient(-58deg, transparent 0 148px, rgba(11, 11, 13, 0.06) 148px 149px);
- mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
- -webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
-}
-
-.dark .af-hero-bg {
- background:
- radial-gradient(55% 45% at 72% 18%, rgba(237, 255, 47, 0.16), transparent 65%),
- radial-gradient(40% 40% at 10% 90%, rgba(237, 255, 47, 0.07), transparent 60%),
- repeating-linear-gradient(-58deg, transparent 0 148px, rgba(237, 255, 47, 0.09) 148px 149px);
-}
-
-.af-hero-grid {
- display: grid;
- gap: 3rem;
- align-items: center;
-}
-
-.af-hero-badges {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-bottom: 1.5rem;
-}
-
-.af-hero h1 {
- font-size: clamp(2.4rem, 5.2vw, 4rem);
- font-weight: 700;
- line-height: 1.02;
- letter-spacing: -0.035em;
-}
-
-:root:not(.dark) .af-hero h1 .af-accent {
- color: var(--af-text);
- background: linear-gradient(transparent 55%, var(--af-accent) 55%);
- padding: 0 0.1em;
- margin: 0 -0.1em;
-}
-
-.af-hero-lead {
- max-width: 38rem;
- margin-top: 1.5rem;
- font-size: clamp(1.1rem, 1.6vw, 1.3rem);
- color: var(--af-text-2);
-}
-
-.af-install {
- margin-top: 2rem;
-}
-
-.af-install-command {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- max-width: 34rem;
- padding: 0.5rem 0.5rem 0.5rem 1rem;
- border: 1px solid var(--af-border-2);
- border-radius: var(--af-radius);
- background: var(--af-surface);
- box-shadow: var(--af-shadow);
-}
-
-.af-install-command code {
- flex: 1;
- min-width: 0;
- font-size: 0.95rem;
- overflow-x: auto;
- white-space: nowrap;
-}
-
-.af-prompt {
- color: var(--af-accent-text);
- font-family: var(--font-mono);
- font-weight: 600;
- user-select: none;
-}
-
-.af-install-alt {
- margin-top: 0.6rem;
- font-size: 0.85rem;
- color: var(--af-muted);
-}
-
-.af-install-alt a {
- color: var(--af-text-2);
- text-decoration: underline;
- text-decoration-color: var(--af-border-2);
- text-underline-offset: 3px;
-}
-
-.af-hero-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.75rem;
- margin-top: 1.75rem;
-}
-
-.af-hero-actions.af-center {
- justify-content: center;
-}
-
-.af-demo-label {
- display: flex;
- align-items: center;
- gap: 0.6rem;
- margin-bottom: 0.75rem;
- font-family: var(--font-mono);
- font-size: 0.78rem;
- font-weight: 600;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--af-accent-text);
-}
-
-.af-demo-dot {
- flex: none;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--af-ok);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--af-ok) 25%, transparent);
- animation: af-pulse 1.6s ease-in-out infinite;
-}
-
-@keyframes af-pulse {
- 50% {
- opacity: 0.3;
- }
-}
-
-/* Copy buttons (install command, source files). */
-.af-copy-button {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- padding: 0.45rem 0.75rem;
- border: 1px solid var(--af-border-2);
- border-radius: 8px;
- background: var(--af-surface);
- color: var(--af-text-2);
- font: 600 0.78rem/1 var(--font-sans);
- transition:
- color 0.15s ease,
- border-color 0.15s ease;
-}
-
-.af-copy-button:hover {
- color: var(--af-text);
- border-color: var(--af-accent-2);
-}
-
-.af-copy-done {
- display: none;
- color: var(--af-ok);
-}
-
-[data-copied="true"] {
- border-color: var(--af-ok) !important;
- color: var(--af-ok) !important;
-}
-
-[data-copied="true"] .af-copy-idle {
- display: none;
-}
-
-[data-copied="true"] .af-copy-done {
- display: inline-flex;
-}
-
-/* ---------- Sections ---------- */
-
-.af-section {
- padding: clamp(3.5rem, 7vw, 6rem) 0;
- border-top: 1px solid var(--af-border);
-}
-
-.af-section-head {
- max-width: 44rem;
- margin-bottom: 2.5rem;
-}
-
-.af-section h2 {
- font-size: clamp(1.9rem, 3.6vw, 2.75rem);
-}
-
-.af-lead {
- margin-top: 1rem;
- font-size: 1.125rem;
- color: var(--af-text-2);
-}
-
-.af-section-more {
- margin-top: 1.5rem;
- font-weight: 500;
-}
-
-.af-section-more a {
- color: var(--af-accent-text);
-}
-
-.af-section-more a:hover {
- text-decoration: underline;
-}
-
-.af-section-demo {
- margin-top: 2.5rem;
-}
-
-.af-checklist {
- display: grid;
- gap: 0.75rem;
- max-width: 52rem;
- margin-top: 2rem;
-}
-
-.af-checklist li {
- position: relative;
- padding-left: 1.6rem;
- color: var(--af-text-2);
-}
-
-.af-checklist li strong {
- color: var(--af-text);
- font-weight: 600;
-}
-
-.af-checklist li::before {
- content: "";
- position: absolute;
- top: 0.7em;
- left: 0;
- width: 0.9rem;
- height: 2px;
- background: var(--af-accent);
-}
-
-.af-feature-grid {
- display: grid;
- gap: 1rem;
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
-}
-
-.af-feature {
- display: flex;
- flex-direction: column;
- gap: 0.6rem;
-}
-
-.af-feature:hover,
-.af-mode:hover {
- border-color: var(--af-border-2);
- transform: translateY(-2px);
-}
-
-.af-feature-icon {
- display: inline-grid;
- place-items: center;
- width: 42px;
- height: 42px;
- margin-bottom: 0.5rem;
- border: 1px solid color-mix(in srgb, var(--af-accent) 35%, var(--af-border));
- border-radius: 12px;
- background: var(--af-accent-soft);
- color: var(--af-accent-text);
-}
-
-.af-feature p {
- flex: 1;
-}
-
-.af-rules {
- display: grid;
- gap: 1rem;
- counter-reset: rule;
-}
-
-.af-rule {
- position: relative;
- padding-left: 3.25rem;
-}
-
-.af-rule::before {
- counter-increment: rule;
- content: counter(rule, decimal-leading-zero);
- position: absolute;
- left: 0;
- top: 0.15rem;
- font-family: var(--font-mono);
- font-size: 0.85rem;
- font-weight: 600;
- color: var(--af-accent-text);
-}
-
-.af-rule h3 {
- font-size: 1.1rem;
- margin-bottom: 0.35rem;
-}
-
-.af-rule p {
- color: var(--af-text-2);
- font-size: 0.98rem;
-}
-
-.af-modes {
- margin-top: 2.5rem;
-}
-
-.af-mode {
- display: flex;
- flex-direction: column;
- gap: 0.35rem;
-}
-
-.af-mode-head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 0.75rem;
-}
-
-.af-mode h3 {
- margin: 0.25rem 0 0;
-}
-
-.af-mode h3 code {
- font-size: 0.95rem;
- font-weight: 600;
-}
-
-.af-mode-when {
- color: var(--af-text) !important;
- font-weight: 600;
-}
-
-.af-band {
- display: grid;
- gap: 2rem;
- align-items: center;
- padding: 2rem;
- border: 1px solid var(--af-border);
- border-radius: var(--af-radius);
- background:
- radial-gradient(60% 80% at 100% 0%, var(--af-accent-soft), transparent 70%),
- var(--af-surface);
-}
-
-.af-band > * {
- min-width: 0;
-}
-
-.af-band h2 {
- font-size: clamp(1.5rem, 2.6vw, 2rem);
-}
-
-.af-band .af-hero-actions {
- margin-top: 0;
-}
-
-/* ---------- Footer ---------- */
-
-.af-footer {
- border-top: 1px solid var(--af-border);
- background: var(--af-bg-2);
- font-size: 1rem;
-}
-
-.af-cta {
- padding: clamp(3.5rem, 7vw, 5.5rem) 0;
- border-bottom: 1px solid var(--af-border);
- background:
- radial-gradient(50% 60% at 50% 100%, var(--af-accent-soft), transparent 70%),
- var(--af-bg-2);
-}
-
-.af-cta-inner {
- max-width: 40rem;
- text-align: center;
-}
-
-.af-cta h2 {
- font-size: clamp(1.9rem, 3.6vw, 2.75rem);
-}
-
-.af-cta p {
- margin-top: 1rem;
- color: var(--af-text-2);
- font-size: 1.1rem;
-}
-
-.af-footer-grid {
- display: grid;
- gap: 2.5rem;
- padding: 3.5rem 0;
-}
-
-.af-footer-grid h3 {
- margin: 0 0 0.9rem;
- font-family: var(--font-mono);
- font-size: 0.72rem;
- font-weight: 600;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--af-muted);
-}
-
-.af-footer-grid ul {
- display: grid;
- gap: 0.5rem;
-}
-
-.af-footer-grid li a {
- color: var(--af-text-2);
-}
-
-.af-footer-grid li a:hover {
- color: var(--af-accent-text);
-}
-
-.af-footer-brand p {
- max-width: 24rem;
- margin-top: 1rem;
- color: var(--af-text-2);
- font-size: 0.95rem;
-}
-
-.af-footer-brand p a {
- color: var(--af-text);
- text-decoration: underline;
- text-decoration-color: var(--af-border-2);
- text-underline-offset: 3px;
-}
-
-.af-footer-bottom {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- gap: 0.75rem;
- padding: 1.5rem 0 2rem;
- border-top: 1px solid var(--af-border);
- font-size: 0.85rem;
- color: var(--af-muted);
-}
-
-.af-footer-bottom a {
- color: var(--af-text-2);
- text-decoration: underline;
- text-decoration-color: var(--af-border-2);
- text-underline-offset: 3px;
-}
-
-/* ---------- Live examples ---------- */
-
-.af-example {
- margin: 0;
- border: 1px solid var(--af-border);
- border-radius: var(--af-radius);
- background: var(--af-surface);
- box-shadow: var(--af-shadow);
- overflow: hidden;
-}
-
-.af-example-bar {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- min-height: 2.6rem;
- padding: 0.4rem 0.75rem 0.4rem 0.9rem;
- border-bottom: 1px solid var(--af-border);
- background: var(--af-surface-2);
-}
-
-.af-dots {
- display: inline-flex;
- gap: 0.4rem;
- flex: none;
-}
-
-.af-dots span {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: var(--af-border-2);
-}
-
-.af-dots span:first-child {
- background: var(--af-accent);
-}
-
-.af-example-title {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- text-align: center;
- font-family: var(--font-mono);
- font-size: 0.75rem;
- color: var(--af-muted);
-}
-
-.af-example-status {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- flex: none;
- font-family: var(--font-mono);
- font-size: 0.68rem;
- font-weight: 600;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--af-muted);
-}
-
-.af-example-status::before {
- content: "";
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--af-muted);
-}
-
-.af-example-status[data-status="ready"] {
- color: var(--af-ok);
-}
-
-.af-example-status[data-status="ready"]::before {
- background: var(--af-ok);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--af-ok) 25%, transparent);
-}
-
-.af-example-status[data-status="attaching"]::before {
- background: var(--af-accent);
- animation: af-pulse 1s ease-in-out infinite;
-}
-
-.af-example-status[data-status="failed"]::before,
-.af-example-status[data-status="evicted"]::before {
- background: var(--af-danger);
-}
-
-.af-example-stage {
- position: relative;
- background: var(--af-surface);
-}
-
-.af-example-stage[data-settled="false"] {
- min-height: 8rem;
-}
-
-.af-example-notice {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 1rem;
- text-align: center;
- font-size: 0.8rem;
- color: var(--af-muted);
-}
-
-.af-example-notice button {
- display: block;
- margin: 0.6rem auto 0;
- padding: 0.35rem 0.9rem;
- border-radius: 999px;
- background: var(--af-accent);
- color: var(--af-accent-ink);
- font-weight: 600;
- font-size: 0.8rem;
-}
-
-.af-example-gate {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: color-mix(in srgb, var(--af-surface) 55%, transparent);
- backdrop-filter: blur(1px);
-}
-
-.af-example-gate span {
- padding: 0.45rem 1.1rem;
- border-radius: 999px;
- background: var(--af-accent);
- color: var(--af-accent-ink);
- font-weight: 600;
- font-size: 0.875rem;
-}
-
-.af-example-gate-done {
- position: absolute;
- top: 0.35rem;
- right: 0.35rem;
- padding: 0.15rem 0.55rem;
- border-radius: 6px;
- background: var(--af-surface-2);
- color: var(--af-text-2);
- font-size: 0.75rem;
-}
-
-.af-example-caption {
- padding: 0.75rem 0.9rem;
- border-top: 1px solid var(--af-border);
- font-size: 0.85rem;
- color: var(--af-muted);
-}
-
-.af-example-caption a {
- color: var(--af-text-2);
- text-decoration: underline;
- text-decoration-color: var(--af-border-2);
- text-underline-offset: 3px;
-}
-
-/* Source, one file at a time. Radio inputs and CSS switch the panels. */
-.af-example-code {
- padding: 0 0.75rem 0.75rem;
-}
-
-.af-tab-input {
- position: absolute;
- width: 1px;
- height: 1px;
- margin: -1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- opacity: 0;
-}
-
-.af-example-toolbar {
- display: flex;
- align-items: center;
- gap: 0.25rem;
- padding: 0.35rem 0.35rem 0;
-}
-
-.af-example-tabs {
- display: flex;
- align-items: center;
- gap: 0.25rem;
- flex: 1;
- min-width: 0;
- overflow-x: auto;
- scrollbar-width: none;
-}
-
-.af-example-tab {
- flex: none;
- padding: 0.45rem 0.8rem;
- border: 1px solid transparent;
- border-bottom: 0;
- border-radius: 8px 8px 0 0;
- font-family: var(--font-mono);
- font-size: 0.75rem;
- color: var(--af-muted);
- cursor: pointer;
- white-space: nowrap;
- transition:
- color 0.15s ease,
- background-color 0.15s ease;
-}
-
-.af-example-tab:hover {
- color: var(--af-text);
-}
-
-.af-example-copy {
- /* Beside the tab strip, not in it, so it stays put when the strip scrolls. */
- flex: none;
- display: inline-grid;
- place-items: center;
- width: 30px;
- height: 30px;
- border-radius: 6px;
- color: var(--af-muted);
- background: var(--af-surface);
-}
-
-.af-example-copy:hover {
- color: var(--af-text);
- background: var(--af-surface-2);
-}
-
-.af-example-copy .af-copy-idle,
-.af-example-copy .af-copy-done {
- display: none;
-}
-
-.af-example-copy .af-copy-idle {
- display: inline-flex;
-}
-
-.af-example-copy[data-copied="true"] .af-copy-idle {
- display: none;
-}
-
-.af-example-copy[data-copied="true"] .af-copy-done {
- display: inline-flex;
-}
-
-.af-code-panel {
- display: none;
- margin-top: -1px;
- border: 1px solid var(--af-border);
- border-radius: 0 var(--af-radius) var(--af-radius) var(--af-radius);
- background: var(--af-bg-2);
- overflow: hidden;
-}
-
-.af-code-panel pre {
- margin: 0;
- padding: 0.9rem 1rem;
- max-height: 420px;
- overflow: auto;
- font-size: 0.8125rem;
- line-height: 1.65;
- tab-size: 2;
- scrollbar-width: thin;
- scrollbar-color: var(--af-border-2) transparent;
-}
-
-.af-code-panel code {
- display: block;
- min-width: max-content;
-}
-
-/* The checked radio picks its tab (the label right after it) and its panel,
- for up to eight files. */
-.af-tab-input:checked + .af-example-tab {
- color: var(--af-text);
- background: var(--af-bg-2);
- border-color: var(--af-border);
- box-shadow: inset 0 2px 0 var(--af-accent);
-}
-
-.af-example-code:has(.af-tab-input:nth-of-type(1):checked) .af-code-panel:nth-child(1),
-.af-example-code:has(.af-tab-input:nth-of-type(2):checked) .af-code-panel:nth-child(2),
-.af-example-code:has(.af-tab-input:nth-of-type(3):checked) .af-code-panel:nth-child(3),
-.af-example-code:has(.af-tab-input:nth-of-type(4):checked) .af-code-panel:nth-child(4),
-.af-example-code:has(.af-tab-input:nth-of-type(5):checked) .af-code-panel:nth-child(5),
-.af-example-code:has(.af-tab-input:nth-of-type(6):checked) .af-code-panel:nth-child(6),
-.af-example-code:has(.af-tab-input:nth-of-type(7):checked) .af-code-panel:nth-child(7),
-.af-example-code:has(.af-tab-input:nth-of-type(8):checked) .af-code-panel:nth-child(8) {
- display: block;
-}
-
-/* The radio is clipped away; its label shows the keyboard focus instead. */
-.af-tab-input:focus-visible + .af-example-tab {
- outline: 2px solid var(--af-accent-text);
- outline-offset: -2px;
-}
-
-/* Code tokens. */
-.tk-keyword { color: var(--tk-keyword); }
-.tk-type { color: var(--tk-type); }
-.tk-string { color: var(--tk-string); }
-.tk-number { color: var(--tk-number); }
-.tk-comment { color: var(--tk-comment); }
-.tk-doc { color: var(--tk-comment); font-style: italic; }
-.tk-annotation { color: var(--tk-annotation); }
-.tk-function { color: var(--tk-function); }
-.tk-operator { color: var(--tk-operator); }
-
-/* ---------- Responsive ---------- */
-
-@media (min-width: 760px) {
- .af-footer-grid {
- grid-template-columns: 1fr 1fr;
- }
-
- .af-band {
- grid-template-columns: 1.3fr 1fr;
- padding: 2.5rem;
- }
-}
-
-@media (min-width: 960px) {
- .af-rules {
- grid-template-columns: repeat(3, 1fr);
- gap: 2rem;
- }
-
- .af-example[data-layout="split"] .af-example-body {
- display: grid;
- grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
- align-items: start;
- }
-
- .af-example[data-layout="split"] .af-example-stage {
- order: 2;
- position: sticky;
- top: calc(var(--af-header-h) + 1.5rem);
- border-left: 1px solid var(--af-border);
- }
-
- .af-example[data-layout="split"] .af-example-code {
- order: 1;
- padding: 0.75rem;
- }
-}
-
-@media (min-width: 1000px) {
- .af-hero-grid {
- grid-template-columns: 1.05fr 1fr;
- }
-
- .af-footer-grid {
- grid-template-columns: 1.6fr 1fr 1fr 1.4fr;
- }
-}
-
-@media (max-width: 540px) {
- .af-nav-links a,
- .af-theme-toggle {
- padding: 0.5rem 0.55rem;
- font-size: 0.9rem;
- }
-
- .af-nav-links {
- gap: 0;
- }
-
- /* Examples is reachable from the docs; the row has to fit next to the logo. */
- .af-nav-secondary {
- display: none;
- }
-
- .af-logo img {
- height: 28px;
- }
-
- /* One row still: the prompt goes, the button shrinks to its icon, and the
- command scrolls sideways if a screen is narrower than it. */
- .af-install-command {
- gap: 0.5rem;
- padding: 0.4rem 0.4rem 0.4rem 0.85rem;
- }
-
- .af-install-command code {
- font-size: 0.875rem;
- }
-
- .af-install-command .af-prompt {
- display: none;
- }
-
- .af-install-command .af-copy-label {
- display: none;
- }
-
- .af-install-command .af-copy-button {
- padding: 0.5rem;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- html {
- scroll-behavior: auto;
- }
-
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- }
-}