diff --git a/README.md b/README.md index c7c8262e..17deac28 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Check a local DTCG source against project rules and build files for apps. [![CI](https://github.com/marklearst/primitree/actions/workflows/ci.yml/badge.svg)](https://github.com/marklearst/primitree/actions/workflows/ci.yml) -[![DTCG](https://img.shields.io/badge/DTCG-2025.10-5F7F2F)](https://www.designtokens.org) +[![DTCG](https://img.shields.io/badge/DTCG-2025.10-0B9464)](https://www.designtokens.org) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) diff --git a/apps/docs/DESIGN.md b/apps/docs/DESIGN.md index ba92c1f9..a0d9cb67 100644 --- a/apps/docs/DESIGN.md +++ b/apps/docs/DESIGN.md @@ -2,66 +2,66 @@ name: Primitree Living Governance description: A living token graph that makes governance, consequence, and proof visible. colors: - root-black: '#030304' - canopy-black: '#08080A' - lichen: '#A8C95F' - lichen-deep: '#5F7F2F' - bone: '#FAFAFA' - ash: '#9CA3AF' - graphite: '#16161A' - rule: 'rgba(255, 255, 255, 0.1)' - blocked: '#F27575' + forest-black: '#0B0F0D' + panel: '#101512' + emerald: '#34D399' + emerald-deep: '#0B9464' + emerald-ink: '#04251A' + bone: '#F0F5F2' + sage: '#8FA39A' + rule: 'rgba(255, 255, 255, 0.09)' + wire: 'rgba(255, 255, 255, 0.22)' + blocked: '#F87171' + changed: '#FBBF24' typography: display: - fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif' - fontSize: 'clamp(3.25rem, 7.2vw, 6rem)' - fontWeight: 560 - lineHeight: 0.92 - letterSpacing: '-0.04em' + fontFamily: 'Schibsted Grotesk, ui-sans-serif, system-ui, sans-serif' + fontSize: 'clamp(2.125rem, 4.2vw, 3.375rem)' + fontWeight: 500 + lineHeight: 1.04 + letterSpacing: '-0.028em' headline: - fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif' - fontSize: 'clamp(2rem, 4.5vw, 4.5rem)' - fontWeight: 540 - lineHeight: 1 - letterSpacing: '-0.045em' + fontFamily: 'Schibsted Grotesk, ui-sans-serif, system-ui, sans-serif' + fontSize: '34px' + fontWeight: 500 + lineHeight: 1.1 + letterSpacing: '-0.02em' body: - fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif' - fontSize: '1.0625rem' - fontWeight: 430 + fontFamily: 'Schibsted Grotesk, ui-sans-serif, system-ui, sans-serif' + fontSize: '14.5px' + fontWeight: 400 lineHeight: 1.65 - letterSpacing: '-0.01em' label: - fontFamily: 'Geist Mono, ui-monospace, monospace' - fontSize: '0.75rem' - fontWeight: 520 + fontFamily: 'IBM Plex Mono, ui-monospace, monospace' + fontSize: '11px' + fontWeight: 600 lineHeight: 1.4 - letterSpacing: '0.02em' + letterSpacing: '0.08em' rounded: - control: '6px' - surface: '10px' - pill: '999px' + chip: '6px' + control: '8px' + surface: '12px' spacing: xs: '6px' sm: '12px' md: '20px' lg: '32px' xl: '64px' - section: 'clamp(96px, 14vw, 176px)' + gutter: '48px' components: button-primary: - backgroundColor: '{colors.lichen}' - textColor: '{colors.root-black}' - typography: '{typography.label}' + backgroundColor: '{colors.emerald}' + textColor: '{colors.emerald-ink}' rounded: '{rounded.control}' - padding: '14px 18px' - height: '48px' + padding: '10px 18px' + minHeight: '44px' button-secondary: - backgroundColor: '{colors.canopy-black}' + backgroundColor: 'transparent' textColor: '{colors.bone}' - typography: '{typography.label}' + border: '1px solid rgba(255, 255, 255, 0.13)' rounded: '{rounded.control}' - padding: '14px 18px' - height: '48px' + padding: '10px 18px' + minHeight: '44px' --- # Design System: Primitree Living Governance @@ -72,7 +72,7 @@ components: Primitree behaves like a botanical specimen crossed with a standards instrument: one rooted token change branches into policy, dependency, and build evidence. The visual system is black, quiet, and exact so the electric lichen signal can carry meaning. The mark is not an ornament; it becomes the structural root of the graph. -The composition is spacious but never vague. Typography establishes the claim, the living graph proves it, and each subsequent section deepens the same causal story. Motion is finite and stateful: it traces an active dependency, resolves a policy decision, or reveals an output. There is no ambient orbiting, pulsing, drifting, or parallax to make the page feel expensive. +The composition is spacious but never vague. Typography establishes the claim, the living graph proves it, and each subsequent section deepens the same causal story. Motion belongs to the graph: governed wires carry a slow marching-ants drift, the command line keeps a block cursor, and a check sweeps the graph once per proposal. Every loop stops under prefers-reduced-motion. Scroll effects, parallax, and orbiting decorations stay out. **Key Characteristics:** @@ -84,52 +84,51 @@ The composition is spacious but never vague. Typography establishes the claim, t ## Colors -The palette favors monochrome surfaces; electric lichen marks active, governed, or ready-to-ship states. +The palette favors deep forest surfaces; emerald marks active, governed, or ready-to-ship states. A light theme swaps every token through `data-theme='light'` on the root, with `#0B9464` as the light-surface emerald. ### Primary -- **Electric Lichen:** The single active signal for selected branches, valid outcomes, primary actions, and the logo root. -- **Root Black:** The page ground and deepest text-on-accent color. +- **Emerald:** The single active signal for selected proposals, valid outcomes, primary actions, prompt glyphs, and added diff lines. +- **Forest Black:** The page ground; **Emerald Ink** is the text color on emerald fills. ### Secondary -- **Blocked Coral:** Reserved for policy violations and blocked changes. Never use it as decoration. -- **Deep Lichen:** A quieter supporting signal for secondary graph structure and pressed states. +- **Blocked Coral (#F87171):** Reserved for policy violations, blocked changes, and removed diff lines. Never use it as decoration. +- **Changed Amber (#FBBF24):** Reserved for rename-affected consumers. ### Neutral -- **Bone:** Primary text and high-contrast focus treatment. -- **Ash:** Explanatory copy and secondary labels. -- **Graphite:** Structural connectors and inactive controls. -- **Canopy Black:** Raised evidence surfaces that must remain connected to the page rather than float above it. +- **Bone:** Primary text and the high-contrast focus treatment. +- **Sage:** Explanatory copy and secondary labels. +- **Wire:** Idle graph connectors; **Rule** draws hairline borders and the hero dot grid. +- **Panel:** Raised evidence surfaces that stay connected to the page. -**The Signal Rule.** Electric Lichen occupies less than ten percent of a screen. Its rarity makes governance state legible at a glance. - -**The Consequence Rule.** Coral means blocked or unsafe. It never means “interesting,” “new,” or “hovered.” +**The Consequence Rule.** Coral means blocked or unsafe. It never means “interesting,” “new,” or “hovered.” Amber means changed, never selected. ## Typography -**Display Font:** Inter (with system sans fallback) -**Body Font:** Inter (with system sans fallback) -**Label/Mono Font:** Geist Mono (with system monospace fallback) +**Display Font:** Schibsted Grotesk (with system sans fallback) +**Body Font:** Schibsted Grotesk (with system sans fallback) +**Label/Mono Font:** IBM Plex Mono (with system monospace fallback) **Character:** The sans voice is direct and legible; assertive display scale and disciplined spacing provide character without borrowing a novelty font. Mono is functional metadata, not a costume. ### Hierarchy -- **Display** (560, fluid 52–96px, 0.92 line-height): Reserved for the short homepage claim. -- **Headline** (540, fluid 32–72px, 1 line-height): Section arguments and major proof statements. -- **Title** (540, 20–28px, 1.15 line-height): Named governance branches and evidence outcomes. -- **Body** (430, 17px, 1.65 line-height): Explanations capped near 65 characters per line. -- **Label** (520, 12px, slight tracking): Token paths, statuses, commands, and interface controls. Sentence case; never a decorative eyebrow. +- **Display** (500, fluid 34–54px, 1.04 line-height): Reserved for the homepage claim. +- **Headline** (500, 34px; the closing claim runs 40px): Section arguments and major proof statements. +- **Title** (600, 19px): Named comparison outcomes and card headings. +- **Body** (400, 14.5–15px, 1.65 line-height): Explanations capped near 50 characters per line. +- **Kicker** (600, 11px, .08em tracking, uppercase): Section eyebrows, step labels, and card headers. +- **Mono** (IBM Plex Mono, 10.5–14px): Token paths, commands, versions, and terminal output. Mono carries real code and state, nothing else. **The One Claim Rule.** One display-scale statement appears in a viewport. Supporting copy uses a subordinate scale. -**The Metadata Rule.** Mono text must describe real state or code. Never use it to simulate a terminal aesthetic. +**The Metadata Rule.** Mono text must describe real state or code. Terminal blocks reproduce real command output. ## Elevation -The system is flat by default. Depth comes from occlusion, line weight, tone, and active state, not diffuse glow or floating glass cards. A narrow inset highlight may clarify a selected evidence surface; drop shadows are otherwise prohibited. +The system is flat by default. Depth comes from occlusion, line weight, tone, and active state, not diffuse glow or floating glass cards. The hero install card carries the one long soft shadow in the system; nothing else floats. **The Rooted Surface Rule.** Every evidence panel touches a rule, branch, or parent surface. Nothing floats without a visible relationship. @@ -137,20 +136,20 @@ The system is flat by default. Depth comes from occlusion, line weight, tone, an ### Buttons -- **Shape:** Compact rectangular controls with subtle 6px corners, never pill-shaped except for a small status chip. -- **Primary:** Electric Lichen with Root Black text, 48px tall, reserved for the quickstart. -- **Hover / Focus:** Hover deepens the lichen tone without translation. Focus uses a 2px Bone outline with 3px offset. -- **Secondary:** Transparent or Canopy Black with a one-pixel Graphite border and Bone text. +- **Shape:** Compact rectangular controls with 8px corners, never pill-shaped except for a small status chip. +- **Primary:** Emerald with Emerald Ink text, at least 44px tall. +- **Hover / Focus:** Hover lightens the emerald tone without translation. Focus uses a 2px Bone outline with 3px offset. +- **Secondary:** Transparent with a one-pixel border and Bone text. ### Chips -- **Style:** Small mono status labels attached to nodes or evidence, with one-pixel structural borders. -- **State:** Active uses Electric Lichen; blocked uses Blocked Coral; inactive remains neutral. Chips are not decorative badges. +- **Style:** Small status labels attached to nodes or evidence, with one-pixel structural borders and 6–8px corners. +- **State:** The active proposal chip and PASS verdict fill with Emerald and Emerald Ink; FAIL fills with Coral; inactive chips stay neutral with a Sage label. Chips are not decorative badges. ### Cards / Containers -- **Corner Style:** Use controlled 10px corners where a contained reading surface is necessary. -- **Background:** Canopy Black against Root Black. +- **Corner Style:** Use controlled 12px corners where a contained reading surface is necessary. +- **Background:** Panel against Forest Black. - **Shadow Strategy:** No default shadow. - **Border:** One-pixel Graphite rules, often open on one edge where a branch enters. - **Internal Padding:** 20–32px depending on density. @@ -165,9 +164,9 @@ The system is flat by default. Depth comes from occlusion, line weight, tone, an The logo and wordmark sit at the left. Docs and Playground sit on the right beside compact search and GitHub icon controls. Desktop and iPad keep these actions visible when space permits; mobile collapses when 44px targets cannot fit without collision. Text and a precise rule convey active state, not a filled pill. -### Living Canopy +### Token Graph -The logo mark is the structural root. Three semantic branches, Govern, Trace, and Ship, share one token node and one state model. Branch controls use real buttons with visible selected state. The visualization remains comprehensible as a textual hierarchy without SVG or motion. +The homepage instrument is a three-column token graph (Roots · primitives, Trunk · semantic, Canopy · component) drawn from the same engine projection the CLI uses. Proposal chips select a document variant; wires and node borders carry the outcome. The governed chain uses Electric Lichen dashes, violations use Blocked Coral, rename-affected consumers use the warn tone. Emphasis changes connectors and borders, never text opacity. Every node is a keyboard-focusable button whose focus traces its dependency chain, and the verdict bar restates the graph state as text for assistive technology. On narrow screens the graph scrolls inside its own container at a legible scale. ## Do's and Don'ts @@ -181,8 +180,8 @@ The logo mark is the structural root. Three semantic branches, Govern, Trace, an ### Don't: -- **Don't** recreate the generic dark dev-tool template: no faint grid/noise backdrop, orbit rings, pulsing dot eyebrow, decorative oversized mark, or fake Mac terminal. +- **Don't** recreate the generic dark dev-tool template: no orbit rings, pulsing dot eyebrow, decorative oversized mark, or fake Mac terminal. The hero dot grid is the one sanctioned backdrop. - **Don't** use generic AI-tool marketing, interchangeable bento cards, dashboard collage, glassmorphism, purple cyber gradients, or 3D pastel illustration. - **Don't** draw an ornamental tree that does not explain governance. -- **Don't** run continuous animation. No ambient drift, parallax, shimmer, or glow loops. +- **Don't** loop motion outside the graph. Wire drift and the command cursor are the two sanctioned loops, and both stop under reduced motion. - **Don't** use rounded pills as the default shape or put every idea inside a card. diff --git a/apps/docs/app/(home)/page.tsx b/apps/docs/app/(home)/page.tsx index ab51cceb..ae4f5ecf 100644 --- a/apps/docs/app/(home)/page.tsx +++ b/apps/docs/app/(home)/page.tsx @@ -1,9 +1,12 @@ import { GovernanceHome } from '@/components/landing/governance-home' import { MarketingShell } from '@/components/landing/site-chrome' +import { readPrimitreeVersion } from '@/lib/landing/release' export default function HomePage() { return ( - + ) diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 1f580b4e..7b016b09 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -3,47 +3,91 @@ @import 'fumadocs-ui/css/preset.css'; @theme { - --color-primitree-bg: #030304; - --color-primitree-surface: #08080a; - --color-primitree-raised: #0f0f12; - --color-primitree-elevated: #16161a; - --color-primitree-border: rgba(255, 255, 255, 0.06); - --color-primitree-border-strong: rgba(255, 255, 255, 0.1); - --color-primitree-text: #fafafa; - --color-primitree-muted: #9ca3af; - --color-primitree-dim: #7a818c; - --color-primitree-accent: #a8c95f; - --color-primitree-accent-strong: #5f7f2f; - --color-primitree-accent-soft: #dde9b9; - --color-primitree-accent-wash: rgb(168 201 95 / 10%); - --color-primitree-good: #45c98b; - --color-primitree-warn: #f2b84b; - --color-primitree-error: #f27575; - --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; - --font-mono: var(--font-geist-mono), ui-monospace, monospace; + --color-primitree-bg: #0b0f0d; + --color-primitree-surface: #101512; + --color-primitree-raised: #161c18; + --color-primitree-elevated: #1c241f; + --color-primitree-border: rgba(255, 255, 255, 0.09); + --color-primitree-border-strong: rgba(255, 255, 255, 0.13); + --color-primitree-edge: rgba(255, 255, 255, 0.22); + --color-primitree-text: #f0f5f2; + --color-primitree-muted: #8fa39a; + --color-primitree-dim: #8fa39a; + --color-primitree-accent: #34d399; + --color-primitree-accent-strong: #0b9464; + --color-primitree-accent-soft: #6ee7b7; + --color-primitree-accent-ink: #04251a; + --color-primitree-accent-wash: rgb(52 211 153 / 10%); + --color-primitree-good: #34d399; + --color-primitree-warn: #fbbf24; + --color-primitree-error: #f87171; + --color-primitree-tint-pass: rgb(52 211 153 / 8%); + --color-primitree-tint-fail: rgb(248 113 113 / 8%); + --color-primitree-hover-wash: rgb(255 255 255 / 3%); + --font-sans: var(--font-schibsted), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-plex-mono), ui-monospace, monospace; --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); } +:root[data-theme='light'] { + --color-primitree-bg: #f6faf8; + --color-primitree-surface: #ffffff; + --color-primitree-raised: #eef4f0; + --color-primitree-elevated: #e4ece7; + --color-primitree-border: rgba(6, 26, 18, 0.12); + --color-primitree-border-strong: rgba(6, 26, 18, 0.16); + --color-primitree-edge: rgba(6, 26, 18, 0.3); + --color-primitree-text: #0a1510; + --color-primitree-muted: #4f6b5f; + --color-primitree-dim: #4f6b5f; + --color-primitree-accent: #0b9464; + --color-primitree-accent-strong: #0b9464; + --color-primitree-accent-soft: #087a52; + --color-primitree-accent-ink: #eafff6; + --color-primitree-accent-wash: rgb(11 148 100 / 9%); + --color-primitree-good: #0b9464; + --color-primitree-warn: #b45309; + --color-primitree-error: #dc2626; + --color-primitree-tint-pass: rgb(11 148 100 / 9%); + --color-primitree-tint-fail: rgb(220 38 38 / 8%); + --color-primitree-hover-wash: rgb(6 26 18 / 4%); +} + html { scrollbar-gutter: stable; color-scheme: dark; } +:root[data-theme='light'] { + color-scheme: light; +} + html > body[data-scroll-locked] { + /* biome-ignore lint/complexity/noImportantStyles: react-remove-scroll sets this as an inline style; only !important can beat it. */ margin-right: 0 !important; + /* biome-ignore lint/complexity/noImportantStyles: react-remove-scroll sets this custom property inline; only !important can beat it. */ --removed-body-scroll-bar-size: 0 !important; } body { background: var(--color-primitree-bg); + color: var(--color-primitree-text); + transition: + background-color 250ms ease, + color 250ms ease; } ::selection { - background: rgb(168 201 95 / 25%); + background: rgb(52 211 153 / 25%); color: #fff; } +:root[data-theme='light'] ::selection { + background: rgb(11 148 100 / 25%); + color: #0a1510; +} + :root, :root #nd-docs-layout, :root #nd-sidebar, @@ -57,7 +101,7 @@ body { --color-fd-card: var(--color-primitree-surface); --color-fd-card-foreground: var(--color-primitree-text); --color-fd-primary: var(--color-primitree-accent); - --color-fd-primary-foreground: #09090b; + --color-fd-primary-foreground: var(--color-primitree-accent-ink); --color-fd-secondary: var(--color-primitree-raised); --color-fd-secondary-foreground: var(--color-primitree-text); --color-fd-accent: var(--color-primitree-accent-wash); @@ -70,7 +114,7 @@ body { } :root #nd-sidebar { - border-color: var(--color-primitree-border) !important; + border-color: var(--color-primitree-border); } .brand-logo-link { @@ -79,6 +123,10 @@ body { align-items: center; } +.brand-mark-body { + fill: currentColor; +} + .site-footer-link { display: inline-flex; min-block-size: 44px; @@ -95,9 +143,7 @@ body { .mobile-nav button:focus-visible, .btn-primary:focus-visible, .btn-ghost:focus-visible, -.governance-text-link:focus-visible, -.canopy-tabs button:focus-visible, -.canopy-proposal:focus-visible { +.governance-text-link:focus-visible { outline: 2px solid var(--color-primitree-text); outline-offset: 3px; } @@ -174,17 +220,17 @@ body { } .site-icon-control { - display: inline-grid !important; - width: 44px !important; - height: 44px !important; - min-width: 44px !important; - min-height: 44px !important; + display: inline-grid; + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; place-items: center; - border: 1px solid transparent !important; - border-radius: 6px !important; - padding: 0 !important; - background: transparent !important; - color: var(--color-primitree-muted) !important; + border: 1px solid transparent; + border-radius: 6px; + padding: 0; + background: transparent; + color: var(--color-primitree-muted); text-decoration: none; } @@ -237,13 +283,19 @@ body { touch-action: manipulation; } +.btn-primary:active, +.btn-ghost:active { + transform: scale(0.98); +} + .btn-primary { border: 1px solid var(--color-primitree-accent); background: var(--color-primitree-accent); - color: var(--color-primitree-bg); + color: var(--color-primitree-accent-ink); transition: border-color 180ms ease, - background 180ms ease; + background 180ms ease, + transform 150ms var(--ease-out-expo); } .btn-ghost { @@ -252,918 +304,1145 @@ body { color: var(--color-primitree-text); transition: border-color 180ms ease, - background 180ms ease; + background 180ms ease, + transform 150ms var(--ease-out-expo); } /* ---------- governance homepage ---------- */ .governance-home { overflow: clip; background: var(--color-primitree-bg); + transition: background-color 250ms ease; } .governance-wrap { - width: min(1280px, calc(100% - 40px)); + max-width: 1400px; margin-inline: auto; + padding-inline: 48px; +} + +.section-kicker { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-primitree-muted); +} + +.section-kicker[data-tone='accent'] { + color: var(--color-primitree-accent); +} + +.site-version { + font-family: var(--font-mono); + display: none; + font-size: 11px; + color: var(--color-primitree-muted); + border: 1px solid var(--color-primitree-border); + border-radius: 6px; + padding: 2px 8px; + white-space: nowrap; +} + +@media (min-width: 430px) { + .site-version { + display: inline-block; + } +} + +.site-theme-toggle { + display: inline-flex; + min-width: 44px; + min-height: 44px; + align-items: center; + justify-content: center; + gap: 7px; + cursor: pointer; + border: 1px solid var(--color-primitree-border); + border-radius: 8px; + padding: 5px 12px; + background: none; + color: var(--color-primitree-muted); + font-size: 12px; + font-weight: 500; + transition: + color 150ms var(--ease-out-expo), + border-color 150ms var(--ease-out-expo); +} + +@media (hover: hover) { + .site-theme-toggle:hover { + color: var(--color-primitree-text); + border-color: var(--color-primitree-border-strong); + } +} + +.site-theme-toggle:focus-visible { + outline: 2px solid var(--color-primitree-text); + outline-offset: 2px; +} + +.site-theme-toggle-to-dark { + display: none; +} + +:root[data-theme='light'] .site-theme-toggle-to-light { + display: none; +} + +:root[data-theme='light'] .site-theme-toggle-to-dark { + display: inline; } +/* ---------- hero ---------- */ .governance-hero { - padding: clamp(48px, 7vw, 88px) 0 clamp(104px, 12vw, 164px); + border-bottom: 1px solid var(--color-primitree-border); + background-image: radial-gradient( + var(--color-primitree-border) 1px, + transparent 1px + ); + background-size: 26px 26px; + background-position: 13px 13px; } -.governance-hero-copy { - position: relative; - display: grid; +.governance-hero-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 40px 56px; + padding-block: 64px 48px; +} + +.governance-hero-copy-stack { + flex: 1 1 440px; min-width: 0; - gap: 36px; - align-items: end; - padding-bottom: 40px; } -.governance-hero-copy::after { - position: absolute; - bottom: 0; - left: 0; - width: min(44vw, 520px); - height: 1px; - background: var(--color-primitree-accent); - content: ''; +.governance-hero-card { + flex: 1 1 400px; + max-width: 480px; + min-width: 0; } .governance-hero h1 { min-width: 0; - max-width: 1040px; + max-width: 21ch; margin: 0; color: var(--color-primitree-text); - font-size: clamp(3.25rem, 7.2vw, 6rem); - font-weight: 560; - letter-spacing: -0.04em; - line-height: 0.94; + font-size: clamp(2.125rem, 4.2vw, 3.375rem); + font-weight: 500; + letter-spacing: -0.028em; + line-height: 1.04; } .governance-hero h1 span { display: block; + text-wrap: balance; } -.governance-hero-support { - min-width: 0; - align-self: end; -} - -.governance-hero-support > p { - max-width: 430px; - margin: 0; +.governance-hero-lede { + max-width: 50ch; + margin: 18px 0 0; color: var(--color-primitree-muted); - font-size: 16px; - letter-spacing: -0.01em; - line-height: 1.65; + font-size: 15px; + line-height: 1.62; + text-wrap: pretty; } .governance-actions { display: flex; flex-wrap: wrap; - gap: 10px; - margin-top: 28px; + align-items: center; + gap: 12px; + margin-top: 26px; } .governance-cta { display: inline-flex; min-width: 44px; - min-height: 48px; + min-height: 44px; align-items: center; justify-content: center; - gap: 9px; - border-radius: 6px; - padding: 0 18px; - font-family: var(--font-mono); - font-size: 12px; - font-weight: 560; - letter-spacing: 0.01em; + gap: 8px; + border-radius: 8px; + padding: 10px 18px; + font-size: 13.5px; } -/* ---------- living canopy ---------- */ -.canopy-root { - position: relative; - margin-top: clamp(48px, 7vw, 76px); +.btn-primary.governance-cta { + font-weight: 600; } -.canopy-sticky, -.canopy-frame, -.canopy-visual-grid, -.canopy-graphic, -.canopy-evidence { - min-width: 0; +.btn-ghost.governance-cta { + font-weight: 500; } -.canopy-frame { - border-top: 1px solid var(--color-primitree-accent); - border-bottom: 1px solid var(--color-primitree-border-strong); - background: var(--color-primitree-bg); +/* ---------- hero install card ---------- */ +.install-card { + display: flex; + flex-direction: column; + min-width: 0; + border: 1px solid var(--color-primitree-border); + border-radius: 12px; + background: var(--color-primitree-surface); + overflow: hidden; + box-shadow: 0 32px 80px -48px rgba(0, 0, 0, 0.6); } -.canopy-frame-header { +.install-card-head { display: flex; - min-height: 58px; align-items: center; - justify-content: space-between; - gap: 20px; + padding: 0 14px; border-bottom: 1px solid var(--color-primitree-border); - padding: 0 20px; -} - -.canopy-frame-header p, -.canopy-frame-header code { - margin: 0; - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.03em; } -.canopy-frame-header p { - color: var(--color-primitree-text); - font-weight: 600; +.install-tabs { + display: flex; } -.canopy-frame-header code { - overflow: hidden; +.install-tabs button { + min-width: 44px; + min-height: 44px; + padding: 11px 12px 9px; + margin-bottom: -1px; + border-bottom: 2px solid transparent; + cursor: pointer; + font-family: var(--font-sans); + font-size: 13px; + font-weight: 500; color: var(--color-primitree-muted); - text-overflow: ellipsis; - white-space: nowrap; + transition: + color 150ms var(--ease-out-expo), + border-color 150ms var(--ease-out-expo); } -.canopy-visual-grid { - display: grid; +@media (hover: hover) { + .install-tabs button:hover { + color: var(--color-primitree-text); + } } -.canopy-graphic { - display: grid; - place-items: center; - background: var(--color-primitree-bg); +.install-tabs button[aria-checked='true'] { + color: var(--color-primitree-text); + border-bottom-color: var(--color-primitree-accent); } -.canopy-svg { - display: block; - width: 100%; - height: auto; - max-height: 680px; - overflow: visible; +.install-tabs button:focus-visible { + outline: 2px solid var(--color-primitree-text); + outline-offset: -2px; } -.canopy-mobile-graph { - display: none; +.install-shell { + margin-left: auto; + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--color-primitree-muted); } -.canopy-link, -.canopy-trunk { - vector-effect: non-scaling-stroke; - stroke: rgb(255 255 255 / 28%); - stroke-linecap: square; - stroke-linejoin: miter; - stroke-width: 1.25; - transition: - stroke 220ms var(--ease-out-expo), - opacity 220ms var(--ease-out-expo); +.install-copy-row { + display: flex; + width: 100%; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 12px; + box-sizing: border-box; + cursor: pointer; + padding: 16px; + text-align: left; + color: var(--color-primitree-text); } -.canopy-trunk { - fill: none; - stroke: var(--color-primitree-accent); +.install-copy-row code { + font-family: var(--font-mono); + font-size: 12.5px; + overflow-wrap: anywhere; } -.canopy-source-node rect, -.canopy-token-node rect, -.canopy-dependent-node rect, -.canopy-output-node rect { - vector-effect: non-scaling-stroke; - fill: var(--color-primitree-surface); - stroke: rgb(255 255 255 / 16%); - stroke-width: 1; - transition: - fill 220ms var(--ease-out-expo), - stroke 220ms var(--ease-out-expo); +.install-copy-label { + flex: none; + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-primitree-muted); } -.canopy-source-node rect, -.canopy-token-node rect { - fill: var(--color-primitree-accent-wash); +.install-copy-row:focus-visible { + outline: 2px solid var(--color-primitree-text); + outline-offset: -2px; } -.canopy-token-node rect { - stroke: var(--color-primitree-accent); +.install-output { + border-top: 1px solid var(--color-primitree-border); + padding: 14px 16px; } -.canopy-node-kicker, -.canopy-node-label, -.canopy-token-label { +.install-output p { + margin: 0; font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.8; + color: var(--color-primitree-muted); + overflow-wrap: anywhere; } -.canopy-node-kicker { - fill: var(--color-primitree-dim); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.09em; +.install-output p:first-child { + color: var(--color-primitree-text); } -.canopy-node-label { - fill: var(--color-primitree-muted); - font-size: 14px; +.mark-acc { + color: var(--color-primitree-accent); } -.canopy-token-label { - fill: var(--color-primitree-text); - font-size: 15px; - font-weight: 600; +/* ---------- proof instrument ---------- */ +.proof { + display: flex; + flex-direction: column; } -.canopy-source-swatch, -.canopy-root-node { - fill: var(--color-primitree-accent); +.proof-command, +.proof-verdict-bar { + padding-inline: max(48px, calc((100% - 1400px) / 2 + 48px)); } -.canopy-logo-body { - fill: var(--color-primitree-text); +.proof-command { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 14px; + padding-block: 10px; } -.canopy-root[data-stage='source'] .canopy-dependency-link, -.canopy-root[data-stage='source'] .canopy-output-link, -.canopy-root[data-stage='resolve'] .canopy-output-link, -.canopy-root[data-stage='govern'] .canopy-output-link { - stroke: rgb(255 255 255 / 10%); +.proof-command > code { + min-width: 0; + overflow: hidden; + font-family: var(--font-mono); + font-size: 14px; + color: var(--color-primitree-text); + text-overflow: ellipsis; + white-space: nowrap; } -.canopy-root[data-branch='trace'] .canopy-dependency-link { - stroke: var(--color-primitree-accent); +.proof-command-spacer { + flex: 1; } -.canopy-root[data-branch='trace'] .canopy-dependent-node rect, -.canopy-root[data-branch='ship'] .canopy-output-node rect { - stroke: var(--color-primitree-accent); +.proof-command-note { + color: var(--color-primitree-muted); } -.canopy-root[data-branch='ship'] .canopy-output-link { - stroke: var(--color-primitree-accent); +.proof-cursor { + color: var(--color-primitree-accent); + animation: blink 1s steps(1) infinite; } -.canopy-evidence { - border-top: 1px solid var(--color-primitree-border-strong); - background: var(--color-primitree-surface); +@keyframes blink { + 50% { + opacity: 0; + } } -.canopy-tabs { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - border-bottom: 1px solid var(--color-primitree-border-strong); +.proof-command-label { + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-primitree-muted); } -.canopy-tabs button { - min-width: 44px; - min-height: 48px; - border: 0; - border-right: 1px solid var(--color-primitree-border); - border-bottom: 2px solid transparent; - padding: 0 12px; +.proof-chips { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.proof-chips button { + min-height: 44px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + padding: 7px 14px; + border-radius: 8px; + border: 1px solid var(--color-primitree-border-strong); background: transparent; color: var(--color-primitree-muted); - font-family: var(--font-mono); - font-size: 12px; - font-weight: 560; - touch-action: manipulation; transition: - border-color 180ms ease, - color 180ms ease, - background 180ms ease; + border-color 150ms ease, + color 150ms ease; } -.canopy-tabs button:last-child { - border-right: 0; +@media (hover: hover) { + .proof-chips button:hover { + color: var(--color-primitree-text); + border-color: var(--color-primitree-accent); + } } -.canopy-tabs button[aria-selected='true'] { - border-bottom-color: var(--color-primitree-accent); - background: var(--color-primitree-accent-wash); - color: var(--color-primitree-text); +.proof-chips button[aria-checked='true'] { + background: var(--color-primitree-accent); + border-color: var(--color-primitree-accent); + color: var(--color-primitree-accent-ink); } -.canopy-panel { - min-height: 360px; - padding: 30px; +.proof-chips button:focus-visible { + outline: 2px solid var(--color-primitree-text); + outline-offset: 2px; } -.canopy-panel-label, -.canopy-panel-metric { - margin: 0; +/* ---------- token graph ---------- */ +.graph-scroll { + position: relative; + overflow-x: auto; + border-top: 1px solid var(--color-primitree-border); + padding-inline: max(24px, calc((100% - 1400px) / 2 + 24px)); + scrollbar-width: thin; } -.canopy-panel-label { - color: var(--color-primitree-dim); - font-family: var(--font-mono); - font-size: 11px; - font-weight: 560; - letter-spacing: 0.06em; - text-transform: uppercase; +.graph-frame { + position: relative; + min-width: 1120px; + padding-block: 8px; + overflow: hidden; } -.canopy-panel-metric { - margin-top: 20px; - color: var(--color-primitree-text); - font-size: clamp(28px, 4vw, 44px); - font-weight: 540; - letter-spacing: -0.04em; - line-height: 1; +.graph-sweep { + pointer-events: none; + position: absolute; + inset-block: 0; + left: 0; + width: 120px; + background: linear-gradient(90deg, transparent, rgb(52 211 153 / 8%)); + border-right: 1px solid var(--color-primitree-accent); + animation: graph-sweep 700ms linear both; } -.canopy-proposal { - position: relative; - display: grid; - width: 100%; - min-width: 44px; - min-height: 118px; - grid-template-columns: minmax(0, 1fr) auto; - gap: 12px 16px; - align-items: center; - margin-top: 24px; - border: 1px solid var(--color-primitree-border-strong); - border-radius: 6px; - padding: 18px; - background: var(--color-primitree-bg); - color: var(--color-primitree-text); - text-align: left; - touch-action: manipulation; - transition: - border-color 180ms ease, - background 180ms ease; +@keyframes graph-sweep { + from { + left: 0; + } + to { + left: 100%; + } } -.canopy-proposal > span:first-child { - color: var(--color-primitree-dim); - font-family: var(--font-mono); - font-size: 11px; +@keyframes drift { + to { + stroke-dashoffset: -40; + } } -.canopy-proposal code { - overflow: hidden; - color: var(--color-primitree-text); - font-family: var(--font-mono); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; +.graph-svg { + display: block; + width: 100%; + height: auto; } -.canopy-proposal[aria-pressed='true'] { - border-color: var(--color-primitree-error); +.graph-column-title { + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + fill: var(--color-primitree-muted); } -.canopy-switch { - display: flex; - width: 40px; - height: 22px; - grid-column: 2; - grid-row: 1 / span 2; - align-items: center; - border: 1px solid var(--color-primitree-border-strong); - border-radius: 999px; - padding: 3px; - background: var(--color-primitree-raised); +.graph-column-rule { + stroke: var(--color-primitree-border); } -.canopy-switch span { - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--color-primitree-accent); - transition: - translate 180ms var(--ease-out-expo), - background 180ms ease; +.graph-edge { + fill: none; + stroke: var(--color-primitree-edge); + stroke-width: 1.2; + opacity: 0.9; + transition: opacity 200ms ease; } -.canopy-proposal[aria-pressed='true'] .canopy-switch span { - translate: 18px 0; - background: var(--color-primitree-error); +.graph-edge[data-tone='governed'] { + stroke: var(--color-primitree-accent); + stroke-width: 1.5; + stroke-dasharray: 6 5; + opacity: 0.85; + animation: drift 2.6s linear infinite; } -.canopy-verdict { - margin-top: 20px; - padding-left: 16px; - border-left: 1px solid var(--color-primitree-accent); +.graph-edge[data-tone='suppressed'] { + stroke: var(--color-primitree-edge); + stroke-width: 1.5; + opacity: 0.5; } -.canopy-verdict p { - margin: 0; - color: var(--color-primitree-accent); +.graph-edge[data-tone='active'] { + stroke: var(--color-primitree-accent); + stroke-width: 1.8; + stroke-dasharray: 6 5; + opacity: 1; + animation: drift 1.6s linear infinite; +} + +.graph-edge[data-tone='active'][data-static] { + animation: none; +} + +.graph-edge[data-tone='violation'] { + stroke: var(--color-primitree-error); + stroke-width: 1.5; + stroke-dasharray: 5 5; + opacity: 1; + animation: drift 1.8s linear infinite; +} + +.graph-edge[data-tone='violation'][data-active] { + stroke-width: 1.9; +} + +.graph-edge[data-dim] { + opacity: 0.15; +} + +.graph-edge[data-tone='violation'][data-dim] { + opacity: 0.12; +} + +.graph-node { + cursor: pointer; + outline: none; + transition: opacity 200ms ease; +} + +.graph-node[data-dim] { + opacity: 0.3; +} + +.graph-node-box { + fill: var(--color-primitree-surface); + stroke: var(--color-primitree-border-strong); + stroke-width: 1; + transition: stroke 150ms ease; +} + +.graph-node-hit { + fill: transparent; + stroke: none; +} + +.graph-node[data-emphasis='chain'] .graph-node-box, +.graph-node[data-emphasis='governed'] .graph-node-box { + stroke: var(--color-primitree-accent); +} + +.graph-node[data-flag='changed'] .graph-node-box { + stroke: var(--color-primitree-warn); +} + +.graph-node[data-flag='added'] .graph-node-box { + stroke: var(--color-primitree-accent); +} + +.graph-node[data-flag='violation'] .graph-node-box { + stroke: var(--color-primitree-error); +} + +.graph-node[data-selected] .graph-node-box { + stroke-width: 2; +} + +.graph-node[data-selected]:not([data-flag]):not([data-emphasis]) + .graph-node-box { + stroke: var(--color-primitree-accent); +} + +.graph-node:focus-visible .graph-node-box { + stroke: var(--color-primitree-text); + stroke-width: 2; +} + +.graph-node-swatch { + stroke: none; +} + +.graph-node-swatch[data-outline] { + stroke: rgba(128, 128, 128, 0.5); + stroke-width: 0.5; +} + +.graph-node-label { font-family: var(--font-mono); - font-size: 13px; - font-weight: 650; + font-size: 12.5px; + fill: var(--color-primitree-text); } -.canopy-verdict p.is-blocked { - color: var(--color-primitree-error); +.graph-node-value { + font-family: var(--font-mono); + font-size: 10.5px; + fill: var(--color-primitree-muted); } -.canopy-verdict:has(.is-blocked) { - border-left-color: var(--color-primitree-error); +.graph-node-value[data-tone='violation'] { + fill: var(--color-primitree-error); } -.canopy-verdict small { - display: block; - margin-top: 8px; - color: var(--color-primitree-muted); - font-size: 12px; - line-height: 1.5; +.graph-violation-tag { + transition: opacity 200ms ease; } -.canopy-owner { +.graph-violation-tag[data-dim] { + opacity: 0.12; +} + +.graph-violation-tag rect { + fill: var(--color-primitree-bg); + stroke: var(--color-primitree-error); + stroke-width: 1; +} + +.graph-violation-tag text { + font-family: var(--font-mono); + font-size: 10.5px; + fill: var(--color-primitree-error); +} + +/* ---------- verdict bar ---------- */ +.proof-verdict-bar { display: flex; - min-height: 44px; align-items: center; - justify-content: space-between; - gap: 16px; - margin: 20px 0 0; + gap: 18px; + min-height: 52px; border-top: 1px solid var(--color-primitree-border); - padding-top: 12px; - color: var(--color-primitree-dim); - font-family: var(--font-mono); - font-size: 11px; + font-size: 13.5px; + flex-wrap: nowrap; + overflow: hidden; + transition: background-color 250ms ease; } -.canopy-owner code { - color: var(--color-primitree-text); - font-family: inherit; +.proof-verdict-bar[data-tone='pass'] { + background: var(--color-primitree-tint-pass); } -.canopy-path-list, -.canopy-output-list { - margin: 28px 0 0; - padding: 0; - list-style: none; +.proof-verdict-bar[data-tone='fail'] { + background: var(--color-primitree-tint-fail); } -.canopy-path-list li { - padding: 13px 0; - border-bottom: 1px solid var(--color-primitree-border); - color: var(--color-primitree-muted); - font-family: var(--font-mono); - font-size: 12px; - overflow-wrap: anywhere; +.proof-checking-label { + font-weight: 600; + color: var(--color-primitree-text); + white-space: nowrap; } -.canopy-output-list { - display: flex; - flex-wrap: wrap; - gap: 8px; +.proof-checking-label .proof-cursor { + color: inherit; } -.canopy-output-list li { - border-left: 1px solid var(--color-primitree-accent); - padding: 5px 10px; +.proof-checking-sub { color: var(--color-primitree-muted); - font-family: var(--font-mono); - font-size: 11px; + white-space: nowrap; } -.canopy-stage-track { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); +.proof-verdict { + display: flex; + flex: none; + align-items: center; + gap: 18px; margin: 0; - border-bottom: 1px solid var(--color-primitree-border); - padding: 0; - list-style: none; + white-space: nowrap; } -.canopy-stage-track > li { - min-width: 0; - padding: 18px; - border-right: 1px solid var(--color-primitree-border); +.proof-swap { + animation: proof-rise 240ms var(--ease-out-expo); } -.canopy-stage-track > li:last-child { - border-right: 0; +.proof-verdict .proof-swap { + display: flex; + align-items: center; + gap: 18px; } -.canopy-stage-track strong { - color: var(--color-primitree-text); - font-family: var(--font-mono); - font-size: 11px; +.proof-verdict-chip { + display: inline-flex; + align-items: center; + border-radius: 6px; + padding: 4px 12px; + background: var(--color-primitree-accent); + color: var(--color-primitree-accent-ink); + font-size: 12.5px; font-weight: 600; + letter-spacing: 0.04em; } -.canopy-stage-track p { - margin: 7px 0 0; - color: var(--color-primitree-dim); - font-size: 12px; - line-height: 1.5; +.proof-verdict[data-tone='fail'] .proof-verdict-chip { + background: var(--color-primitree-error); } -/* ---------- consequence narrative ---------- */ -.governance-consequences, -.governance-quickstart, -.governance-final { - border-top: 1px solid var(--color-primitree-border); +.proof-findings { + font-weight: 600; + color: var(--color-primitree-text); } -.governance-consequences { - padding: clamp(100px, 13vw, 176px) 0; +.proof-divider { + flex: none; + width: 1px; + height: 18px; + background: var(--color-primitree-border-strong); } -.governance-argument { - display: grid; - gap: clamp(60px, 9vw, 120px); +@keyframes proof-rise { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.governance-argument-intro h2, -.governance-proof-copy h2, -.governance-final h2 { +.proof-detail { + flex: 1; + min-width: 0; margin: 0; color: var(--color-primitree-text); - font-size: clamp(2.25rem, 5vw, 4.5rem); - font-weight: 540; - letter-spacing: -0.04em; - line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.governance-argument-intro p, -.governance-proof-copy > p, -.governance-final-inner > p { - max-width: 520px; - margin: 24px 0 0; +.proof-hint, +.proof-count { + flex: none; + margin: 0; + font-size: 12.5px; color: var(--color-primitree-muted); - font-size: 16px; - line-height: 1.65; + white-space: nowrap; } -.governance-argument-flow { - position: relative; - display: grid; - gap: 48px; - max-width: 700px; - padding-left: 32px; +.proof-attribution { + max-width: 62ch; + margin: 20px 0 0; + font-size: 13px; + line-height: 1.6; + color: var(--color-primitree-muted); + text-wrap: pretty; } -.governance-argument-flow::before { - position: absolute; - top: 8px; - bottom: 8px; - left: 0; - width: 1px; - background: var(--color-primitree-accent); - content: ''; +.governance-proof-section { + padding-bottom: clamp(40px, 5vw, 64px); } -.governance-argument-flow article { - position: relative; +/* ---------- before and after ---------- */ +.governance-compare { + border-bottom: 1px solid var(--color-primitree-border); } -.governance-argument-flow article::before { - position: absolute; - top: 7px; - left: -36px; - width: 9px; - height: 9px; - border: 2px solid var(--color-primitree-bg); - border-radius: 50%; - background: var(--color-primitree-accent); - content: ''; +.governance-compare-grid { + display: grid; } -.governance-argument-flow h3 { - margin: 0; +.governance-compare-cell { + min-width: 0; + padding-block: 56px; +} + +@media (min-width: 1024px) { + .governance-compare-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .governance-compare-cell:first-child { + border-right: 1px solid var(--color-primitree-border); + padding-right: 48px; + } + + .governance-compare-cell + .governance-compare-cell { + padding-left: 48px; + } +} + +.governance-compare-cell h3 { + margin: 12px 0 0; color: var(--color-primitree-text); - font-size: 20px; - font-weight: 560; - letter-spacing: -0.02em; + font-size: 19px; + font-weight: 600; + letter-spacing: -0.01em; } -.governance-argument-flow p { - max-width: 600px; +.governance-compare-copy { + max-width: 42ch; margin: 12px 0 0; - color: var(--color-primitree-muted); - font-size: 16px; + color: var(--color-primitree-text); + font-size: 14.5px; line-height: 1.65; + text-wrap: pretty; } -/* ---------- real config proof ---------- */ -.governance-quickstart { - padding: clamp(96px, 12vw, 160px) 0; - background: var(--color-primitree-surface); +.governance-reach { + margin: 16px 0 0; + font-size: 12.5px; + color: var(--color-primitree-muted); } -.governance-proof { - display: grid; - gap: clamp(48px, 8vw, 112px); - align-items: start; +.governance-reach [data-tone='fail'] { + color: var(--color-primitree-error); } -.governance-text-link { - display: inline-flex; - min-width: 44px; - min-height: 44px; - align-items: center; - gap: 8px; - margin-top: 28px; +.governance-reach [data-tone='pass'] { color: var(--color-primitree-accent); - font-family: var(--font-mono); - font-size: 12px; - text-decoration: none; } -.governance-code-proof { - min-width: 0; - border-left: 1px solid var(--color-primitree-accent); - background: var(--color-primitree-bg); +/* ---------- terminal cards ---------- */ +.terminal { + border: 1px solid var(--color-primitree-border); + border-radius: 12px; + background: var(--color-primitree-surface); + overflow: hidden; } -.governance-code-block { - padding: 22px 24px; +.governance-compare .terminal { + margin-top: 22px; } -.governance-code-block > p { - margin: 0 0 22px; - color: var(--color-primitree-dim); - font-family: var(--font-mono); +.terminal-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0; font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-primitree-muted); + border-bottom: 1px solid var(--color-primitree-border); + padding: 9px 14px; +} + +.terminal-title[data-style='report'] { + font-size: 12px; + font-weight: 500; + letter-spacing: normal; + text-transform: none; + padding: 10px 16px; +} + +.terminal-dot { + display: inline-block; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-primitree-accent); } -.governance-code-block pre { +.terminal pre { margin: 0; + padding: 14px; overflow-x: auto; - color: var(--color-primitree-muted); +} + +.terminal-lines { + padding: 14px; font-family: var(--font-mono); font-size: 12px; + line-height: 1.8; + color: var(--color-primitree-text); + overflow-x: auto; +} + +.terminal[data-density='report'] .terminal-lines { + padding: 18px 20px; + font-size: 12.5px; line-height: 1.75; - scrollbar-width: thin; } -.governance-command-row { - display: flex; - min-height: 54px; - align-items: center; - justify-content: space-between; - gap: 20px; - border-top: 1px solid var(--color-primitree-border-strong); - padding: 10px 18px 10px 24px; +.terminal-lines div { + white-space: pre; } -.governance-command-row code, -.governance-command-row span { - font-family: var(--font-mono); - font-size: 11px; +.terminal-lines [data-line='removed'] { + color: var(--color-primitree-error); } -.governance-command-row code { - overflow: hidden; +.terminal-lines [data-line='added'] { + color: var(--color-primitree-accent); +} + +.terminal-lines [data-line='dim'] { + color: var(--color-primitree-muted); +} + +.terminal-lines [data-gap] { + margin-top: 8px; +} + +.terminal-lines [data-gap='block'] { + margin-top: 12px; +} + +.terminal[data-density='report'] pre { + padding: 18px 20px; +} + +.terminal code { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.8; color: var(--color-primitree-text); - text-overflow: ellipsis; - white-space: nowrap; + white-space: pre; } -.governance-command-row span { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - gap: 6px; +.terminal[data-density='report'] code { + font-size: 12.5px; + line-height: 1.75; +} + +.terminal code [data-line='removed'] { + color: var(--color-primitree-error); +} + +.terminal code [data-line='added'] { color: var(--color-primitree-accent); - font-weight: 650; } -/* ---------- final invitation ---------- */ -.governance-final { - padding: clamp(100px, 13vw, 176px) 0; +.terminal code [data-line='dim'], +.governance-step code [data-line='dim'] { + color: var(--color-primitree-muted); } -.governance-final-inner { - max-width: 900px; +/* ---------- diff feature ---------- */ +.governance-diff-section { + border-bottom: 1px solid var(--color-primitree-border); } -@media (min-width: 720px) { - .governance-wrap { - width: min(1280px, calc(100% - 64px)); - } +.governance-diff-grid { + display: grid; + gap: 64px; + align-items: start; + padding-block: 84px; +} - .governance-hero-copy, - .governance-argument, - .governance-proof { - grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr); +@media (min-width: 1024px) { + .governance-diff-grid { + grid-template-columns: minmax(320px, 400px) minmax(0, 1fr); } } -@media (min-width: 900px) { - .canopy-visual-grid { - grid-template-columns: minmax(0, 1fr) 330px; - } +.governance-diff-grid .section-intro { + display: flex; + flex-direction: column; + align-items: flex-start; + margin: 0; +} - .canopy-evidence { - border-top: 0; - border-left: 1px solid var(--color-primitree-border-strong); - } +.section-intro h2 { + margin: 14px 0 0; + font-size: 34px; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.1; + color: var(--color-primitree-text); + text-wrap: balance; +} - .canopy-panel { - min-height: 520px; - } +.section-intro > p:last-of-type { + margin: 16px 0 0; + max-width: 40ch; + font-size: 14.5px; + line-height: 1.65; + color: var(--color-primitree-muted); + text-wrap: pretty; } -@media (min-width: 1200px) and (min-height: 900px) { - .canopy-root { - display: grid; - min-height: 230vh; - grid-template-columns: 164px minmax(0, 1fr); - gap: 24px; - } +.governance-text-link { + display: inline-flex; + min-width: 44px; + min-height: 44px; + align-items: center; + gap: 6px; + margin-top: 8px; + color: var(--color-primitree-accent); + font-size: 13.5px; + font-weight: 500; + text-decoration: none; +} - .canopy-sticky { - position: sticky; - top: 88px; - grid-column: 2; - grid-row: 1; - align-self: start; +@media (hover: hover) { + .governance-text-link:hover { + color: var(--color-primitree-accent-soft); } +} - .canopy-stage-track { - display: block; - grid-column: 1; - grid-row: 1; - border: 0; - padding-top: 18vh; - } +/* ---------- quickstart ---------- */ +.governance-quickstart { + padding-block: 64px 72px; +} - .canopy-stage-track > li { - display: flex; - min-height: 50vh; - flex-direction: column; - justify-content: center; - border: 0; - border-left: 2px solid var(--color-primitree-border-strong); - padding: 0 0 0 16px; - transition: border-color 220ms ease; - } +.governance-quickstart .section-intro { + margin: 0; +} - .canopy-root[data-stage='source'] - .canopy-stage-track - [data-canopy-stage='source'], - .canopy-root[data-stage='resolve'] - .canopy-stage-track - [data-canopy-stage='resolve'], - .canopy-root[data-stage='govern'] - .canopy-stage-track - [data-canopy-stage='govern'], - .canopy-root[data-stage='ship'] - .canopy-stage-track - [data-canopy-stage='ship'] { - border-left-color: var(--color-primitree-accent); - } +.governance-quickstart .section-intro > p:last-of-type { + margin: 14px 0 0; + max-width: 52ch; } -@media (max-width: 719px) { - .governance-hero-copy::after { - width: 45%; - } +.governance-steps { + display: grid; + gap: 20px; + margin-top: 32px; +} - .governance-actions { - align-items: stretch; +@media (min-width: 900px) { + .governance-steps { + grid-template-columns: repeat(3, minmax(0, 1fr)); } +} - .governance-cta { - flex: 1 1 100%; - } +.governance-step { + min-width: 0; + border: 1px solid var(--color-primitree-border); + border-radius: 12px; + background: var(--color-primitree-surface); + padding: 18px 20px; +} - .canopy-frame-header { - align-items: flex-start; - flex-direction: column; - justify-content: center; - gap: 4px; - padding-block: 12px; - } +.governance-step-title { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-primitree-muted); +} - .canopy-svg { - display: none; - } +.governance-step pre { + margin: 12px 0 0; + overflow-x: auto; + scrollbar-width: thin; +} - .canopy-mobile-graph { - position: relative; - display: grid; - width: 100%; - gap: 22px; - padding: 28px 18px 34px; - } +.governance-step code { + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.8; + color: var(--color-primitree-text); + white-space: pre; +} - .canopy-mobile-graph::before { - position: absolute; - top: 28px; - bottom: 76px; - left: 50%; - width: 1px; - background: var(--color-primitree-accent); - content: ''; - } +/* ---------- standards strip ---------- */ +.governance-facts-section { + border-top: 1px solid var(--color-primitree-border); +} - .canopy-mobile-node { - position: relative; - z-index: 1; - display: grid; - min-width: 0; - gap: 6px; - border: 1px solid var(--color-primitree-border-strong); - border-left: 2px solid var(--color-primitree-accent); - padding: 12px 14px; - background: var(--color-primitree-surface); - color: var(--color-primitree-muted); - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.45; - } +.governance-facts { + display: grid; + gap: 40px; + padding-block: 44px; +} - .canopy-mobile-node span { - color: var(--color-primitree-dim); - font-size: inherit; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; +@media (min-width: 720px) { + .governance-facts { + grid-template-columns: repeat(3, minmax(0, 1fr)); } +} - .canopy-mobile-node code { - min-width: 0; - color: inherit; - font: inherit; - overflow-wrap: anywhere; - } +.governance-facts p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--color-primitree-text); +} - .canopy-mobile-node-token { - border-color: var(--color-primitree-accent); - color: var(--color-primitree-text); - } +.governance-facts p span { + display: block; + margin-bottom: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-primitree-muted); +} - .canopy-mobile-node-source { - background: var(--color-primitree-accent-wash); - } +/* ---------- closing invitation ---------- */ +.governance-final { + border-top: 1px solid var(--color-primitree-border); + text-align: center; +} + +.governance-final-inner { + max-width: 720px; + margin-inline: auto; + padding-block: 88px; +} + +.governance-final h2 { + margin: 0; + color: var(--color-primitree-text); + font-size: 40px; + font-weight: 500; + letter-spacing: -0.025em; + line-height: 1.06; +} + +.governance-final-inner > p { + max-width: 44ch; + margin: 16px auto 0; + color: var(--color-primitree-muted); + font-size: 14.5px; + line-height: 1.65; + text-wrap: pretty; +} + +.governance-final .governance-actions { + justify-content: center; + margin-top: 26px; +} - .canopy-mobile-dependents { - position: relative; - z-index: 1; - display: grid; - gap: 8px; - border-left: 1px solid var(--color-primitree-accent); - padding-left: 14px; +/* ---------- responsive ---------- */ +@media (max-width: 719px) { + .governance-wrap { + padding-inline: 20px; } - .canopy-mobile-dependents .canopy-mobile-node::before { - position: absolute; - top: 50%; - left: -15px; - width: 14px; - height: 1px; - background: var(--color-primitree-accent); - content: ''; + .proof-command, + .proof-verdict-bar { + padding-inline: 20px; } - .canopy-mobile-logo { - position: relative; - z-index: 1; - display: grid; - justify-items: center; - gap: 10px; - width: max-content; - margin-inline: auto; - padding: 10px 18px 0; - background: var(--color-primitree-bg); - color: var(--color-primitree-dim); - font-family: var(--font-mono); - font-size: 12px; + .graph-scroll { + padding-inline: 12px; } - .canopy-mobile-logo svg { - width: 76px; - height: auto; - fill: var(--color-primitree-text); + .governance-hero-inner { + padding-block: 48px 40px; } - .canopy-panel { - padding: 24px 18px; + .governance-actions { + align-items: stretch; } - .canopy-stage-track { - grid-template-columns: 1fr 1fr; + .governance-cta { + flex: 1 1 100%; } - .canopy-stage-track > li:nth-child(2) { - border-right: 0; + .proof-command > code { + flex: 1 1 100%; } - .canopy-stage-track > li:nth-child(-n + 2) { - border-bottom: 1px solid var(--color-primitree-border); + .proof-hint { + display: none; } - .canopy-stage-track strong { - font-size: 12px; + .proof-verdict-bar { + flex-wrap: wrap; + padding-block: 10px; } - .governance-command-row { - align-items: flex-start; - flex-direction: column; - gap: 8px; + .proof-detail { + flex: 1 1 100%; + white-space: normal; } } @media (max-width: 359px) { .governance-hero h1 { - font-size: 2.5rem; + font-size: 1.9rem; } } @@ -1176,66 +1455,33 @@ body { .site-footer-link:hover, .mobile-nav a:hover, .site-icon-control:hover { - color: var(--color-primitree-text) !important; + color: var(--color-primitree-text); } .site-icon-control:hover, .mobile-nav summary:hover, .btn-ghost:hover { - border-color: rgb(255 255 255 / 18%) !important; - background: rgb(255 255 255 / 3%) !important; + border-color: var(--color-primitree-border-strong); + background: var(--color-primitree-hover-wash); } .btn-primary:hover { border-color: var(--color-primitree-accent-soft); background: var(--color-primitree-accent-soft); } - - .canopy-tabs button:hover { - color: var(--color-primitree-text); - } - - .canopy-proposal:hover { - background: var(--color-primitree-raised); - } - - .governance-text-link:hover { - text-decoration: underline; - text-underline-offset: 4px; - } -} - -@media (prefers-contrast: more) { - .canopy-frame, - .canopy-evidence, - .canopy-tabs, - .canopy-proposal, - .canopy-source-node rect, - .canopy-dependent-node rect, - .canopy-output-node rect { - border-color: rgb(255 255 255 / 45%); - stroke: rgb(255 255 255 / 55%); - } - - .canopy-link { - stroke: rgb(255 255 255 / 60%); - } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { + /* biome-ignore lint/complexity/noImportantStyles: the reduced-motion kill switch must out-rank every animation rule by design. */ animation-duration: 0.01ms !important; + /* biome-ignore lint/complexity/noImportantStyles: the reduced-motion kill switch must out-rank every animation rule by design. */ animation-iteration-count: 1 !important; + /* biome-ignore lint/complexity/noImportantStyles: the reduced-motion kill switch must out-rank every animation rule by design. */ scroll-behavior: auto !important; + /* biome-ignore lint/complexity/noImportantStyles: the reduced-motion kill switch must out-rank every animation rule by design. */ transition-duration: 0.01ms !important; } - - [data-motion] { - animation: none !important; - animation-duration: 0s !important; - transform: none !important; - transition: none !important; - } } diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 3f40c3be..5c6a01be 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -1,16 +1,18 @@ import { RootProvider } from 'fumadocs-ui/provider/next' -import { Geist_Mono, Inter } from 'next/font/google' +import { IBM_Plex_Mono, Schibsted_Grotesk } from 'next/font/google' import { siteMetadata } from '@/lib/discovery' import './global.css' -const inter = Inter({ +const schibsted = Schibsted_Grotesk({ subsets: ['latin'], - variable: '--font-inter', + weight: ['400', '500', '600'], + variable: '--font-schibsted', }) -const geistMono = Geist_Mono({ +const plexMono = IBM_Plex_Mono({ subsets: ['latin'], - variable: '--font-geist-mono', + weight: ['400', '500', '600'], + variable: '--font-plex-mono', }) export const metadata = siteMetadata @@ -19,10 +21,17 @@ export default function Layout({ children }: LayoutProps<'/'>) { return ( - {children} + + {children} + ) diff --git a/apps/docs/components/brand-logo.tsx b/apps/docs/components/brand-logo.tsx index 43e15705..172da44b 100644 --- a/apps/docs/components/brand-logo.tsx +++ b/apps/docs/components/brand-logo.tsx @@ -1,8 +1,9 @@ -import Image from 'next/image' import Link from 'next/link' -import mark from '@/public/primitree-icon.svg' import { cn } from '@/lib/cn' +const MARK_PATH = + 'M36.76 85.34L41.7 58.07L37.3 60.88C20.85 71.1 -1.72 58.7 0.1 38.87C1.36 25.27 10.05 13.33 21.66 6.62C48.59 -8.95 89.6 3.69 94.62 36.67C97.83 57.79 76.07 71.48 58.03 60.92L53.42 58.06L58.23 85.33H36.76V85.34ZM42.44 8.69C38.52 8.99 34.65 9.87 31.04 11.39L30.99 34.53L42.45 41.4V8.69H42.44ZM52.56 8.88V19.19L64.9 12.09C64.95 11.83 64.8 11.92 64.65 11.85C61.71 10.44 57.91 9.45 54.68 8.98C54.13 8.9 53.34 8.74 52.81 8.75C52.7 8.75 52.59 8.77 52.56 8.88ZM22.44 16.59L20.22 18.25C18.14 20.3 16.03 22.37 14.54 24.91L22.44 29.42V16.58V16.59ZM52.56 41.28L80.7 24.81C79.28 22.73 77.66 20.56 75.77 18.88C75.56 18.69 74.41 17.69 74.28 17.64C74.06 17.54 73.89 17.72 73.71 17.81L52.72 30.33L52.57 41.28H52.56ZM10.35 32.88C9.18 37.97 8.35 43.02 11.07 47.77C14.73 54.18 22.78 56.77 29.67 54.35C32.54 53.34 35.47 51.47 37.63 49.35L10.36 32.87L10.35 32.88ZM84.46 32.89L57.62 49.48C63.17 55.44 71.96 57.46 79.08 52.93C85.1 49.1 86.82 43.04 85.45 36.21C85.38 35.86 84.63 32.88 84.46 32.88V32.89Z' + const markSizes = { sm: 'h-[20px] w-[22px]', md: 'h-[24px] w-[27px]', @@ -11,12 +12,26 @@ const markSizes = { } as const const textSizes = { - sm: 'text-base', - md: 'text-lg', + sm: 'text-sm', + md: 'text-[15px]', lg: 'text-2xl sm:text-[28px]', xl: 'text-3xl sm:text-4xl md:text-[44px]', } as const +export function BrandMark({ className }: { className?: string }) { + return ( + + ) +} + export function BrandLogo({ size = 'md', linked = false, @@ -28,13 +43,8 @@ export function BrandLogo({ }) { const inner = ( <> - - + + Primitree @@ -45,7 +55,7 @@ export function BrandLogo({ {inner} @@ -54,7 +64,7 @@ export function BrandLogo({ } return ( - + {inner} ) diff --git a/apps/docs/components/landing/governance-home.tsx b/apps/docs/components/landing/governance-home.tsx index 7ce9e5e4..44c85529 100644 --- a/apps/docs/components/landing/governance-home.tsx +++ b/apps/docs/components/landing/governance-home.tsx @@ -1,182 +1,386 @@ import Link from 'next/link' -import { ArrowRight, Check } from 'lucide-react' -import { LivingCanopy } from '@/components/landing/living-canopy' -import { createGovernanceDemo } from '@/lib/landing/governance-demo' +import { ArrowRightIcon } from '@phosphor-icons/react/dist/ssr' +import { InstallCard } from '@/components/landing/install-card' +import { ProofPanel } from '@/components/landing/proof-panel' +import { + createGovernanceDemo, + createProposalDeck, + describeLayersSnippet, +} from '@/lib/landing/governance-demo' +import { readPrimitreeVersion } from '@/lib/landing/release' + +/** Renders the diff transcript with the design's per-line color roles and + * block spacing. */ +function DiffReportLines({ lines }: { lines: readonly string[] }) { + let firstChangeSeen = false + const seen = new Map() + return ( +
+ {lines.map(line => { + if (line === '') { + return null + } + const occurrence = (seen.get(line) ?? 0) + 1 + seen.set(line, occurrence) + const key = `${line}#${occurrence}` + if (line.startsWith('$ ')) { + return ( +
+ $ {line.slice(2)} +
+ ) + } + const foundMatch = line.match(/^Diff found (\d+) token changes\.$/u) + if (foundMatch !== null) { + return ( +
+ Diff found {foundMatch[1]} token + changes. +
+ ) + } + if (line.startsWith(' Affected tokens')) { + return ( +
+ {line} +
+ ) + } + if (line.startsWith('removed ')) { + return ( +
+ {line} +
+ ) + } + if (line.startsWith('added ')) { + return ( +
+ {line} +
+ ) + } + if (line.startsWith('New findings:')) { + return ( +
+ {line} +
+ ) + } + const isFirstChange = !firstChangeSeen && line.startsWith('changed ') + if (isFirstChange) { + firstChangeSeen = true + } + return ( +
+ {line} +
+ ) + })} +
+ ) +} export function GovernanceHome() { const demo = createGovernanceDemo() + const deck = createProposalDeck() + const layersSnippet = describeLayersSnippet() + const [snippetComment, ...snippetRest] = layersSnippet.split('\n') + const snippetCode = snippetRest.join('\n') + const version = readPrimitreeVersion() + const consumerCount = deck.renameImpact.consumers.length return (
-
-
-

+
+
+

Govern token change. - Know every consequence. + Know what it reaches before it merges.

-
-

- Turn a token edit into reviewable evidence: what it resolves to, - which components depend on it, who owns it, and whether the - configured policy allows the change to ship. -

-
- - Run the quickstart -
+

+ Primitree checks each change to a token source against the layer + and ownership rules in your config, shows the components the + change reaches, and builds files when the graph passes. +

+
+ + Run the quickstart +
- +
+ +

-
-
-
-

One graph. Every consequence.

-

- Primitree makes the relationship between source, policy, impact, - and output explicit before a token reaches product code. +

+

+ Try a change against the policy. +

+ +
+

+ Each verdict, consumer list, and count comes from @primitree/core at + this site's build time, through the same functions the CLI + calls. +

+
+
+ +
+
+
+

Before

+

Review by eyeball

+

+ A rename lands in tokens.json. Someone scans the PR, approves, and + finds out in production which components it reached.

+

+ what it reaches: unknown +

+
+

tokens.json · pull request

+
+
+ {'- "bg.brand": { "$value": "{blue.500}" }'} +
+
+ {'+ "bg.primary": { "$value": "{blue.500}" }'} +
+
+ Approved · merged · consumers unknown +
+
+
-
-
-

Govern the proposal

-

- Encode allowed layers, references, and owners as policy. A - proposal either passes with evidence or stops with the exact - rule it crossed. -

-
-
-

Trace the blast radius

-

- Follow resolved aliases and direct dependents through the same - graph. Reviewers see what changed and which consumers inherit - it. -

-
-
-

Ship one contract

-

- After the graph passes, one configured build emits the selected - DTCG, CSS, Tailwind, and TypeScript files from the same resolved - values. -

-
+
+

+ After +

+

Review by graph

+

+ The same rename arrives as five changes with three named + consumers. A reference left pointing at the old name blocks the + build before merge. +

+

+ what it reaches:{' '} + {consumerCount} components, named +

+
+

primitree diff · same change

+
+
+ removed {deck.renameImpact.removedPath} +
+
added {deck.renameImpact.addedPath}
+
{`→ ${deck.renameImpact.consumers.join(' · ')}`}
+
+ merge waits until the {consumerCount} refs update +
+
+
-
-
-
-

Rules close to the tokens.

+
+
+
+

+ Change review +

+

See what a rename touches.

- Define architecture and ownership beside the source. Check the - exact graph in development and CI before generating files. + primitree diff reads two revisions of a source through the graph + and pairs each change with the components that reference it. One + rename shows up as five changes.

- Read the check command -
-
-
-

primitree.config.ts

+
+

+

+ +
+
+
+ +
+
+
+

+ Quickstart +

+

First report in two minutes.

+

+ No service and no account. A config file next to your tokens, one + command locally or in CI. +

+
+
+
+

Step 01 · Install

-                {`export default {
-  schemaVersion: 1,
-  sources: {
-    brand: {
-      type: 'dtcg',
-      file: './tokens.json',
-      architecture: { layers: [
-        {
-          id: 'primitives',
-          roots: ['primitives'],
-          values: 'literal'
-        },
-        {
-          id: 'semantic',
-          roots: ['semantic'],
-          values: 'reference',
-          references: ['primitives']
-        },
-        {
-          id: 'component',
-          roots: ['component'],
-          values: 'reference',
-          references: ['semantic']
-        }
-      ] },
-      ownership: {
-        default: ['design-systems'],
-        paths: { semantic: ['product-design'] }
-      },
-      outputs: {
-        directory: './generated',
-        formats: ['dtcg', 'css', 'typescript', 'tailwind']
-      }
-    }
-  }
-}`}
+                
+                  $ npm install -D
+                  primitree@next
+                  {'\n'}
+                  + primitree@{version}
+                
               
-
-
- pnpm exec primitree check --source brand - - -
+ +
+

+ Step 02 · Describe your layers +

+
+                
+                  {snippetComment}
+                  {'\n'}
+                  {snippetCode}
+                
+              
+
+
+

Step 03 · Check and build

+
+                
+                  $ primitree check --source
+                  brand
+                  {'\n'}
+                  
+                    ✓ policy passed ·{' '}
+                    {demo.compliant.findings} findings
+                  
+                  {'\n'}
+                  $ primitree build --source
+                  brand
+                  {'\n'}
+                  
+                    → wrote dtcg ·{' '}
+                    {demo.outputs
+                      .map(label =>
+                        label === 'TypeScript' ? 'ts' : label.toLowerCase()
+                      )
+                      .join(' · ')}
+                  
+                
+              
+
+
+
+

+ Standard + DTCG Format Module 2025.10 output. Resolver contexts in + tokens.resolver.json. Extensions under + $extensions["com.primitree"]. +

+

+ Outputs + DTCG · CSS · Tailwind · TypeScript, from one resolved graph. +

+

+ Sources + Local DTCG files · Figma variables export · Variables REST API. +

+
+
+
-

Make token change legible.

+

Your next token change, reviewed.

- Start with one source, one policy, and evidence your team can - review. + Point primitree at a tokens.json, write three layer rules, and read + the first report.

Start with the docs -
diff --git a/apps/docs/components/landing/install-card.tsx b/apps/docs/components/landing/install-card.tsx new file mode 100644 index 00000000..ed34cf0e --- /dev/null +++ b/apps/docs/components/landing/install-card.tsx @@ -0,0 +1,142 @@ +'use client' + +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react' + +const PACKAGE_MANAGERS = [ + { id: 'npm', command: 'npm install -D primitree@next' }, + { id: 'pnpm', command: 'pnpm add -D primitree@next' }, + { id: 'yarn', command: 'yarn add -D primitree@next' }, + { id: 'bun', command: 'bun add -d primitree@next' }, +] as const + +type PackageManagerId = (typeof PACKAGE_MANAGERS)[number]['id'] + +export function InstallCard({ + tokenCount, + layerCount, + findings, + outputs, +}: { + tokenCount: number + layerCount: number + findings: number + outputs: readonly string[] +}) { + const [activeId, setActiveId] = useState('npm') + const [copied, setCopied] = useState(false) + const copyTimer = useRef | undefined>(undefined) + useEffect(() => { + return () => { + clearTimeout(copyTimer.current) + } + }, []) + const active = + PACKAGE_MANAGERS.find(manager => manager.id === activeId) ?? + PACKAGE_MANAGERS[0] + const wroteLine = ['dtcg', ...outputs.map(label => label.toLowerCase())].join( + ' · ' + ) + + function moveTo(index: number) { + const next = + PACKAGE_MANAGERS[ + (index + PACKAGE_MANAGERS.length) % PACKAGE_MANAGERS.length + ] + if (next !== undefined) { + setActiveId(next.id) + document.getElementById(`install-tab-${next.id}`)?.focus() + } + } + + function onTabKeyDown( + event: ReactKeyboardEvent, + index: number + ) { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault() + moveTo(index + 1) + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault() + moveTo(index - 1) + } else if (event.key === 'Home') { + event.preventDefault() + moveTo(0) + } else if (event.key === 'End') { + event.preventDefault() + moveTo(PACKAGE_MANAGERS.length - 1) + } + } + + return ( +
+
+
+ {PACKAGE_MANAGERS.map((manager, index) => ( + // biome-ignore lint/a11y/useSemanticElements: the design's tab row is a WAI-ARIA radio group of buttons with roving tabindex; native radio inputs cannot take this styling. + + ))} +
+ +
+ +
+

+ $ primitree check +

+

+ ✓ graph resolved · {tokenCount}{' '} + tokens · {layerCount} layers +

+

+ ✓ policy passed · {findings}{' '} + findings +

+

+ $ primitree build +

+

→ wrote {wroteLine}

+
+
+ ) +} diff --git a/apps/docs/components/landing/living-canopy.tsx b/apps/docs/components/landing/living-canopy.tsx deleted file mode 100644 index 99ec90d3..00000000 --- a/apps/docs/components/landing/living-canopy.tsx +++ /dev/null @@ -1,496 +0,0 @@ -'use client' - -import { - useEffect, - useRef, - useState, - type KeyboardEvent as ReactKeyboardEvent, -} from 'react' -import type { GovernanceDemo } from '@/lib/landing/governance-demo' - -const MARK_PATH = - 'M36.76 85.34L41.7 58.07L37.3 60.88C20.85 71.1 -1.72 58.7 0.1 38.87C1.36 25.27 10.05 13.33 21.66 6.62C48.59 -8.95 89.6 3.69 94.62 36.67C97.83 57.79 76.07 71.48 58.03 60.92L53.42 58.06L58.23 85.33H36.76V85.34ZM42.44 8.69C38.52 8.99 34.65 9.87 31.04 11.39L30.99 34.53L42.45 41.4V8.69H42.44ZM52.56 8.88V19.19L64.9 12.09C64.95 11.83 64.8 11.92 64.65 11.85C61.71 10.44 57.91 9.45 54.68 8.98C54.13 8.9 53.34 8.74 52.81 8.75C52.7 8.75 52.59 8.77 52.56 8.88ZM22.44 16.59L20.22 18.25C18.14 20.3 16.03 22.37 14.54 24.91L22.44 29.42V16.58V16.59ZM52.56 41.28L80.7 24.81C79.28 22.73 77.66 20.56 75.77 18.88C75.56 18.69 74.41 17.69 74.28 17.64C74.06 17.54 73.89 17.72 73.71 17.81L52.72 30.33L52.57 41.28H52.56ZM10.35 32.88C9.18 37.97 8.35 43.02 11.07 47.77C14.73 54.18 22.78 56.77 29.67 54.35C32.54 53.34 35.47 51.47 37.63 49.35L10.36 32.87L10.35 32.88ZM84.46 32.89L57.62 49.48C63.17 55.44 71.96 57.46 79.08 52.93C85.1 49.1 86.82 43.04 85.45 36.21C85.38 35.86 84.63 32.88 84.46 32.88V32.89Z' - -const branches = ['govern', 'trace', 'ship'] as const -type Branch = (typeof branches)[number] - -const branchLabels: Record = { - govern: 'Govern', - trace: 'Trace', - ship: 'Ship', -} - -function branchId(branch: Branch, part: 'tab' | 'panel') { - return `canopy-${part}-${branch}` -} - -export function LivingCanopy({ demo }: { demo: GovernanceDemo }) { - const rootRef = useRef(null) - const [activeBranch, setActiveBranch] = useState('govern') - const [forbiddenProposal, setForbiddenProposal] = useState(false) - - useEffect(() => { - const root = rootRef.current - if (root === null) { - return - } - - const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') - const supportsChoreography = window.matchMedia( - '(min-width: 75rem) and (min-height: 56.25rem)' - ) - const sentinels = root.querySelectorAll('[data-canopy-stage]') - let observer: IntersectionObserver | undefined - - const configureChoreography = () => { - observer?.disconnect() - observer = undefined - - if (reducedMotion.matches || !supportsChoreography.matches) { - root.dataset.stage = 'ship' - return - } - - root.dataset.stage = 'source' - observer = new IntersectionObserver( - entries => { - const visible = entries - .filter(entry => entry.isIntersecting) - .sort( - (left, right) => right.intersectionRatio - left.intersectionRatio - ) - .at(0) - const stage = (visible?.target as HTMLElement | undefined)?.dataset - .canopyStage - - if (stage !== undefined) { - root.dataset.stage = stage - } - }, - { rootMargin: '-34% 0px -46%', threshold: [0, 0.25, 0.6] } - ) - sentinels.forEach(sentinel => { - observer?.observe(sentinel) - }) - } - - configureChoreography() - reducedMotion.addEventListener('change', configureChoreography) - supportsChoreography.addEventListener('change', configureChoreography) - - return () => { - observer?.disconnect() - reducedMotion.removeEventListener('change', configureChoreography) - supportsChoreography.removeEventListener('change', configureChoreography) - } - }, []) - - function focusBranch(branch: Branch) { - setActiveBranch(branch) - document.getElementById(branchId(branch, 'tab'))?.focus() - } - - function handleTabKeyDown( - event: ReactKeyboardEvent, - current: Branch - ) { - const currentIndex = branches.indexOf(current) - let next: Branch | undefined - - if (event.key === 'ArrowRight') { - next = branches[(currentIndex + 1) % branches.length] - } else if (event.key === 'ArrowLeft') { - next = branches[(currentIndex - 1 + branches.length) % branches.length] - } else if (event.key === 'Home') { - next = branches[0] - } else if (event.key === 'End') { - next = branches.at(-1) - } - - if (next !== undefined) { - event.preventDefault() - focusBranch(next) - } - } - - return ( -
-
-
-
-

Living token

- {demo.tokenPath} -
- -
-
-

- {demo.tokenPath} references {demo.aliasTarget}, resolves to{' '} - {demo.resolvedValue}, belongs to {demo.owner}, and has three - direct component dependents:{' '} - {demo.directDependents - .map(dependent => dependent.path) - .join(', ')} - . A passing policy check allows the configured{' '} - {demo.outputs.join(', ')} code formats. -

- - - - -
- -
-
- {branches.map(branch => ( - - ))} -
- - - - - - -
-
-
-
- -
    -
  1. - Source -

    The primitive enters once as an opaque sRGB value.

    -
  2. -
  3. - Resolve -

    The semantic alias resolves before policy or output runs.

    -
  4. -
  5. - Govern -

    Layer and ownership rules decide whether the change proceeds.

    -
  6. -
  7. - Ship -

    A passing graph can build every configured code format.

    -
  8. -
-
- ) -} diff --git a/apps/docs/components/landing/proof-panel.tsx b/apps/docs/components/landing/proof-panel.tsx new file mode 100644 index 00000000..47bf2fb0 --- /dev/null +++ b/apps/docs/components/landing/proof-panel.tsx @@ -0,0 +1,226 @@ +'use client' + +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react' +import type { + GovernanceDemo, + ProposalDeck, + ProposalOutcome, +} from '@/lib/landing/governance-demo' +import { TokenGraph } from '@/components/landing/token-graph' + +const PROPOSAL_FLAGS: Record = { + baseline: 'current', + alias: 'alias', + violation: 'violation', + rename: 'rename', +} + +function nodeSummary(outcome: ProposalOutcome, slot: string | null) { + if (slot === null) { + return null + } + const node = outcome.nodes.find(item => item.slot === slot) + if (node === undefined) { + return null + } + const consumerCount = + node.consumers === 1 ? '1 consumer' : `${node.consumers} consumers` + return `${node.path} · ${node.kind} ${node.display} · owner ${node.owner} · ${consumerCount}` +} + +export function ProofPanel({ + demo, + deck, +}: { + demo: GovernanceDemo + deck: ProposalDeck +}) { + const [activeId, setActiveId] = useState('baseline') + const [traceSlot, setTraceSlot] = useState(null) + const [selectedSlot, setSelectedSlot] = useState(null) + const [checking, setChecking] = useState(false) + const checkTimer = useRef | undefined>( + undefined + ) + useEffect(() => { + return () => { + clearTimeout(checkTimer.current) + } + }, []) + const outcome = + deck.outcomes.find(item => item.id === activeId) ?? deck.outcomes[0] + if (outcome === undefined) { + return null + } + + function pick(id: ProposalOutcome['id']) { + setActiveId(id) + setTraceSlot(null) + setSelectedSlot(null) + setChecking(true) + clearTimeout(checkTimer.current) + checkTimer.current = setTimeout(() => setChecking(false), 700) + } + + function moveTo(index: number) { + const next = + deck.outcomes[(index + deck.outcomes.length) % deck.outcomes.length] + if (next !== undefined) { + pick(next.id) + document.getElementById(`proof-chip-${next.id}`)?.focus() + } + } + + function onChipKeyDown( + event: ReactKeyboardEvent, + index: number + ) { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault() + moveTo(index + 1) + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault() + moveTo(index - 1) + } else if (event.key === 'Home') { + event.preventDefault() + moveTo(0) + } else if (event.key === 'End') { + event.preventDefault() + moveTo(deck.outcomes.length - 1) + } + } + + const inspected = nodeSummary(outcome, traceSlot ?? selectedSlot) + + return ( +
+
+ + $ primitree check --source brand{' '} + + # proposal: {PROPOSAL_FLAGS[outcome.id]} + + + + + + Proposal + +
+ {deck.outcomes.map((item, index) => ( + // biome-ignore lint/a11y/useSemanticElements: proposal chips form a WAI-ARIA radio group of buttons with roving tabindex; native radio inputs cannot take this styling. + + ))} +
+
+

{outcome.description}

+
+
+ + {checking ? ( + +
+
+ {checking ? ( + <> + + checking + + + + resolving aliases · applying policy + + + ) : null} + + {checking ? null : ( + <> +
+
+ ) +} diff --git a/apps/docs/components/landing/site-chrome.tsx b/apps/docs/components/landing/site-chrome.tsx index c28ed388..90b0de67 100644 --- a/apps/docs/components/landing/site-chrome.tsx +++ b/apps/docs/components/landing/site-chrome.tsx @@ -1,10 +1,12 @@ import type { ReactNode } from 'react' import Link from 'next/link' +import { GithubLogoIcon } from '@phosphor-icons/react/dist/ssr' import { FullSearchTrigger, SearchTrigger, } from 'fumadocs-ui/layouts/shared/slots/search-trigger' -import { BrandLogo } from '@/components/brand-logo' +import { BrandLogo, BrandMark } from '@/components/brand-logo' +import { ThemeToggle } from '@/components/landing/theme-toggle' import { links } from '@/lib/shared' import { cn } from '@/lib/cn' @@ -13,31 +15,25 @@ const nav = [ { label: 'Playground', href: links.playground, key: 'playground' as const }, ] -function GithubMark() { - return ( - - ) -} - export function SiteHeader({ active, + versionLabel, }: { active?: 'home' | 'docs' | 'playground' + versionLabel?: string }) { return (
-
- +
+ + + {versionLabel === undefined ? null : ( + {versionLabel} + )} +
GitHub +
@@ -103,37 +105,37 @@ export function SiteHeader({ export function SiteFooter() { return (
-
-
- -
- - Documentation - - - Playground - - - npm - - - GitHub - -
-
-

MIT · Mark Learst

+
+ + + Primitree · MIT · Mark Learst + + + + Documentation + + + Playground + + + npm + + + GitHub + +
) @@ -142,13 +144,18 @@ export function SiteFooter() { export function MarketingShell({ children, active, + versionLabel, }: { children: ReactNode active?: 'home' | 'docs' | 'playground' + versionLabel?: string }) { return ( <> - +
{children}
diff --git a/apps/docs/components/landing/theme-toggle.tsx b/apps/docs/components/landing/theme-toggle.tsx new file mode 100644 index 00000000..fedad2e5 --- /dev/null +++ b/apps/docs/components/landing/theme-toggle.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useTheme } from 'next-themes' + +/** CSS keys the visible label off the root data-theme attribute, so the + * label is correct from the first paint with no mounted-state flash; the + * click handler reads the live attribute for the same reason. */ +export function ThemeToggle() { + const { setTheme } = useTheme() + + return ( + + ) +} diff --git a/apps/docs/components/landing/token-graph.tsx b/apps/docs/components/landing/token-graph.tsx new file mode 100644 index 00000000..268b2edb --- /dev/null +++ b/apps/docs/components/landing/token-graph.tsx @@ -0,0 +1,351 @@ +'use client' + +import type { + DemoLayerId, + GraphEdge, + GraphNode, + ProposalOutcome, +} from '@/lib/landing/governance-demo' + +const VIEW_WIDTH = 1160 +const VIEW_HEIGHT = 560 +const NODE_WIDTH = 240 +const NODE_HEIGHT = 42 +const HIT_HEIGHT = 50 +const GOVERNED_SLOT = 'semantic.color.bg.brand' + +const COLUMNS: readonly { + id: DemoLayerId + x: number + title: string + rows: readonly number[] +}[] = [ + { + id: 'primitives', + x: 52, + title: 'ROOTS · PRIMITIVES', + rows: [88, 164, 240, 316, 392, 468], + }, + { + id: 'semantic', + x: 460, + title: 'TRUNK · SEMANTIC', + rows: [108, 216, 324, 432], + }, + { + id: 'component', + x: 868, + title: 'CANOPY · COMPONENT', + rows: [88, 182, 276, 370, 464], + }, +] + +/** Hand-tuned turn positions for each reference wire, from the design file. */ +const EDGE_MIDS = new Map([ + ['primitives.color.blue.500>semantic.color.bg.brand', 340], + ['primitives.color.green.500>semantic.color.bg.brand', 340], + ['primitives.color.gray.900>semantic.color.fg.default', 320], + ['primitives.radius.md>semantic.radius.control', 360], + ['primitives.space.4>semantic.space.inset', 300], + ['semantic.color.bg.brand>component.button.bg', 760], + ['semantic.color.bg.brand>component.focus.ring', 780], + ['semantic.color.bg.brand>component.nav.active.bg', 800], + ['semantic.radius.control>component.button.radius', 745], + ['semantic.space.inset>component.card.inset', 820], +]) + +/** Hand-tuned bypass lanes for the layer-skipping violation wires, routing + * around the semantic column the way the design file draws them. */ +const VIOLATION_LANES = new Map([ + [ + 'primitives.color.blue.500>component.button.bg', + 'M292 119 L373 119 Q380 119 380 126 L380 173 Q380 180 387 180 L835 180 Q842 180 842 173 L842 126 Q842 119 849 119 L868 119', + ], + [ + 'primitives.color.green.500>component.focus.ring', + 'M292 175 L373 175 Q380 175 380 182 L380 186 Q380 193 387 193 L868 193', + ], + ['primitives.space.4>component.card.inset', 'M292 499 L868 495'], +]) + +const VIOLATION_TAG = { x: 556, y: 169, width: 110, height: 22 } + +interface PlacedNode { + readonly node: GraphNode + readonly x: number + readonly y: number +} + +function placeNodes(nodes: readonly GraphNode[]): Map { + const placed = new Map() + for (const column of COLUMNS) { + const members = nodes.filter(node => node.layer === column.id) + members.forEach((node, rowIndex) => { + const y = column.rows[rowIndex] + if (y === undefined) { + throw new Error(`Token graph has no row for ${node.path}.`) + } + placed.set(node.slot, { node, x: column.x, y }) + }) + } + return placed +} + +/** Orthogonal elbow with 7px-rounded corners, matching the design file. */ +function orth(from: PlacedNode, to: PlacedNode, mid: number): string { + const r = 7 + const startX = from.x + NODE_WIDTH + const startY = from.y + NODE_HEIGHT / 2 + const endX = to.x + const endY = to.y + NODE_HEIGHT / 2 + if (Math.abs(startY - endY) < 1) { + return `M${startX} ${startY} L${endX} ${endY}` + } + const dy = endY > startY ? 1 : -1 + return ( + `M${startX} ${startY} L${mid - r} ${startY} ` + + `Q${mid} ${startY} ${mid} ${startY + r * dy} L${mid} ${endY - r * dy} ` + + `Q${mid} ${endY} ${mid + r} ${endY} L${endX} ${endY}` + ) +} + +function edgePath(edge: GraphEdge, placed: Map): string { + const key = `${edge.from}>${edge.to}` + const lane = VIOLATION_LANES.get(key) + if (edge.violation && lane !== undefined) { + return lane + } + const from = placed.get(edge.from) + const to = placed.get(edge.to) + if (from === undefined || to === undefined) { + throw new Error('Token graph expected both edge ends to have slots.') + } + const mid = EDGE_MIDS.get(key) ?? from.x + NODE_WIDTH + 84 + return orth(from, to, mid) +} + +function chainOf( + slot: string, + edges: readonly GraphEdge[] +): ReadonlySet { + const chain = new Set([slot]) + let grew = true + while (grew) { + grew = false + for (const edge of edges) { + if (chain.has(edge.from) && !chain.has(edge.to)) { + chain.add(edge.to) + grew = true + } + if (chain.has(edge.to) && !chain.has(edge.from)) { + chain.add(edge.from) + grew = true + } + } + } + return chain +} + +function isAchromatic(hex: string): boolean { + const channels = hex + .slice(1) + .match(/.{2}/gu) + ?.map(channel => Number.parseInt(channel, 16)) + if (channels === undefined || channels.length !== 3) { + return false + } + return Math.max(...channels) - Math.min(...channels) < 30 +} + +function nodeSummary(node: GraphNode): string { + const consumerCount = + node.consumers === 1 ? '1 consumer' : `${node.consumers} consumers` + return `${node.path} · ${node.kind} ${node.display} · owner ${node.owner} · ${consumerCount}` +} + +export function TokenGraph({ + outcome, + checking, + traceSlot, + selectedSlot, + onTraceSlot, + onSelectSlot, +}: { + outcome: ProposalOutcome + checking: boolean + traceSlot: string | null + selectedSlot: string | null + onTraceSlot: (slot: string | null) => void + onSelectSlot: (slot: string | null) => void +}) { + const placed = placeNodes(outcome.nodes) + const trace = traceSlot === null ? null : chainOf(traceSlot, outcome.edges) + const governed = chainOf(GOVERNED_SLOT, outcome.edges) + const hasViolations = outcome.edges.some(edge => edge.violation) + const violationCount = outcome.edges.filter(edge => edge.violation).length + const violationSlots = new Set( + outcome.edges + .filter(edge => edge.violation) + .flatMap(edge => [edge.from, edge.to]) + ) + const traceTouchesViolation = + trace !== null && [...violationSlots].some(slot => trace.has(slot)) + + return ( + // biome-ignore lint/a11y/useSemanticElements: an SVG cannot become a fieldset; role=group names the graph for assistive technology. + + {COLUMNS.map(column => ( + + + {column.title} + + + + ))} + + {outcome.edges.map(edge => { + const inTrace = + (trace?.has(edge.from) ?? false) && (trace?.has(edge.to) ?? false) + const inGoverned = governed.has(edge.from) && governed.has(edge.to) + const tone = edge.violation + ? 'violation' + : inTrace + ? 'active' + : trace === null && inGoverned + ? hasViolations + ? 'suppressed' + : 'governed' + : 'base' + const dim = trace !== null && !inTrace + return ( + + ) + })} + + {hasViolations && !checking ? ( + + + + {violationCount} × layer-skip + + + ) : null} + {[...placed.values()].map(({ node, x, y }) => { + const inTrace = trace?.has(node.slot) ?? false + const flag = + node.finding !== null || violationSlots.has(node.slot) + ? 'violation' + : (node.change ?? undefined) + const emphasis = inTrace + ? 'chain' + : node.slot === GOVERNED_SLOT && !hasViolations + ? 'governed' + : undefined + const selected = selectedSlot === node.slot + const textX = node.swatch === null ? x + 14 : x + 32 + return ( + // biome-ignore lint/a11y/useSemanticElements: HTML buttons cannot exist inside SVG; the node group carries role=button with full keyboard handling. + onSelectSlot(selected ? null : node.slot)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelectSlot(selected ? null : node.slot) + } else if (event.key === 'Escape') { + onSelectSlot(null) + } + }} + onMouseEnter={() => onTraceSlot(node.slot)} + onMouseLeave={() => onTraceSlot(null)} + onFocus={() => onTraceSlot(node.slot)} + onBlur={() => onTraceSlot(null)}> + + + {node.swatch === null ? null : ( + + )} + + {node.label} + + + {node.display} + + + ) + })} + + ) +} diff --git a/apps/docs/components/playground/playground.css b/apps/docs/components/playground/playground.css index 7b63e9e8..72f6afba 100644 --- a/apps/docs/components/playground/playground.css +++ b/apps/docs/components/playground/playground.css @@ -102,7 +102,7 @@ display: inline-flex; align-items: center; justify-content: center; - border-radius: 999px; + border-radius: 8px; padding: 10px 18px; font-size: 14px; font-weight: 600; @@ -111,8 +111,8 @@ } .pg-button.primary { - background: #fafafa; - color: #09090b; + background: var(--color-primitree-accent); + color: #04251a; } .pg-button.ghost { @@ -125,8 +125,8 @@ margin-top: 16px; padding: 12px 16px; border-radius: 12px; - border: 1px solid rgb(242 117 117 / 40%); - background: rgb(242 117 117 / 8%); + border: 1px solid rgb(248 113 113 / 40%); + background: rgb(248 113 113 / 8%); color: var(--color-primitree-error); font-size: 14px; font-family: var(--font-mono); @@ -166,8 +166,8 @@ .pg-warnings { margin: 0 0 16px; - border: 1px solid rgb(242 184 75 / 35%); - background: rgb(242 184 75 / 7%); + border: 1px solid rgb(251 191 36 / 35%); + background: rgb(251 191 36 / 7%); border-radius: 12px; padding: 10px 14px; font-size: 14px; @@ -230,7 +230,7 @@ border: 1px solid var(--color-primitree-border); background: transparent; color: var(--color-primitree-muted); - border-radius: 999px; + border-radius: 8px; padding: 0 12px; font-size: 13px; cursor: pointer; @@ -361,7 +361,7 @@ display: inline-block; padding: 2px 8px; border-radius: 999px; - background: rgb(69 201 139 / 10%); + background: rgb(52 211 153 / 10%); color: var(--color-primitree-good); font-size: 12px; font-weight: 600; diff --git a/apps/docs/lib/landing/governance-demo.ts b/apps/docs/lib/landing/governance-demo.ts index d020fe90..24efc53c 100644 --- a/apps/docs/lib/landing/governance-demo.ts +++ b/apps/docs/lib/landing/governance-demo.ts @@ -1,11 +1,16 @@ import { composeGraph, createSourceView, + diffGraphViews, getDependents, inspectToken, type TokenId, } from '@primitree/core' -import { createPolicy, evaluatePolicy } from '@primitree/core/policy' +import { + createPolicy, + evaluatePolicy, + type PolicyFinding, +} from '@primitree/core/policy' import { buildDTCGOutputs, createDTCGGraphFragment, @@ -14,6 +19,8 @@ import { type DTCGOutputSet, } from '@primitree/dtcg' +export type DemoLayerId = 'primitives' | 'semantic' | 'component' + export interface GovernanceBranch { readonly path: string } @@ -32,8 +39,55 @@ export interface GovernanceDemo { readonly status: 'BLOCK' readonly ruleId: 'PT1004' readonly reason: string + readonly findingCount: number } readonly outputs: readonly string[] + readonly tokenCount: number + readonly layerCount: number +} + +export interface GraphNode { + readonly slot: string + readonly path: string + readonly label: string + readonly layer: DemoLayerId + readonly kind: 'literal' | 'reference' + readonly display: string + readonly swatch: string | null + readonly owner: string + readonly consumers: number + readonly finding: string | null + readonly change: 'added' | 'changed' | null +} + +export interface GraphEdge { + readonly from: string + readonly to: string + readonly violation: boolean +} + +export interface ProposalOutcome { + readonly id: 'baseline' | 'alias' | 'violation' | 'rename' + readonly label: string + readonly status: 'PASS' | 'FAIL' + readonly findingsLine: string + readonly verdictTone: 'pass' | 'fail' + readonly detail: string + readonly description: string + readonly nodes: readonly GraphNode[] + readonly edges: readonly GraphEdge[] +} + +export interface RenameImpact { + readonly removedPath: string + readonly addedPath: string + readonly consumers: readonly string[] +} + +export interface ProposalDeck { + readonly outcomes: readonly ProposalOutcome[] + readonly diffReport: readonly string[] + readonly renameImpact: RenameImpact } const tokenPath = 'semantic.color.bg.brand' @@ -44,57 +98,136 @@ const expectedDependents = [ 'component.nav.active.bg', ] +const srgb = (components: readonly [number, number, number]) => + ({ + $type: 'color', + $value: { colorSpace: 'srgb', components: [...components], alpha: 1 }, + }) as const + +const px = (value: number) => + ({ $type: 'dimension', $value: { value, unit: 'px' } }) as const + +const colorRef = (target: string) => + ({ $type: 'color', $value: `{${target}}` }) as const + +const dimensionRef = (target: string) => + ({ $type: 'dimension', $value: `{${target}}` }) as const + const compliantDocument = { primitives: { color: { - blue: { - '500': { - $type: 'color', - $value: { - colorSpace: 'srgb', - components: [0.2, 0.4, 1], - alpha: 1, - }, - }, + blue: { '500': srgb([0.2, 0.4, 1]) }, + green: { '500': srgb([0.203922, 0.827451, 0.6]) }, + gray: { + '900': srgb([0.066667, 0.094118, 0.152941]), + '50': srgb([0.976471, 0.980392, 0.984314]), }, }, + radius: { md: px(6) }, + space: { '4': px(16) }, }, semantic: { color: { - bg: { - brand: { $type: 'color', $value: '{primitives.color.blue.500}' }, - support: { $type: 'color', $value: '{primitives.color.blue.500}' }, - }, + bg: { brand: colorRef('primitives.color.blue.500') }, + fg: { default: colorRef('primitives.color.gray.900') }, }, + radius: { control: dimensionRef('primitives.radius.md') }, + space: { inset: dimensionRef('primitives.space.4') }, }, component: { - button: { bg: { $type: 'color', $value: '{semantic.color.bg.brand}' } }, - nav: { - active: { - bg: { $type: 'color', $value: '{semantic.color.bg.brand}' }, - }, + button: { + bg: colorRef('semantic.color.bg.brand'), + radius: dimensionRef('semantic.radius.control'), }, - focus: { ring: { $type: 'color', $value: '{semantic.color.bg.brand}' } }, + focus: { ring: colorRef('semantic.color.bg.brand') }, + nav: { active: { bg: colorRef('semantic.color.bg.brand') } }, + card: { inset: dimensionRef('semantic.space.inset') }, + }, +} satisfies DTCGDocument + +const aliasDocument = { + ...compliantDocument, + semantic: { + ...compliantDocument.semantic, color: { - brand: { $type: 'color', $value: '{semantic.color.bg.support}' }, + ...compliantDocument.semantic.color, + bg: { brand: colorRef('primitives.color.green.500') }, }, }, -} satisfies DTCGDocument +} as const -const blockedDocument = { +const violationDocument = { + ...compliantDocument, + component: { + ...compliantDocument.component, + button: { + ...compliantDocument.component.button, + bg: colorRef('primitives.color.blue.500'), + }, + focus: { ring: colorRef('primitives.color.green.500') }, + card: { inset: dimensionRef('primitives.space.4') }, + }, +} as const + +const renameDocument = { ...compliantDocument, semantic: { ...compliantDocument.semantic, color: { ...compliantDocument.semantic.color, - bg: { - ...compliantDocument.semantic.color.bg, - brand: { $type: 'color', $value: '{component.color.brand}' }, - }, + bg: { primary: colorRef('primitives.color.blue.500') }, + }, + }, + component: { + ...compliantDocument.component, + button: { + ...compliantDocument.component.button, + bg: colorRef('semantic.color.bg.primary'), }, + focus: { ring: colorRef('semantic.color.bg.primary') }, + nav: { active: { bg: colorRef('semantic.color.bg.primary') } }, }, } as const +/** Row order for each graph column. Slots keep node positions stable while a + * proposal swaps one path, so a rename reads as motion in place. */ +const graphSlots: readonly (readonly [DemoLayerId, readonly string[]])[] = [ + [ + 'primitives', + [ + 'primitives.color.blue.500', + 'primitives.color.green.500', + 'primitives.color.gray.900', + 'primitives.color.gray.50', + 'primitives.radius.md', + 'primitives.space.4', + ], + ], + [ + 'semantic', + [ + 'semantic.color.bg.brand', + 'semantic.color.fg.default', + 'semantic.radius.control', + 'semantic.space.inset', + ], + ], + [ + 'component', + [ + 'component.button.bg', + 'component.focus.ring', + 'component.nav.active.bg', + 'component.button.radius', + 'component.card.inset', + ], + ], +] + +const renameSlotPaths = new Map([ + ['semantic.color.bg.brand', 'semantic.color.bg.primary'], +]) + const policyInput = { id: 'living-canopy', viewId: 'living-canopy', @@ -191,7 +324,7 @@ function resolveSrgbHex(value: unknown): string { value.alpha !== 1 ) { throw new Error( - 'Governance demo expected the brand token to resolve to an opaque sRGB color.' + 'Governance demo expected the token to resolve to an opaque sRGB color.' ) } const channels = value.components.map(component => { @@ -207,6 +340,51 @@ function resolveSrgbHex(value: unknown): string { return `#${channels.join('')}` } +function isSrgbColor(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + 'colorSpace' in value && + 'components' in value + ) +} + +function resolveDisplayValue(value: unknown): string { + if (isSrgbColor(value)) { + return resolveSrgbHex(value) + } + if ( + typeof value === 'object' && + value !== null && + 'value' in value && + 'unit' in value && + typeof value.value === 'number' && + typeof value.unit === 'string' + ) { + return `${value.value}${value.unit}` + } + throw new Error('Governance demo expected a color or dimension value.') +} + +/** Reference targets drop the layer root and a leading color group, the way + * the graph abbreviates them: primitives.color.blue.500 reads as blue.500 and + * semantic.color.bg.brand reads as bg.brand. */ +function abbreviate(path: string): string { + return path + .split('.') + .slice(1) + .join('.') + .replace(/^color\./u, '') +} + +/** Node labels drop the layer root; primitive labels abbreviate further so + * color scales read as blue.500. */ +function nodeLabel(path: string, layer: DemoLayerId): string { + return layer === 'primitives' + ? abbreviate(path) + : path.split('.').slice(1).join('.') +} + function buildSnapshot(document: unknown) { const fragment = requireBuildValue( createDTCGGraphFragment(document, { source: 'living-canopy' }), @@ -220,6 +398,8 @@ function buildSnapshot(document: unknown) { return { graph, view } } +type Snapshot = ReturnType + function buildOutputLabels( outputOptions: BuildOutputOptions ): readonly string[] { @@ -231,11 +411,191 @@ function buildOutputLabels( return Object.freeze(labels) } -/** Builds the serializable evidence projection rendered by the Living Canopy. */ +function ownerForPath(path: string): string { + const roots = policyInput.ownership.paths + for (const [root, owners] of Object.entries(roots)) { + if (path === root || path.startsWith(`${root}.`)) { + const owner = owners[0] + if (owner !== undefined) { + return owner + } + } + } + const fallback = policyInput.ownership.default[0] + if (fallback === undefined) { + throw new Error('Governance demo expected a default owner.') + } + return fallback +} + +function buildGraphProjection( + snapshot: Snapshot, + findings: readonly PolicyFinding[], + changesByPath: ReadonlyMap +): { nodes: readonly GraphNode[]; edges: readonly GraphEdge[] } { + const paths = new Map( + snapshot.view.tokens.map(token => [token.tokenId, token.path]) + ) + const slotByPath = new Map() + const findingByPath = new Map( + findings.map(finding => [finding.path.join('.'), finding.ruleId]) + ) + + const nodes: GraphNode[] = [] + const edges: GraphEdge[] = [] + + for (const [layer, slots] of graphSlots) { + for (const slot of slots) { + const path = renameSlotPaths.get(slot) ?? slot + const resolvedPath = snapshot.view.tokens.some( + token => token.path.join('.') === path + ) + ? path + : slot + slotByPath.set(resolvedPath, slot) + + const inspection = requireBuildValue( + inspectToken(snapshot, { + kind: 'path', + path: resolvedPath.split('.'), + }), + `inspection of ${resolvedPath}` + ) + const chain = inspection.resolution.referenceChain + const kind = chain.length > 1 ? 'reference' : 'literal' + const resolvedDisplay = resolveDisplayValue(inspection.resolution.value) + const dependents = requireBuildValue( + getDependents(snapshot.graph, inspection.tokenId), + `dependents of ${resolvedPath}` + ) + + let display = resolvedDisplay + if (kind === 'reference') { + const targetId = chain[1] + if (targetId === undefined) { + throw new Error( + `Governance demo expected a direct target for ${resolvedPath}.` + ) + } + const targetPath = requirePath(paths, targetId, 'reference target') + display = `→ {${abbreviate(targetPath)}}` + edges.push({ + from: targetPath, + to: resolvedPath, + violation: findingByPath.has(resolvedPath), + }) + } + + nodes.push({ + slot, + path: resolvedPath, + label: nodeLabel(resolvedPath, layer), + layer, + kind, + display, + swatch: + kind === 'literal' && isSrgbColor(inspection.resolution.value) + ? resolvedDisplay + : null, + owner: ownerForPath(resolvedPath), + consumers: dependents.length, + finding: findingByPath.get(resolvedPath) ?? null, + change: changesByPath.get(resolvedPath) ?? null, + }) + } + } + + const slottedEdges = edges.map(edge => { + const from = slotByPath.get(edge.from) + const to = slotByPath.get(edge.to) + if (from === undefined || to === undefined) { + throw new Error('Governance demo expected every edge to land in a slot.') + } + return Object.freeze({ from, to, violation: edge.violation }) + }) + + return { + nodes: Object.freeze(nodes.map(node => Object.freeze(node))), + edges: Object.freeze(slottedEdges), + } +} + +function evaluateFindings(snapshot: Snapshot): readonly PolicyFinding[] { + const policy = requireBuildValue(createPolicy(policyInput), 'policy creation') + const report = requireBuildValue( + evaluatePolicy(snapshot, policy), + 'policy evaluation' + ) + return report.findings.filter(finding => finding.disposition === 'active') +} + +function buildDiffReport( + before: Snapshot, + after: Snapshot +): { lines: readonly string[]; changeCount: number; impactedCount: number } { + const paths = new Map() + for (const token of [...before.view.tokens, ...after.view.tokens]) { + paths.set(token.tokenId, token.path.join('.')) + } + const diff = requireBuildValue(diffGraphViews(before, after), 'rename diff') + const describe = (tokenId: TokenId) => { + const path = paths.get(tokenId) + if (path === undefined) { + throw new Error('Governance demo expected a path for every diff token.') + } + return path + } + const impacted = new Set() + const lines: string[] = [ + '$ primitree diff tokens.before.json tokens.json --config primitree.config.ts', + '', + `Diff found ${diff.changes.length} token changes.`, + ] + for (const change of diff.changes) { + lines.push(`${change.kind} ${describe(change.tokenId)}`) + if (change.impactedTokenIds.length === 0) { + lines.push(' Affected tokens: none') + } else { + const impactedPaths = change.impactedTokenIds.map(impactedId => { + const impactedPath = describe(impactedId) + impacted.add(impactedPath) + return impactedPath + }) + lines.push(` Affected tokens: ${impactedPaths.join(', ')}`) + } + } + lines.push('New findings: 0 · Resolved findings: 0') + return { + lines: Object.freeze(lines), + changeCount: diff.changes.length, + impactedCount: impacted.size, + } +} + +/** Renders the policy layers as the config excerpt the quickstart shows, + * complete enough to satisfy config validation. */ +export function describeLayersSnippet(): string { + const lines = policyInput.layers.map((layer, index) => { + const parts = [ + `id: '${layer.id}'`, + `roots: [${layer.roots.map(root => `'${root}'`).join(', ')}]`, + `values: '${layer.values}'`, + ] + if (layer.references.length > 0) { + parts.push( + `references: [${layer.references.map(reference => `'${reference}'`).join(', ')}]` + ) + } + const comma = index === policyInput.layers.length - 1 ? '' : ',' + return ` { ${parts.join(', ')} }${comma}` + }) + return `// primitree.config.ts\nlayers: [\n${lines.join('\n')}\n]` +} + +/** Builds the serializable evidence projection for the homepage. */ export function createGovernanceDemo( outputOptions: BuildOutputOptions = {} ): GovernanceDemo { - const policy = requireBuildValue(createPolicy(policyInput), 'policy creation') const compliantSnapshot = buildSnapshot(compliantDocument) const inspection = requireBuildValue( inspectToken(compliantSnapshot, { @@ -275,39 +635,32 @@ export function createGovernanceDemo( 'Governance demo expected three direct component dependents.' ) } - const compliantReport = requireBuildValue( - evaluatePolicy(compliantSnapshot, policy), - 'compliant policy evaluation' - ) - if (compliantReport.summary.active !== 0) { + const compliantFindings = evaluateFindings(compliantSnapshot) + if (compliantFindings.length !== 0) { throw new Error('Governance demo expected the compliant policy to pass.') } - const blockedSnapshot = buildSnapshot(blockedDocument) - const blockedReport = requireBuildValue( - evaluatePolicy(blockedSnapshot, policy), - 'blocked policy evaluation' - ) - const blockedFinding = blockedReport.findings.find( - finding => finding.ruleId === 'PT1004' - ) + const violationFindings = evaluateFindings(buildSnapshot(violationDocument)) + const firstFinding = violationFindings[0] if ( - blockedFinding === undefined || - blockedFinding.message !== - 'Layer semantic cannot reference layer component.' + violationFindings.length !== 3 || + firstFinding === undefined || + violationFindings.some( + finding => + finding.ruleId !== 'PT1004' || + finding.message !== 'Layer component cannot reference layer primitives.' + ) ) { - throw new Error('Governance demo expected the blocked policy finding.') - } - const owner = policy.ownership.paths.semantic?.[0] - if (owner === undefined) { - throw new Error('Governance demo expected a semantic token owner.') + throw new Error( + 'Governance demo expected three layer-skip findings from PT1004.' + ) } return Object.freeze({ tokenPath, aliasTarget: inspectedAliasTarget, resolvedValue: resolveSrgbHex(inspection.resolution.value), - owner, + owner: ownerForPath(tokenPath), directDependents: Object.freeze( directDependents.map(path => Object.freeze({ path })) ), @@ -315,8 +668,171 @@ export function createGovernanceDemo( blocked: Object.freeze({ status: 'BLOCK' as const, ruleId: 'PT1004' as const, - reason: blockedFinding.message, + reason: firstFinding.message, + findingCount: violationFindings.length, }), outputs: buildOutputLabels(outputOptions), + tokenCount: compliantSnapshot.view.tokens.length, + layerCount: policyInput.layers.length, + }) +} + +/** Builds the click-driven proposal outcomes for the homepage graph. */ +export function createProposalDeck(): ProposalDeck { + const baselineSnapshot = buildSnapshot(compliantDocument) + const aliasSnapshot = buildSnapshot(aliasDocument) + const violationSnapshot = buildSnapshot(violationDocument) + const renameSnapshot = buildSnapshot(renameDocument) + + const baselineFindings = evaluateFindings(baselineSnapshot) + const aliasFindings = evaluateFindings(aliasSnapshot) + const violationFindings = evaluateFindings(violationSnapshot) + const renameFindings = evaluateFindings(renameSnapshot) + if (baselineFindings.length !== 0 || aliasFindings.length !== 0) { + throw new Error('Governance demo expected the compliant graphs to pass.') + } + if (renameFindings.length !== 0) { + throw new Error('Governance demo expected the rename proposal to pass.') + } + const violationFinding = violationFindings[0] + if (violationFindings.length !== 3 || violationFinding === undefined) { + throw new Error('Governance demo expected three PT1004 findings.') + } + + const aliasHex = resolveSrgbHex( + requireBuildValue( + inspectToken(aliasSnapshot, { + kind: 'path', + path: tokenPath.split('.'), + }), + 'alias inspection' + ).resolution.value + ) + if (aliasHex !== '#34d399') { + throw new Error( + 'Governance demo expected the green alias to resolve to #34d399.' + ) + } + + const diff = buildDiffReport(baselineSnapshot, renameSnapshot) + if (!diff.lines.includes('removed semantic.color.bg.brand')) { + throw new Error( + 'Governance demo expected the rename diff to remove the old path.' + ) + } + + const renameChanges = new Map([ + ['semantic.color.bg.primary', 'added'], + ...expectedDependents.map(path => [path, 'changed'] as [string, 'changed']), + ]) + const noChanges = new Map() + + const dependentList = expectedDependents.join(', ') + + /** Counts a node's consumers from the wires in view, so the inspect line + * matches the graph the visitor is looking at. */ + const withEdgeConsumers = (graph: { + nodes: readonly GraphNode[] + edges: readonly GraphEdge[] + }) => ({ + edges: graph.edges, + nodes: Object.freeze( + graph.nodes.map(node => + Object.freeze({ + ...node, + consumers: graph.edges.filter(edge => edge.from === node.slot).length, + }) + ) + ), + }) + + const baselineGraph = withEdgeConsumers( + buildGraphProjection(baselineSnapshot, baselineFindings, noChanges) + ) + const aliasGraph = withEdgeConsumers( + buildGraphProjection(aliasSnapshot, aliasFindings, noChanges) + ) + const violationProjection = buildGraphProjection( + violationSnapshot, + violationFindings, + noChanges + ) + // The design keeps every compliant wire visible under the red bypass + // lanes, suppressed, so the violation preset overlays the baseline graph + // instead of replacing it. + const violationEdgeKeys = new Set( + violationProjection.edges.map(edge => `${edge.from}>${edge.to}`) + ) + const violationGraph = withEdgeConsumers({ + nodes: violationProjection.nodes, + edges: Object.freeze([ + ...violationProjection.edges, + ...baselineGraph.edges.filter( + edge => !violationEdgeKeys.has(`${edge.from}>${edge.to}`) + ), + ]), + }) + const renameGraph = withEdgeConsumers( + buildGraphProjection(renameSnapshot, renameFindings, renameChanges) + ) + + const outcomes: readonly ProposalOutcome[] = Object.freeze([ + Object.freeze({ + id: 'baseline' as const, + label: 'Current graph', + status: 'PASS' as const, + findingsLine: `${baselineFindings.length} findings`, + verdictTone: 'pass' as const, + detail: + 'semantic points at primitives, component points at semantic. Nothing for policy to flag.', + description: `${tokenPath} references ${aliasTarget} and feeds ${dependentList}. The check passes with zero findings.`, + nodes: baselineGraph.nodes, + edges: baselineGraph.edges, + }), + Object.freeze({ + id: 'alias' as const, + label: 'Compliant alias', + status: 'PASS' as const, + findingsLine: `${aliasFindings.length} findings`, + verdictTone: 'pass' as const, + detail: + 'alias now points at {green.500}. Same layer, same owner, nothing to flag.', + description: `${tokenPath} references primitives.color.green.500, resolves to ${aliasHex}, and feeds ${dependentList}. The check passes.`, + nodes: aliasGraph.nodes, + edges: aliasGraph.edges, + }), + Object.freeze({ + id: 'violation' as const, + label: 'Layer violations', + status: 'FAIL' as const, + findingsLine: `${violationFindings.length} findings · layer-skip`, + verdictTone: 'fail' as const, + detail: + 'button.bg, focus.ring, and card.inset reference primitives directly. component may only reference semantic.', + description: `Three component tokens reference primitives, skipping the semantic layer. Policy ${violationFinding.ruleId} blocks the change: ${violationFinding.message}`, + nodes: violationGraph.nodes, + edges: violationGraph.edges, + }), + Object.freeze({ + id: 'rename' as const, + label: 'Rename', + status: 'PASS' as const, + findingsLine: `${diff.changeCount} changes · ${renameFindings.length} findings`, + verdictTone: 'pass' as const, + detail: 'bg.brand → bg.primary pairs with 3 consumers before merge.', + description: `semantic.color.bg.brand becomes semantic.color.bg.primary. The diff lists ${dependentList} as affected consumers.`, + nodes: renameGraph.nodes, + edges: renameGraph.edges, + }), + ]) + + return Object.freeze({ + outcomes, + diffReport: diff.lines, + renameImpact: Object.freeze({ + removedPath: tokenPath, + addedPath: 'semantic.color.bg.primary', + consumers: Object.freeze(expectedDependents.map(abbreviate)), + }), }) } diff --git a/apps/docs/lib/landing/release.ts b/apps/docs/lib/landing/release.ts new file mode 100644 index 00000000..b3a29c9f --- /dev/null +++ b/apps/docs/lib/landing/release.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +let cachedVersion: string | undefined + +/** Reads the published primitree package version once per process. */ +export function readPrimitreeVersion(): string { + if (cachedVersion !== undefined) { + return cachedVersion + } + const packagePath = resolve( + process.cwd(), + '../../packages/primitree/package.json' + ) + const parsed: unknown = JSON.parse(readFileSync(packagePath, 'utf8')) + if ( + typeof parsed !== 'object' || + parsed === null || + !('version' in parsed) || + typeof parsed.version !== 'string' + ) { + throw new Error('The primitree package manifest has no version field.') + } + cachedVersion = parsed.version + return cachedVersion +} diff --git a/apps/docs/package.json b/apps/docs/package.json index 96e464ce..e2c34265 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,6 +16,7 @@ "postinstall": "pnpm run generate:api && pnpm run check:prose && pnpm run check:links && fumadocs-mdx" }, "dependencies": { + "@phosphor-icons/react": "^2.1.10", "@primitree/core": "workspace:*", "@primitree/dtcg": "workspace:*", "cnfast": "^0.0.8", @@ -25,6 +26,7 @@ "fumadocs-ui": "16.10.7", "lucide-react": "^1.21.0", "next": "16.2.9", + "next-themes": "0.4.6", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/apps/docs/public/favicon.svg b/apps/docs/public/favicon.svg index 4fc6a87f..9a55389d 100644 --- a/apps/docs/public/favicon.svg +++ b/apps/docs/public/favicon.svg @@ -1,4 +1,4 @@ Primitree - + diff --git a/apps/docs/tests/brand-logo.test.ts b/apps/docs/tests/brand-logo.test.ts index a47eda27..c4b96164 100644 --- a/apps/docs/tests/brand-logo.test.ts +++ b/apps/docs/tests/brand-logo.test.ts @@ -93,13 +93,24 @@ async function loadBrandLogo() { test('linked BrandLogo exposes one Primitree name with the compact tree mark', async () => { const BrandLogo = await loadBrandLogo() const logo = BrandLogo({ linked: true }) - const inner = logo?.props.children as ElementNode - const mark = (inner.props.children as ElementNode[])[0] + const nodes = elementNodes(logo) + const markComponent = nodes.find( + node => + typeof node.type === 'function' && + (node.type as { name?: string }).name === 'BrandMark' + ) + assert.ok(markComponent, 'the logo must render the inline BrandMark') + const mark = (markComponent.type as (props: object) => ElementNode)( + markComponent.props + ) + const markBody = elementNodes(mark).find( + node => node.props.className === 'brand-mark-body' + ) assert.equal(textContent(logo), 'Primitree') assert.equal(logo.props.href, '/') - assert.equal(mark.props.src, '/primitree-icon.svg') - assert.equal(mark.props.alt, '') + assert.equal(mark.type, 'svg') + assert.ok(markBody, 'the mark body must carry the themable brand class') }) test('unlinked BrandLogo keeps the mark and wordmark in one caller-styled layout wrapper', async () => { diff --git a/apps/docs/tests/color-system.test.ts b/apps/docs/tests/color-system.test.ts index adab5f14..0144bcd3 100644 --- a/apps/docs/tests/color-system.test.ts +++ b/apps/docs/tests/color-system.test.ts @@ -9,14 +9,18 @@ const formerBrandColors = [ '#7b8cff', '#c7d2fe', '#a78bfa', - '#34d399', - '#fbbf24', '#ff6b81', + '#a8c95f', + '#5f7f2f', + '#dde9b9', + '#45c98b', + '#f2b84b', + '#f27575', 'rgba(139, 156, 255', 'rgba(123, 140, 255', - 'rgba(52, 211, 153', - 'rgba(251, 191, 36', 'rgba(255, 107, 129', + 'rgb(168 201 95', + 'rgb(69 201 139', ] as const async function read(relativePath: string) { @@ -85,25 +89,49 @@ function flatCssRules(theme: string) { ) } -test('documentation theme exposes the approved Lichen palette', async () => { +test('documentation theme exposes the approved brand palette', async () => { const theme = await read('../app/global.css') - assert.match(theme, /--color-primitree-accent: #a8c95f;/u) - assert.match(theme, /--color-primitree-accent-strong: #5f7f2f;/u) - assert.match(theme, /--color-primitree-accent-soft: #dde9b9;/u) + assert.match(theme, /--color-primitree-accent: #34d399;/u) + assert.match(theme, /--color-primitree-accent-strong: #0b9464;/u) + assert.match(theme, /--color-primitree-accent-soft: #6ee7b7;/u) + assert.match(theme, /--color-primitree-accent-ink: #04251a;/u) assert.match( theme, - /--color-primitree-accent-wash: rgb\(168 201 95 \/ 10%\);/u + /--color-primitree-accent-wash: rgb\(52 211 153 \/ 10%\);/u ) - assert.match(theme, /--color-primitree-good: #45c98b;/u) - assert.match(theme, /--color-primitree-warn: #f2b84b;/u) - assert.match(theme, /--color-primitree-error: #f27575;/u) + assert.match(theme, /--color-primitree-good: #34d399;/u) + assert.match(theme, /--color-primitree-warn: #fbbf24;/u) + assert.match(theme, /--color-primitree-error: #f87171;/u) assert.match( theme, - /::selection\s*\{\s*background: rgb\(168 201 95 \/ 25%\);/u + /::selection\s*\{\s*background: rgb\(52 211 153 \/ 25%\);/u ) }) +test('documentation theme carries a complete light overlay', async () => { + const theme = await read('../app/global.css') + const lightBlock = theme.match( + /:root\[data-theme='light'\] \{[\s\S]*?\n\}/u + )?.[0] + assert.ok(lightBlock, 'the light theme overlay must exist') + + for (const variable of [ + '--color-primitree-bg: #f6faf8;', + '--color-primitree-surface: #ffffff;', + '--color-primitree-text: #0a1510;', + '--color-primitree-accent: #0b9464;', + '--color-primitree-accent-ink: #eafff6;', + '--color-primitree-warn: #b45309;', + '--color-primitree-error: #dc2626;', + ]) { + assert.ok( + lightBlock.includes(variable), + `light theme must define ${variable}` + ) + } +}) + test('documentation dim text clears AA contrast on every landing surface', async () => { const theme = await read('../app/global.css') const dim = themeHex(theme, 'color-primitree-dim') @@ -125,52 +153,66 @@ test('documentation dim text clears AA contrast on every landing surface', async } }) -test('living canopy never fades text-bearing stages or graph nodes', async () => { +test('token graph fades text only for the transient hover trace', async () => { const theme = await read('../app/global.css') const textBearingSelector = - /\.canopy-(?:source|token|dependent|output)-node|\.canopy-stage-track\s*>\s*li/u + /\.graph-node(?!-swatch)|\.graph-violation-tag|\.proof-(?:verdict|detail)/u const fadedTextRules = flatCssRules(theme).filter( ({ declarations, selectors }) => - textBearingSelector.test(selectors) && /\bopacity\s*:/u.test(declarations) + textBearingSelector.test(selectors) && + /\bopacity\s*:/u.test(declarations) && + !selectors.includes('[data-dim]') ) assert.deepEqual( fadedTextRules, [], - 'stage emphasis must change connectors and borders, never text opacity' + 'static outcome emphasis must change connectors and borders, never text opacity' ) }) -test('living canopy provides a legible compact graph below tablet width', async () => { - const [theme, canopy] = await Promise.all([ +test('token graph scrolls in place and keeps its labels legible', async () => { + const [theme, graph] = await Promise.all([ read('../app/global.css'), - read('../components/landing/living-canopy.tsx'), + read('../components/landing/token-graph.tsx'), ]) - assert.match(canopy, /className='canopy-mobile-graph'/u) - assert.match(theme, /\.canopy-mobile-graph\s*\{[\s\S]*?display: none;/u) - assert.match( - theme, - /@media \(max-width: 719px\)[\s\S]*?\.canopy-svg\s*\{\s*display: none;\s*\}/u - ) - assert.match( - theme, - /@media \(max-width: 719px\)[\s\S]*?\.canopy-mobile-graph\s*\{[\s\S]*?display: grid;/u - ) + assert.match(graph, /className='graph-svg'/u) + assert.match(graph, /viewBox=\{`0 0 \$\{VIEW_WIDTH\} \$\{VIEW_HEIGHT\}`\}/u) + const viewWidth = graph.match(/const VIEW_WIDTH = (\d+)/u)?.[1] + assert.ok(viewWidth, 'the graph must declare its viewBox width') + + const ruleFor = (selector: string) => + flatCssRules(theme).find( + ({ selectors }) => + selectors.replace(/\/\*[\s\S]*?\*\//gu, '').trim() === selector + ) + const scrollRule = ruleFor('.graph-scroll') assert.match( - theme, - /@media \(max-width: 719px\)[\s\S]*?\.canopy-stage-track strong\s*\{\s*font-size: 12px;\s*\}/u + scrollRule?.declarations ?? '', + /overflow-x: auto;/u, + 'the graph must scroll inside its own container on small screens' ) - const mobileNodeRule = flatCssRules(theme).find(({ selectors }) => - selectors.trim().startsWith('.canopy-mobile-node') - ) - const fontSize = mobileNodeRule?.declarations.match( + const frameRule = ruleFor('.graph-frame') + const minWidth = frameRule?.declarations.match( + /min-width:\s*([0-9.]+)px/u + )?.[1] + assert.ok(minWidth, 'the graph frame must set a minimum width') + + const labelRule = ruleFor('.graph-node-label') + const fontSize = labelRule?.declarations.match( /font-size:\s*([0-9.]+)px/u )?.[1] - assert.ok(fontSize, 'compact graph nodes must set a CSS-pixel font size') - assert.ok(Number(fontSize) >= 12, 'compact graph text must be at least 12px') + assert.ok(fontSize, 'graph labels must set a CSS-pixel font size') + + const effectiveFontSize = + (Number(minWidth) / Number(viewWidth)) * Number(fontSize) + assert.ok( + effectiveFontSize >= 12, + `graph labels must stay at least 12px when scaled (got ${effectiveFontSize.toFixed(2)}px)` + ) }) test('documentation shell maps Fumadocs to the Lichen theme', async () => { @@ -180,7 +222,10 @@ test('documentation shell maps Fumadocs to the Lichen theme', async () => { assert.match(theme, /--color-fd-background: var\(--color-primitree-bg\);/u) assert.match(theme, /--color-fd-foreground: var\(--color-primitree-text\);/u) assert.match(theme, /--color-fd-primary: var\(--color-primitree-accent\);/u) - assert.match(theme, /--color-fd-primary-foreground: #09090b;/u) + assert.match( + theme, + /--color-fd-primary-foreground: var\(--color-primitree-accent-ink\);/u + ) assert.match( theme, /--color-fd-accent: var\(--color-primitree-accent-wash\);/u @@ -193,7 +238,10 @@ test('documentation shell maps Fumadocs to the Lichen theme', async () => { test('documentation brand files contain no former Primitree purple treatment', async () => { const sources = await Promise.all([ read('../app/global.css'), - read('../components/landing/living-canopy.tsx'), + read('../components/landing/token-graph.tsx'), + read('../components/landing/proof-panel.tsx'), + read('../components/landing/install-card.tsx'), + read('../components/landing/governance-home.tsx'), read('../components/playground/playground.css'), read('../public/favicon.svg'), read('../public/primitree-icon.svg'), @@ -205,31 +253,31 @@ test('documentation brand files contain no former Primitree purple treatment', a } }) -test('living canopy makes the Bone mark a structural Lichen root', async () => { - const [theme, canopy] = await Promise.all([ +test('token graph keeps the structural Lichen contract', async () => { + const [theme, graph] = await Promise.all([ read('../app/global.css'), - read('../components/landing/living-canopy.tsx'), + read('../components/landing/token-graph.tsx'), ]) - assert.match(canopy, /className='canopy-logo'/u) + assert.match(graph, /className='graph-node'/u) + assert.match(graph, /className='graph-edge'/u) + assert.doesNotMatch(graph, /(?:linear|radial)Gradient|filter=|pointermove/u) + assert.doesNotMatch(graph, /\binfinite\b/u) assert.match( - canopy, - /className='canopy-logo-body'\s+d=\{MARK_PATH\}\s+fill='var\(--color-primitree-text\)'/u + theme, + /\.graph-node-box\s*\{[\s\S]*?fill: var\(--color-primitree-surface\);/u ) - assert.match(canopy, /className='canopy-root-node'/u) - assert.doesNotMatch(canopy, /(?:linear|radial)Gradient|filter=|pointermove/u) - assert.doesNotMatch(canopy, /\binfinite\b/u) assert.match( theme, - /\.canopy-logo-body\s*\{\s*fill: var\(--color-primitree-text\);\s*\}/u + /\.graph-edge\[data-tone='governed'\]\s*\{[\s\S]*?stroke: var\(--color-primitree-accent\);/u ) assert.match( theme, - /\.canopy-source-swatch,\s*\.canopy-root-node\s*\{\s*fill: var\(--color-primitree-accent\);\s*\}/u + /\.graph-edge\[data-tone='violation'\]\s*\{[\s\S]*?stroke: var\(--color-primitree-error\);/u ) assert.match( theme, - /\.canopy-trunk\s*\{[\s\S]*stroke: var\(--color-primitree-accent\);/u + /\.graph-node:focus-visible \.graph-node-box\s*\{[\s\S]*?stroke: var\(--color-primitree-text\);/u ) }) @@ -247,7 +295,7 @@ test('documentation UI uses solid accents, neutral actions, and neutral focus', ) assert.match( playground, - /\.pg-button\.primary\s*\{\s*background: #fafafa;\s*color: #09090b;\s*\}/u + /\.pg-button\.primary\s*\{\s*background: var\(--color-primitree-accent\);\s*color: #04251a;\s*\}/u ) assert.match(playground, /outline: 2px solid var\(--color-primitree-text\);/u) assert.match( @@ -267,23 +315,22 @@ test('documentation UI uses solid accents, neutral actions, and neutral focus', chrome, /import\s*\{\s*FullSearchTrigger,\s*SearchTrigger,\s*\}\s*from 'fumadocs-ui\/layouts\/shared\/slots\/search-trigger'/u ) - assert.match(chrome, /function GithubMark\(\)/u) - assert.match(chrome, /fill='currentColor'/u) + assert.match(chrome, /GithubLogoIcon/u) assert.match(chrome, /aria-label='Primitree on GitHub'/u) assert.match( theme, - /\.site-icon-control\s*\{[\s\S]*width: 44px !important;[\s\S]*height: 44px !important;/u + /\.site-icon-control\s*\{[\s\S]*width: 44px;[\s\S]*height: 44px;/u ) }) -test('documentation static mark stays white and favicon uses the light-surface Lichen accent', async () => { +test('documentation static mark stays white and favicon uses the light-surface accent', async () => { const [mark, favicon] = await Promise.all([ read('../public/primitree-icon.svg'), read('../public/favicon.svg'), ]) assert.match(mark, /fill="(?:white|#ffffff)"/iu) - assert.match(favicon, /fill="#5f7f2f"/iu) + assert.match(favicon, /fill="#0b9464"/iu) assert.equal((mark.match(/\bfill="(?:white|#ffffff)"/giu) ?? []).length, 1) - assert.equal((favicon.match(/\bfill="#5f7f2f"/giu) ?? []).length, 1) + assert.equal((favicon.match(/\bfill="#0b9464"/giu) ?? []).length, 1) }) diff --git a/apps/docs/tests/governance-demo.test.ts b/apps/docs/tests/governance-demo.test.ts index a402023c..71fe78e2 100644 --- a/apps/docs/tests/governance-demo.test.ts +++ b/apps/docs/tests/governance-demo.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { createGovernanceDemo } from '../lib/landing/governance-demo.ts' +import { + createGovernanceDemo, + createProposalDeck, + describeLayersSnippet, +} from '../lib/landing/governance-demo.ts' test('governance demo derives truthful graph and policy evidence', () => { const demo = createGovernanceDemo() @@ -16,9 +20,12 @@ test('governance demo derives truthful graph and policy evidence', () => { assert.deepEqual(demo.blocked, { status: 'BLOCK', ruleId: 'PT1004', - reason: 'Layer semantic cannot reference layer component.', + reason: 'Layer component cannot reference layer primitives.', + findingCount: 3, }) assert.deepEqual(demo.outputs, ['CSS', 'Tailwind', 'TypeScript']) + assert.equal(demo.tokenCount, 15) + assert.equal(demo.layerCount, 3) }) test('governance demo derives output labels from the DTCG build result', () => { @@ -30,3 +37,140 @@ test('governance demo derives output labels from the DTCG build result', () => { assert.deepEqual(tailwindOnly.outputs, ['Tailwind']) }) + +test('proposal deck derives every outcome from the engine', () => { + const deck = createProposalDeck() + + assert.deepEqual( + deck.outcomes.map(outcome => outcome.id), + ['baseline', 'alias', 'violation', 'rename'] + ) + + const [baseline, alias, violation, rename] = deck.outcomes + assert.equal(baseline?.status, 'PASS') + assert.equal(baseline?.findingsLine, '0 findings') + const baselineBrand = baseline?.nodes.find( + node => node.slot === 'semantic.color.bg.brand' + ) + assert.equal(baselineBrand?.label, 'color.bg.brand') + assert.equal(baselineBrand?.display, '→ {blue.500}') + assert.equal(baselineBrand?.consumers, 3) + assert.equal(baselineBrand?.owner, 'product-design') + const baselineBlue = baseline?.nodes.find( + node => node.slot === 'primitives.color.blue.500' + ) + assert.equal(baselineBlue?.label, 'blue.500') + assert.equal(baselineBlue?.swatch, '#3366ff') + const baselineButton = baseline?.nodes.find( + node => node.slot === 'component.button.bg' + ) + assert.equal(baselineButton?.display, '→ {bg.brand}') + + assert.equal(alias?.status, 'PASS') + const aliasBrand = alias?.nodes.find( + node => node.slot === 'semantic.color.bg.brand' + ) + assert.equal(aliasBrand?.display, '→ {green.500}') + const aliasGreen = alias?.nodes.find( + node => node.slot === 'primitives.color.green.500' + ) + assert.equal(aliasGreen?.swatch, '#34d399') + + assert.equal(violation?.status, 'FAIL') + assert.equal(violation?.findingsLine, '3 findings · layer-skip') + assert.equal(violation?.edges.filter(edge => edge.violation).length, 3) + const violationBrand = violation?.nodes.find( + node => node.slot === 'semantic.color.bg.brand' + ) + assert.equal(violationBrand?.consumers, 3) + assert.deepEqual( + violation?.nodes + .filter(node => node.finding !== null) + .map(node => node.path) + .sort(), + ['component.button.bg', 'component.card.inset', 'component.focus.ring'] + ) + assert.deepEqual( + violation?.edges + .filter(edge => edge.violation) + .map(edge => `${edge.from}>${edge.to}`) + .sort(), + [ + 'primitives.color.blue.500>component.button.bg', + 'primitives.color.green.500>component.focus.ring', + 'primitives.space.4>component.card.inset', + ] + ) + + assert.equal(rename?.status, 'PASS') + assert.equal(rename?.findingsLine, '5 changes · 0 findings') + const renamed = rename?.nodes.find( + node => node.slot === 'semantic.color.bg.brand' + ) + assert.equal(renamed?.path, 'semantic.color.bg.primary') + assert.equal(renamed?.change, 'added') + const renamedButton = rename?.nodes.find( + node => node.slot === 'component.button.bg' + ) + assert.equal(renamedButton?.display, '→ {bg.primary}') + assert.deepEqual( + rename?.nodes + .filter(node => node.change === 'changed') + .map(node => node.path) + .sort(), + ['component.button.bg', 'component.focus.ring', 'component.nav.active.bg'] + ) + + assert.equal( + deck.diffReport.includes('removed semantic.color.bg.brand'), + true + ) + assert.equal( + deck.diffReport.includes( + ' Affected tokens: component.button.bg, component.focus.ring, component.nav.active.bg' + ), + true + ) + assert.equal( + deck.diffReport.includes('New findings: 0 · Resolved findings: 0'), + true + ) + assert.deepEqual(deck.renameImpact, { + removedPath: 'semantic.color.bg.brand', + addedPath: 'semantic.color.bg.primary', + consumers: ['button.bg', 'focus.ring', 'nav.active.bg'], + }) +}) + +test('graph projections keep every slot stable across proposals', () => { + const deck = createProposalDeck() + const [baseline] = deck.outcomes + const slots = baseline?.nodes.map(node => node.slot).sort() ?? [] + assert.equal(slots.length, 15) + + for (const outcome of deck.outcomes) { + assert.deepEqual(outcome.nodes.map(node => node.slot).sort(), slots) + const known = new Set(slots) + for (const edge of outcome.edges) { + assert.equal(known.has(edge.from), true) + assert.equal(known.has(edge.to), true) + } + assert.equal(outcome.edges.length, outcome.id === 'violation' ? 12 : 9) + } +}) + +test('quickstart config excerpt mirrors the policy layers', () => { + const snippet = describeLayersSnippet() + assert.match( + snippet, + /\{ id: 'primitives', roots: \['primitives'\], values: 'literal' \}/u + ) + assert.match( + snippet, + /\{ id: 'semantic', roots: \['semantic'\], values: 'reference', references: \['primitives'\] \}/u + ) + assert.match( + snippet, + /\{ id: 'component', roots: \['component'\], values: 'reference', references: \['semantic'\] \}/u + ) +}) diff --git a/apps/playground/public/favicon.svg b/apps/playground/public/favicon.svg index 4fc6a87f..9a55389d 100644 --- a/apps/playground/public/favicon.svg +++ b/apps/playground/public/favicon.svg @@ -1,4 +1,4 @@ Primitree - + diff --git a/apps/playground/src/color-system.test.ts b/apps/playground/src/color-system.test.ts index 9c02c8f5..117a2eae 100644 --- a/apps/playground/src/color-system.test.ts +++ b/apps/playground/src/color-system.test.ts @@ -22,20 +22,20 @@ async function read(relativePath: string) { test('playground theme exposes the approved Lichen palette', async () => { const theme = await read('./styles.css') - expect(theme).toMatch(/--bg: #030304;/u) - expect(theme).toMatch(/--bg-surface: #08080a;/u) - expect(theme).toMatch(/--bg-raised: #0f0f12;/u) - expect(theme).toMatch(/--bg-hover: #16161a;/u) - expect(theme).toMatch(/--text: #fafafa;/u) - expect(theme).toMatch(/--accent: #a8c95f;/u) - expect(theme).toMatch(/--accent-strong: #5f7f2f;/u) - expect(theme).toMatch(/--accent-soft: #dde9b9;/u) - expect(theme).toMatch(/--accent-wash: rgb\(168 201 95 \/ 10%\);/u) - expect(theme).toMatch(/--good: #45c98b;/u) - expect(theme).toMatch(/--warn: #f2b84b;/u) - expect(theme).toMatch(/--error: #f27575;/u) + expect(theme).toMatch(/--bg: #0b0f0d;/u) + expect(theme).toMatch(/--bg-surface: #101512;/u) + expect(theme).toMatch(/--bg-raised: #161c18;/u) + expect(theme).toMatch(/--bg-hover: #1c241f;/u) + expect(theme).toMatch(/--text: #f0f5f2;/u) + expect(theme).toMatch(/--accent: #34d399;/u) + expect(theme).toMatch(/--accent-strong: #0b9464;/u) + expect(theme).toMatch(/--accent-soft: #6ee7b7;/u) + expect(theme).toMatch(/--accent-wash: rgb\(52 211 153 \/ 10%\);/u) + expect(theme).toMatch(/--good: #34d399;/u) + expect(theme).toMatch(/--warn: #fbbf24;/u) + expect(theme).toMatch(/--error: #f87171;/u) expect(theme).toMatch( - /::selection\s*\{\s*background: rgb\(168 201 95 \/ 25%\);/u + /::selection\s*\{\s*background: rgb\(52 211 153 \/ 25%\);/u ) }) @@ -59,7 +59,7 @@ test('playground uses solid accents, neutral actions, and neutral focus', async /\.page-title em\s*\{\s*font-style: normal;\s*color: var\(--accent\);\s*\}/u ) expect(theme).toMatch( - /\.button\.primary\s*\{\s*background: #fafafa;\s*color: #09090b;\s*\}/u + /\.button\.primary\s*\{\s*background: var\(--accent\);\s*color: #04251a;\s*\}/u ) expect(theme).toMatch(/outline: 2px solid var\(--text\);/u) expect(theme).toMatch(/\.tab\.active\s*\{[\s\S]*color: var\(--accent\);/u) @@ -75,7 +75,7 @@ test('playground static mark stays white and favicon uses the light-surface Lich ]) expect(mark).toMatch(/fill="(?:white|#ffffff)"/iu) - expect(favicon).toMatch(/fill="#5f7f2f"/iu) + expect(favicon).toMatch(/fill="#0b9464"/iu) expect(mark.match(/\bfill="(?:white|#ffffff)"/giu)).toHaveLength(1) - expect(favicon.match(/\bfill="#5f7f2f"/giu)).toHaveLength(1) + expect(favicon.match(/\bfill="#0b9464"/giu)).toHaveLength(1) }) diff --git a/apps/playground/src/styles.css b/apps/playground/src/styles.css index a11b0597..30cf8c3e 100644 --- a/apps/playground/src/styles.css +++ b/apps/playground/src/styles.css @@ -1,19 +1,19 @@ :root { color-scheme: dark; - --bg: #030304; - --bg-surface: #08080a; - --bg-raised: #0f0f12; - --bg-hover: #16161a; - --border: rgb(255 255 255 / 6%); - --text: #fafafa; - --text-dim: #9ca3af; - --accent: #a8c95f; - --accent-strong: #5f7f2f; - --accent-soft: #dde9b9; - --accent-wash: rgb(168 201 95 / 10%); - --good: #45c98b; - --warn: #f2b84b; - --error: #f27575; + --bg: #0b0f0d; + --bg-surface: #101512; + --bg-raised: #161c18; + --bg-hover: #1c241f; + --border: rgb(255 255 255 / 9%); + --text: #f0f5f2; + --text-dim: #8fa39a; + --accent: #34d399; + --accent-strong: #0b9464; + --accent-soft: #6ee7b7; + --accent-wash: rgb(52 211 153 / 10%); + --good: #34d399; + --warn: #fbbf24; + --error: #f87171; --radius: 12px; --font-sans: 'Inter', system-ui, -apple-system, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; @@ -24,7 +24,7 @@ } ::selection { - background: rgb(168 201 95 / 25%); + background: rgb(52 211 153 / 25%); color: #fff; } @@ -204,7 +204,7 @@ body { display: inline-flex; align-items: center; justify-content: center; - border-radius: 10px; + border-radius: 8px; padding: 10px 18px; font-size: 14px; font-weight: 600; @@ -223,8 +223,8 @@ body { } .button.primary { - background: #fafafa; - color: #09090b; + background: var(--accent); + color: #04251a; } .button.ghost { @@ -237,8 +237,8 @@ body { margin-top: 16px; padding: 12px 16px; border-radius: var(--radius); - border: 1px solid rgb(242 117 117 / 40%); - background: rgb(242 117 117 / 8%); + border: 1px solid rgb(248 113 113 / 40%); + background: rgb(248 113 113 / 8%); color: var(--error); font-size: 14px; font-family: var(--font-mono); @@ -321,8 +321,8 @@ body { .warnings { margin: 0 0 16px; - border: 1px solid rgb(242 184 75 / 35%); - background: rgb(242 184 75 / 7%); + border: 1px solid rgb(251 191 36 / 35%); + background: rgb(251 191 36 / 7%); border-radius: var(--radius); padding: 10px 14px; font-size: 14px; @@ -388,7 +388,7 @@ body { border: 1px solid var(--border); background: transparent; color: var(--text-dim); - border-radius: 999px; + border-radius: 8px; padding: 0 12px; font-size: 13px; cursor: pointer; @@ -528,7 +528,7 @@ body { display: inline-block; padding: 2px 8px; border-radius: 999px; - background: rgb(69 201 139 / 10%); + background: rgb(52 211 153 / 10%); color: var(--good); font-size: 12px; font-family: var(--font-sans); @@ -625,7 +625,7 @@ body { } .button.primary:hover { - background: #e4e4e7; + background: var(--accent-soft); } .button.ghost:hover, diff --git a/docs/superpowers/plans/2026-08-20-homepage-governance-proof.md b/docs/superpowers/plans/2026-08-20-homepage-governance-proof.md new file mode 100644 index 00000000..73c76418 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-homepage-governance-proof.md @@ -0,0 +1,1774 @@ +# Homepage Governance Proof Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the scroll-choreographed Living Canopy with a click-driven proof panel, a diff terminal, and a merged config section, per the spec at `docs/superpowers/specs/2026-08-20-homepage-governance-proof-design.md`. + +**Architecture:** `governance-demo.ts` gains a precomputed proposal deck built through `@primitree/core` and `@primitree/dtcg` at build time. A client `ProofPanel` switches between deck outcomes. A presentational `TokenTree` renders HTML nodes with CSS connectors. `governance-home.tsx` composes five sections in normal flow. Brand validators and tests retarget from the canopy to the new components. + +**Tech Stack:** Next.js 16 App Router, React 19, Tailwind 4 with handwritten classes in `apps/docs/app/global.css`, node:test, Playwright. + +## Global Constraints + +- Every string in TSX, including comments, passes `node scripts/check-prose.mjs`. Write zero comments in new components. Banned in copy, from `scripts/prose/rules.mjs`: em dashes, passive voice, `ensure`, `you can`, `lets you`, `only`, most `ly` adverbs, sentence-initial `If`, `Then`, or Wh-words, `simple`, `easy`, `consistent`, `flexible`, `seamless`, `powerful`. +- Color values in UI sources pass `node scripts/check-brand.mjs`. Use `--color-primitree-*` variables from `global.css:5-26`. No former brand hex values. +- No scroll observers, no sticky choreography, no `pointermove` listeners, no infinite animations. Brand rules enforce the last two on every `apps/docs/components/landing/` file. +- Node >= 24, pnpm >= 11. Run docs commands as `pnpm --filter primitree-docs run