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; - } -}