@protolabsai/ui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @protolabsai/ui
2
+
3
+ protoLabs.studio React component library. Every primitive is className-only over the [`@protolabsai/design`](../design-system/) tokens (`--pl-*`), so it inherits the locked brand and restyles for free when a token changes. Documented in Storybook.
4
+
5
+ ## Components (v1)
6
+
7
+ `Button` · `Badge` (status) · `Card` · `Stat` · `Eyebrow` — extracted from the marketing site's primitives. More land here as the site + other sites need them.
8
+
9
+ ## Use
10
+
11
+ ```tsx
12
+ import "@protolabsai/design/css"; // tokens + base layer (once, at root)
13
+ import "@protolabsai/ui/styles.css"; // component styles (once, at root)
14
+ import { Button, Badge, Card, Stat, Eyebrow } from "@protolabsai/ui";
15
+ ```
16
+
17
+ ## Storybook
18
+
19
+ ```bash
20
+ pnpm --filter @protolabsai/ui storybook # dev workbench → http://localhost:6006
21
+ pnpm --filter @protolabsai/ui build-storybook # static site → packages/ui/storybook-static/
22
+ ```
23
+
24
+ Sidebar: **Foundations** (colors, type, geometry — rendered from the live tokens) + **Components**. The canvas uses the real dark ground because Storybook imports `@protolabsai/design/css`.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@protolabsai/ui",
3
+ "version": "0.2.0",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "registry": "https://registry.npmjs.org/"
7
+ },
8
+ "description": "protoLabs.studio React component library — primitives built on @protolabsai/design tokens.",
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "sideEffects": [
12
+ "*.css"
13
+ ],
14
+ "exports": {
15
+ ".": "./src/index.tsx",
16
+ "./styles.css": "./src/styles.css"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "dependencies": {
22
+ "@types/react": "^19.0.0",
23
+ "@types/react-dom": "^19.0.0",
24
+ "@protolabsai/design": "0.2.0"
25
+ },
26
+ "peerDependencies": {
27
+ "react": "^19.0.0",
28
+ "react-dom": "^19.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@storybook/react": "^10.4.1",
32
+ "@storybook/react-vite": "^10.4.1",
33
+ "@vitejs/plugin-react": "^4.3.4",
34
+ "react": "^19.0.0",
35
+ "react-dom": "^19.0.0",
36
+ "storybook": "^10.4.1",
37
+ "typescript": "^5.6.0",
38
+ "vite": "^6.0.0"
39
+ },
40
+ "scripts": {
41
+ "storybook": "storybook dev -p 6006 --no-open",
42
+ "build-storybook": "storybook build -o storybook-static",
43
+ "typecheck": "tsc --noEmit"
44
+ }
45
+ }
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Badge, type Status } from "./index";
3
+
4
+ const meta: Meta<typeof Badge> = {
5
+ title: "Components/Badge",
6
+ component: Badge,
7
+ args: { children: "released" },
8
+ argTypes: { status: { control: "inline-radio", options: ["neutral", "success", "warning", "error", "info"] } },
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof Badge>;
12
+
13
+ export const Neutral: Story = {};
14
+ export const Success: Story = { args: { status: "success", children: "passed" } };
15
+
16
+ const ALL: Status[] = ["neutral", "success", "warning", "error", "info"];
17
+ export const AllStatuses: Story = {
18
+ render: () => (
19
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
20
+ {ALL.map((s) => (
21
+ <Badge key={s} status={s}>
22
+ {s}
23
+ </Badge>
24
+ ))}
25
+ </div>
26
+ ),
27
+ };
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Container, Empty, PostItem, PostList, Prose } from "./index";
3
+
4
+ const meta: Meta = { title: "Components/Blog" };
5
+ export default meta;
6
+ type Story = StoryObj;
7
+
8
+ export const Index: Story = {
9
+ render: () => (
10
+ <Container>
11
+ <PostList>
12
+ <PostItem
13
+ href="#"
14
+ meta="2026-05-29 · engineering"
15
+ title="Six builds to a green Docker image"
16
+ excerpt="The deps stage copied node_modules for the wrong packages. Here's the diagnosis trail and the in-place-install fix."
17
+ />
18
+ <PostItem
19
+ href="#"
20
+ meta="2026-05-20 · decisions"
21
+ title="Why we retired the Python stack"
22
+ excerpt="Consolidating onto Payload + TypeScript. What the migration kept and what it dropped."
23
+ />
24
+ <PostItem
25
+ href="#"
26
+ meta="2026-05-12 · voice"
27
+ title="Pulling the AI voice out of writing"
28
+ excerpt="The tells we ban, the craft we keep, and how the spec enforces both."
29
+ />
30
+ </PostList>
31
+ </Container>
32
+ ),
33
+ };
34
+
35
+ export const EmptyState: Story = {
36
+ render: () => (
37
+ <Container>
38
+ <Empty>No posts yet — the queue is gated on voice landing.</Empty>
39
+ </Container>
40
+ ),
41
+ };
42
+
43
+ export const PostBody: Story = {
44
+ render: () => (
45
+ <Container>
46
+ <Prose>
47
+ <h2>The bet</h2>
48
+ <p>
49
+ We build systems that build themselves. That is not a tagline for a deck — it is the operating
50
+ model. One operator sets direction; a fleet of agents lands the work.
51
+ </p>
52
+ <h3>What ships</h3>
53
+ <ul>
54
+ <li>Patterns, open to study and steal.</li>
55
+ <li>A voice spec that every agent reads before drafting.</li>
56
+ <li>
57
+ A green CI gate — every change lands via <code>PR</code>.
58
+ </li>
59
+ </ul>
60
+ <blockquote>No roadmaps for fork users. No smoothing for newcomers.</blockquote>
61
+ <pre>
62
+ <code>branch → push → PR → CI green → merge → deploy</code>
63
+ </pre>
64
+ </Prose>
65
+ </Container>
66
+ ),
67
+ };
@@ -0,0 +1,15 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "./index";
3
+
4
+ const meta: Meta<typeof Button> = {
5
+ title: "Components/Button",
6
+ component: Button,
7
+ args: { children: "Breakdowns" },
8
+ argTypes: { variant: { control: "inline-radio", options: ["default", "primary"] } },
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof Button>;
12
+
13
+ export const Default: Story = {};
14
+ export const Primary: Story = { args: { variant: "primary", children: "Get started" } };
15
+ export const Disabled: Story = { args: { disabled: true, children: "Disabled" } };
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button, Container, Eyebrow, GradientText, Hero, HeroActions, Heading, Lead, Section, SectionIntro } from "./index";
3
+
4
+ const meta: Meta = { title: "Components/Content" };
5
+ export default meta;
6
+ type Story = StoryObj;
7
+
8
+ export const HeroBlock: Story = {
9
+ render: () => (
10
+ <Container>
11
+ <Hero>
12
+ <Eyebrow>protoLabs.studio</Eyebrow>
13
+ <h1>
14
+ We build systems that <GradientText>build themselves</GradientText>.
15
+ </h1>
16
+ <Lead>
17
+ One operator, a fleet of agents. The patterns that ship our work are open to study and steal —
18
+ no roadmaps, no hand-holding.
19
+ </Lead>
20
+ <HeroActions>
21
+ <Button variant="primary">Read the patterns</Button>
22
+ <Button>See the stack</Button>
23
+ </HeroActions>
24
+ </Hero>
25
+ </Container>
26
+ ),
27
+ };
28
+
29
+ export const SectionHeader: Story = {
30
+ render: () => (
31
+ <Container>
32
+ <Section>
33
+ <Eyebrow>How it works</Eyebrow>
34
+ <Heading>Voice as code</Heading>
35
+ <SectionIntro>
36
+ The brand voice lives in JSON Schema, composes into a system prompt, and every agent reads it
37
+ before drafting. Change the spec, the whole surface restyles.
38
+ </SectionIntro>
39
+ </Section>
40
+ </Container>
41
+ ),
42
+ };
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+
3
+ const meta: Meta = { title: "Foundations" };
4
+ export default meta;
5
+ type Story = StoryObj;
6
+
7
+ const COLORS: Array<[string, string]> = [
8
+ ["Brand violet", "--pl-color-brand-violet"],
9
+ ["Violet light", "--pl-color-brand-violet-light"],
10
+ ["Indigo", "--pl-color-brand-indigo"],
11
+ ["Indigo bright", "--pl-color-brand-indigo-bright"],
12
+ ["Ground", "--pl-color-bg"],
13
+ ["Raised", "--pl-color-bg-raised"],
14
+ ["Foreground", "--pl-color-fg"],
15
+ ["Muted", "--pl-color-fg-muted"],
16
+ ["Success", "--pl-color-status-success"],
17
+ ["Warning", "--pl-color-status-warning"],
18
+ ["Error", "--pl-color-status-error"],
19
+ ["Info", "--pl-color-status-info"],
20
+ ];
21
+
22
+ export const Colors: Story = {
23
+ render: () => (
24
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 12 }}>
25
+ {COLORS.map(([label, v]) => (
26
+ <div key={v} style={{ fontFamily: "var(--pl-font-mono)", fontSize: 11, color: "var(--pl-color-fg-muted)" }}>
27
+ <div style={{ height: 56, borderRadius: "var(--pl-radius)", border: "1px solid var(--pl-color-border)", background: `var(${v})` }} />
28
+ <div style={{ marginTop: 6, color: "var(--pl-color-fg)" }}>{label}</div>
29
+ <div>{v}</div>
30
+ </div>
31
+ ))}
32
+ <div
33
+ style={{ gridColumn: "1 / -1", height: 56, borderRadius: "var(--pl-radius)", background: "var(--pl-gradient-brand)" }}
34
+ title="--pl-gradient-brand"
35
+ />
36
+ </div>
37
+ ),
38
+ };
39
+
40
+ export const Typography: Story = {
41
+ render: () => (
42
+ <div style={{ color: "var(--pl-color-fg)", display: "grid", gap: 12 }}>
43
+ <h1 style={{ fontSize: "2.5rem", fontWeight: 500, letterSpacing: "-0.025em", margin: 0 }}>
44
+ We build systems that build themselves.
45
+ </h1>
46
+ <p style={{ color: "var(--pl-color-fg-muted)", maxWidth: "60ch", margin: 0 }}>
47
+ An indie studio running experiments in the open — and sharing the patterns that build them.
48
+ </p>
49
+ <p style={{ fontFamily: "var(--pl-font-mono)", fontSize: 13, margin: 0 }}>
50
+ Geist Mono — code, metrics, eyebrows. Base size 14px, tools-grade density.
51
+ </p>
52
+ </div>
53
+ ),
54
+ };
55
+
56
+ export const Geometry: Story = {
57
+ render: () => (
58
+ <div style={{ display: "flex", gap: 16, alignItems: "flex-end", color: "var(--pl-color-fg-muted)", fontFamily: "var(--pl-font-mono)", fontSize: 11 }}>
59
+ {["1", "2", "3", "4", "6", "8", "12"].map((s) => (
60
+ <div key={s} style={{ textAlign: "center" }}>
61
+ <div style={{ width: `var(--pl-space-${s})`, height: `var(--pl-space-${s})`, background: "var(--pl-color-brand-violet)", borderRadius: "var(--pl-radius)" }} />
62
+ <div style={{ marginTop: 6 }}>space-{s}</div>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ ),
67
+ };
@@ -0,0 +1,120 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import {
3
+ Callout,
4
+ Container,
5
+ Divider,
6
+ Eyebrow,
7
+ GradientText,
8
+ Heading,
9
+ Hero,
10
+ Lead,
11
+ Row,
12
+ Section,
13
+ SectionIntro,
14
+ } from "./index";
15
+
16
+ const meta: Meta = {
17
+ title: "Introduction",
18
+ parameters: { layout: "fullscreen" },
19
+ };
20
+ export default meta;
21
+ type Story = StoryObj;
22
+
23
+ const code = (text: string) => (
24
+ <pre
25
+ style={{
26
+ background: "var(--pl-color-bg-raised)",
27
+ border: "var(--pl-border-width) solid var(--pl-color-border)",
28
+ borderRadius: "var(--pl-radius)",
29
+ padding: "1rem",
30
+ overflowX: "auto",
31
+ fontFamily: "var(--pl-font-mono)",
32
+ fontSize: 13,
33
+ lineHeight: 1.6,
34
+ color: "var(--pl-color-fg)",
35
+ margin: 0,
36
+ }}
37
+ >
38
+ <code>{text}</code>
39
+ </pre>
40
+ );
41
+
42
+ export const Overview: Story = {
43
+ render: () => (
44
+ <>
45
+ <Hero>
46
+ <Container>
47
+ <Eyebrow>@protolabsai/ui · @protolabsai/design</Eyebrow>
48
+ <h1>
49
+ The protoLabs.studio <GradientText>design system</GradientText>.
50
+ </h1>
51
+ <Lead>
52
+ React primitives built on one token source. Change a token, every component restyles —
53
+ these are the same components that ship the marketing site. Gray and small: content is the
54
+ hero, not the chrome.
55
+ </Lead>
56
+ </Container>
57
+ </Hero>
58
+
59
+ <Section>
60
+ <Container>
61
+ <Eyebrow>Install</Eyebrow>
62
+ <SectionIntro>Two packages: the tokens, and the components that read them.</SectionIntro>
63
+ {code(`pnpm add @protolabsai/ui @protolabsai/design
64
+
65
+ # once, at your app root:
66
+ import "@protolabsai/design/css"; // the --pl-* custom properties
67
+ import "@protolabsai/ui/styles.css"; // the component styles`)}
68
+ </Container>
69
+ </Section>
70
+
71
+ <Section>
72
+ <Container>
73
+ <Eyebrow>How it's wired</Eyebrow>
74
+ <SectionIntro>
75
+ One source generates the rest, so nothing drifts. Components are className-only over the
76
+ <code> --pl-* </code> custom properties — no runtime theming layer.
77
+ </SectionIntro>
78
+ <Row
79
+ label="tokens.js"
80
+ desc="The single source. Generates tokens.css (custom properties), tokens.json, and a Tailwind preset."
81
+ />
82
+ <Row
83
+ label="@protolabsai/design"
84
+ desc="Ships the generated tokens + base layer. Sets the dark ground and the type scale."
85
+ />
86
+ <Row
87
+ label="@protolabsai/ui"
88
+ desc="React components over the --pl-* properties. Restyle for free when a token changes."
89
+ />
90
+ </Container>
91
+ </Section>
92
+
93
+ <Section>
94
+ <Container>
95
+ <Eyebrow>What's here</Eyebrow>
96
+ <Heading>Components</Heading>
97
+ <SectionIntro style={{ marginBottom: "1.25rem" }}>
98
+ Browse the sidebar. Foundations covers the raw tokens; the rest are the primitives that build
99
+ every page.
100
+ </SectionIntro>
101
+ <Row label="Foundations" desc="Color, type scale, spacing, radius — straight from the tokens." />
102
+ <Row label="Button · Badge" desc="Bordered actions and lowercase status chips." />
103
+ <Row label="Surface" desc="Card, Section, Container, Eyebrow, Stat — the layout shells." />
104
+ <Row label="Row" desc="The label · body · status line that lists tools, repos, patterns." />
105
+ <Row label="Content" desc="Hero, Lead, Heading, SectionIntro, GradientText — page headers." />
106
+ <Row label="Process" desc="Steps, Checks, Deliverables — the consulting-page primitives." />
107
+ <Row label="Blog" desc="PostList, PostItem, Empty, Prose — index and long-form." />
108
+ <Row label="Primitives" desc="Divider, Callout, Kbd, TextLink — technical-content staples." />
109
+
110
+ <Divider />
111
+
112
+ <Callout tone="info" title="one brand, locked">
113
+ The voice and visual identity are fixed at v4.x. Tokens and components change by generation,
114
+ not by hand — edit the source, not the output.
115
+ </Callout>
116
+ </Container>
117
+ </Section>
118
+ </>
119
+ ),
120
+ };
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Callout, Divider, Kbd, TextLink } from "./index";
3
+
4
+ const meta: Meta = { title: "Components/Primitives" };
5
+ export default meta;
6
+ type Story = StoryObj;
7
+
8
+ export const Callouts: Story = {
9
+ render: () => (
10
+ <div style={{ display: "grid", gap: 16, maxWidth: 560 }}>
11
+ <Callout title="note">The deps stage copied node_modules for the wrong packages. In-place install fixed it.</Callout>
12
+ <Callout tone="success" title="shipped">Image build green after the sixth attempt. Deployed to the studio stack.</Callout>
13
+ <Callout tone="warning" title="heads up">main is protected — every change lands via PR, admins included.</Callout>
14
+ <Callout tone="error" title="rejected">Paid tiers, gated features, microtransactions. PWYW + donations only.</Callout>
15
+ <Callout tone="info" title="context">Voice lives in JSON Schema and composes into the system prompt.</Callout>
16
+ </div>
17
+ ),
18
+ };
19
+
20
+ export const Rule: Story = {
21
+ render: () => (
22
+ <div style={{ maxWidth: 560 }}>
23
+ <p>Above the rule.</p>
24
+ <Divider />
25
+ <p>Below the rule.</p>
26
+ </div>
27
+ ),
28
+ };
29
+
30
+ export const KbdAndLink: Story = {
31
+ render: () => (
32
+ <p style={{ maxWidth: 560, lineHeight: 1.7 }}>
33
+ Run <Kbd>pnpm seed</Kbd> to upsert the specs, then open the{" "}
34
+ <TextLink href="#">admin</TextLink> — or read the{" "}
35
+ <TextLink href="#" external>
36
+ ADR
37
+ </TextLink>{" "}
38
+ for the rationale.
39
+ </p>
40
+ ),
41
+ };
@@ -0,0 +1,64 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Check, Checks, Deliverable, Deliverables, Heading, Section, Step, Steps } from "./index";
3
+
4
+ const meta: Meta = { title: "Components/Process" };
5
+ export default meta;
6
+ type Story = StoryObj;
7
+
8
+ export const NumberedSteps: Story = {
9
+ render: () => (
10
+ <Section style={{ maxWidth: 560 }}>
11
+ <Heading>Engagement shape</Heading>
12
+ <Steps>
13
+ <Step n="01" title="Scope">
14
+ One call. We map the system you have to the system that builds itself.
15
+ </Step>
16
+ <Step n="02" title="Build">
17
+ Agents land PRs against a green CI gate. You watch the work merge.
18
+ </Step>
19
+ <Step n="03" title="Hand off">
20
+ You keep the patterns. Fork, hack to fit, run it without us.
21
+ </Step>
22
+ </Steps>
23
+ </Section>
24
+ ),
25
+ };
26
+
27
+ export const Checklist: Story = {
28
+ render: () => (
29
+ <div style={{ maxWidth: 560 }}>
30
+ <Checks>
31
+ <Check>
32
+ <strong>PWYW + donations only.</strong> No paid tiers, no gated features.
33
+ </Check>
34
+ <Check>
35
+ <strong>Open source.</strong> The code has no audience filter — anyone can fork it.
36
+ </Check>
37
+ <Check>
38
+ <strong>Patterns over products.</strong> Study and steal, not subscribe.
39
+ </Check>
40
+ </Checks>
41
+ </div>
42
+ ),
43
+ };
44
+
45
+ export const DeliverableGrid: Story = {
46
+ render: () => (
47
+ <div style={{ maxWidth: 720 }}>
48
+ <Deliverables>
49
+ <Deliverable title="voice-as-code">
50
+ A JSON-Schema voice spec that composes into the system prompt every agent reads.
51
+ </Deliverable>
52
+ <Deliverable title="content pipeline">
53
+ Intake → draft → corpus capture, modeled as Payload collections.
54
+ </Deliverable>
55
+ <Deliverable title="design system">
56
+ Tokens → CSS → Tailwind, generated from one source. No drift.
57
+ </Deliverable>
58
+ <Deliverable title="release flow">
59
+ Tag push → LLM changelog → Discord + GitHub release. Hands-off.
60
+ </Deliverable>
61
+ </Deliverables>
62
+ </div>
63
+ ),
64
+ };
@@ -0,0 +1,55 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Row, Stats, Stat } from "./index";
3
+
4
+ const meta: Meta<typeof Row> = { title: "Components/Row", component: Row };
5
+ export default meta;
6
+ type Story = StoryObj<typeof Row>;
7
+
8
+ /** Tools-style: label + mono name + description (links out). */
9
+ export const WithName: Story = {
10
+ args: {
11
+ label: "Orchestration",
12
+ name: "protoWorkstacean",
13
+ desc: "Event orchestrator and fleet switchboard. Routes work across the portfolio.",
14
+ href: "https://github.com/protoLabsAI/protoWorkstacean",
15
+ external: true,
16
+ },
17
+ };
18
+
19
+ /** Experiments-style: label + description only. */
20
+ export const LabelAndDesc: Story = {
21
+ args: {
22
+ label: "rabbit-hole",
23
+ desc: "Multi-source research agent. A topic + a budget; out comes a knowledge graph.",
24
+ },
25
+ };
26
+
27
+ /** Work-style: widens to label | body | status. */
28
+ export const WithStatus: Story = {
29
+ args: {
30
+ label: "mythxengine-sdk",
31
+ desc: "Deterministic multi-agent worlds in Rust. Genre-agnostic; games are packs.",
32
+ status: "in progress",
33
+ },
34
+ };
35
+
36
+ export const Stack: StoryObj = {
37
+ render: () => (
38
+ <div>
39
+ <Row label="Orchestration" name="protoWorkstacean" desc="Event orchestrator and fleet switchboard." />
40
+ <Row label="Execution" name="protoMaker" desc="The dark factory. Board → features → worktrees → PRs." />
41
+ <Row label="Runtime" name="protoCLI" desc="An AI agent that lives in your terminal; ships @protolabsai/sdk." />
42
+ </div>
43
+ ),
44
+ };
45
+
46
+ export const StatsGrid: StoryObj = {
47
+ render: () => (
48
+ <Stats>
49
+ <Stat value="6,795" label="PRs merged" />
50
+ <Stat value="14,000+" label="contributions, past year" />
51
+ <Stat value="82" label="repos · 26 open source" />
52
+ <Stat value="$8.56" label="avg agent cost / feature" />
53
+ </Stats>
54
+ ),
55
+ };
@@ -0,0 +1,20 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Card, Eyebrow, Stat } from "./index";
3
+
4
+ const meta: Meta = { title: "Components/Surface" };
5
+ export default meta;
6
+ type Story = StoryObj;
7
+
8
+ export const CardWithStats: Story = {
9
+ render: () => (
10
+ <Card style={{ maxWidth: 420 }}>
11
+ <Eyebrow>By the numbers</Eyebrow>
12
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 12 }}>
13
+ <Stat value="6,795" label="PRs merged" />
14
+ <Stat value="82" label="repos · 26 open source" />
15
+ <Stat value="14,000+" label="contributions, past year" />
16
+ <Stat value="$8.56" label="avg agent cost / feature" />
17
+ </div>
18
+ </Card>
19
+ ),
20
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @protolabsai/ui — React primitives on the @protolabsai/design token set.
3
+ * Every component is className-only over the --pl-* custom properties, so it
4
+ * inherits the locked brand and restyles for free when a token changes.
5
+ */
6
+ import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
7
+ import "./styles.css";
8
+
9
+ const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
10
+
11
+ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
12
+ /** "primary" reads as a stronger border, not a fill (brand restraint). */
13
+ variant?: "default" | "primary";
14
+ };
15
+ export function Button({ variant = "default", className, ...rest }: ButtonProps) {
16
+ return <button className={cx("pl-btn", variant === "primary" && "pl-btn--primary", className)} {...rest} />;
17
+ }
18
+
19
+ export type Status = "neutral" | "success" | "warning" | "error" | "info";
20
+ export function Badge({ status = "neutral", children }: { status?: Status; children: ReactNode }) {
21
+ return <span className={cx("pl-badge", status !== "neutral" && `pl-badge--${status}`)}>{children}</span>;
22
+ }
23
+
24
+ export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
25
+ return <div className={cx("pl-card", className)} {...rest} />;
26
+ }
27
+
28
+ export function Eyebrow({ children }: { children: ReactNode }) {
29
+ return <div className="pl-eyebrow">{children}</div>;
30
+ }
31
+
32
+ export function Stat({ value, label }: { value: ReactNode; label: ReactNode }) {
33
+ return (
34
+ <div>
35
+ <div className="pl-stat__num">{value}</div>
36
+ <div className="pl-stat__label">{label}</div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ export function Container({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
42
+ return <div className={cx("pl-container", className)} {...rest} />;
43
+ }
44
+
45
+ export function Section({ className, ...rest }: HTMLAttributes<HTMLElement>) {
46
+ return <section className={cx("pl-section", className)} {...rest} />;
47
+ }
48
+
49
+ /** Stats grid — wrap Stat children. Two columns, four at ≥640px. */
50
+ export function Stats({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
51
+ return <div className={cx("pl-stats", className)} {...rest} />;
52
+ }
53
+
54
+ export type RowProps = {
55
+ /** Left mono label / layer. */
56
+ label: string;
57
+ /** Optional mono name above the description. */
58
+ name?: ReactNode;
59
+ desc: ReactNode;
60
+ /** When present, the row widens to label | body | status. */
61
+ status?: ReactNode;
62
+ /** Renders as a link when set. */
63
+ href?: string;
64
+ external?: boolean;
65
+ };
66
+ export function Row({ label, name, desc, status, href, external }: RowProps) {
67
+ const cls = cx("pl-row", status != null && "pl-row--wide");
68
+ const inner = (
69
+ <>
70
+ <span className="pl-row__label">{label}</span>
71
+ <span>
72
+ {name != null && <div className="pl-row__name">{name}</div>}
73
+ <div className="pl-row__desc">{desc}</div>
74
+ </span>
75
+ {status != null && <span className="pl-row__status">{status}</span>}
76
+ </>
77
+ );
78
+ return href ? (
79
+ <a className={cls} href={href} {...(external ? { target: "_blank", rel: "noreferrer noopener" } : {})}>
80
+ {inner}
81
+ </a>
82
+ ) : (
83
+ <div className={cls}>{inner}</div>
84
+ );
85
+ }
86
+
87
+ /** The one gradient — the tagline word treatment. Foundation §1 + §13. */
88
+ export function GradientText({ children }: { children: ReactNode }) {
89
+ return <span className="pl-gradient-text">{children}</span>;
90
+ }
91
+
92
+ /** Hero header — put an <h1> + <Lead> + <HeroActions> inside. */
93
+ export function Hero({ className, ...rest }: HTMLAttributes<HTMLElement>) {
94
+ return <header className={cx("pl-hero", className)} {...rest} />;
95
+ }
96
+ /** Button row under the hero lead. */
97
+ export function HeroActions({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
98
+ return <div className={cx("pl-hero__cta", className)} {...rest} />;
99
+ }
100
+
101
+ /** Large muted intro paragraph (hero size). */
102
+ export function Lead({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
103
+ return <p className={cx("pl-lead", className)} {...rest} />;
104
+ }
105
+
106
+ /** Section heading — self-contained h2 (doesn't rely on a global reset). */
107
+ export function Heading({ className, ...rest }: HTMLAttributes<HTMLHeadingElement>) {
108
+ return <h2 className={cx("pl-heading", className)} {...rest} />;
109
+ }
110
+
111
+ /** Muted paragraph that introduces a section (body size). */
112
+ export function SectionIntro({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
113
+ return <p className={cx("pl-section-intro", className)} {...rest} />;
114
+ }
115
+
116
+ /** Numbered process list — wrap Step children. */
117
+ export function Steps({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
118
+ return <div className={cx("pl-steps", className)} {...rest} />;
119
+ }
120
+ export function Step({ n, title, children }: { n: ReactNode; title: ReactNode; children: ReactNode }) {
121
+ return (
122
+ <div className="pl-step">
123
+ <div className="pl-step__num">{n}</div>
124
+ <div>
125
+ <div className="pl-step__title">{title}</div>
126
+ <div className="pl-step__body">{children}</div>
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ /** Checklist — wrap Check children. The ✓ mark is rendered for you. */
133
+ export function Checks({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
134
+ return <div className={cx("pl-checks", className)} {...rest} />;
135
+ }
136
+ export function Check({ children, mark = "✓" }: { children: ReactNode; mark?: ReactNode }) {
137
+ return (
138
+ <div className="pl-check">
139
+ <span className="pl-check__mark" aria-hidden>
140
+ {mark}
141
+ </span>
142
+ <span>{children}</span>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ /** Two-column deliverable cards (left-border, mono title). */
148
+ export function Deliverables({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
149
+ return <div className={cx("pl-deliverables", className)} {...rest} />;
150
+ }
151
+ export function Deliverable({ title, children }: { title: ReactNode; children: ReactNode }) {
152
+ return (
153
+ <div className="pl-deliverable">
154
+ <div className="pl-deliverable__title">{title}</div>
155
+ <div className="pl-deliverable__body">{children}</div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ /** Blog index list — wrap PostItem children. */
161
+ export function PostList({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
162
+ return <div className={cx("pl-post-list", className)} {...rest} />;
163
+ }
164
+ export type PostItemProps = { meta?: ReactNode; title: ReactNode; excerpt?: ReactNode; href: string };
165
+ export function PostItem({ meta, title, excerpt, href }: PostItemProps) {
166
+ return (
167
+ <a className="pl-post-item" href={href}>
168
+ {meta != null && <div className="pl-post-item__meta">{meta}</div>}
169
+ <div className="pl-post-item__title">{title}</div>
170
+ {excerpt != null && <div className="pl-post-item__excerpt">{excerpt}</div>}
171
+ </a>
172
+ );
173
+ }
174
+
175
+ /** Mono empty-state line. */
176
+ export function Empty({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
177
+ return <div className={cx("pl-empty", className)} {...rest} />;
178
+ }
179
+
180
+ /** Long-form rich-text wrapper (blog post body, docs). */
181
+ export function Prose({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
182
+ return <div className={cx("pl-prose", className)} {...rest} />;
183
+ }
184
+
185
+ /** Hairline rule. */
186
+ export function Divider({ className, ...rest }: HTMLAttributes<HTMLHRElement>) {
187
+ return <hr className={cx("pl-divider", className)} {...rest} />;
188
+ }
189
+
190
+ /** Bordered note block — left-accent keyed to the status tone. */
191
+ export function Callout({
192
+ tone = "neutral",
193
+ title,
194
+ children,
195
+ }: {
196
+ tone?: Status;
197
+ title?: ReactNode;
198
+ children: ReactNode;
199
+ }) {
200
+ return (
201
+ <div className={cx("pl-callout", tone !== "neutral" && `pl-callout--${tone}`)}>
202
+ {title != null && <div className="pl-callout__title">{title}</div>}
203
+ <div className="pl-callout__body">{children}</div>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ /** Keyboard / inline-token chip. */
209
+ export function Kbd({ children }: { children: ReactNode }) {
210
+ return <kbd className="pl-kbd">{children}</kbd>;
211
+ }
212
+
213
+ /** Standalone styled link (underline-offset treatment). Use the app's router
214
+ * Link for internal navigation; this is for plain anchors. */
215
+ export function TextLink({
216
+ className,
217
+ external,
218
+ ...rest
219
+ }: HTMLAttributes<HTMLAnchorElement> & { href?: string; external?: boolean }) {
220
+ return (
221
+ <a
222
+ className={cx("pl-link", className)}
223
+ {...(external ? { target: "_blank", rel: "noreferrer noopener" } : {})}
224
+ {...rest}
225
+ />
226
+ );
227
+ }
package/src/styles.css ADDED
@@ -0,0 +1,466 @@
1
+ /* @protolabsai/ui — component styles, built entirely on @protolabsai/design
2
+ * tokens (the --pl-* custom properties). Import once at your app root:
3
+ * import "@protolabsai/ui/styles.css";
4
+ * Requires @protolabsai/design/css to be imported too (for the tokens). */
5
+
6
+ .pl-btn {
7
+ display: inline-flex;
8
+ align-items: center;
9
+ gap: 0.4rem;
10
+ padding: 0.5rem 0.9rem;
11
+ font-family: var(--pl-font-sans);
12
+ font-size: 13px;
13
+ font-weight: 400;
14
+ color: var(--pl-color-fg);
15
+ background: transparent;
16
+ border: var(--pl-border-width) solid var(--pl-color-border-strong);
17
+ border-radius: var(--pl-radius);
18
+ cursor: pointer;
19
+ text-decoration: none;
20
+ transition:
21
+ border-color var(--pl-motion-fast) var(--pl-motion-ease),
22
+ color var(--pl-motion-fast) var(--pl-motion-ease),
23
+ background var(--pl-motion-fast) var(--pl-motion-ease);
24
+ }
25
+ .pl-btn:hover {
26
+ border-color: var(--pl-color-fg);
27
+ }
28
+ /* Primary ships as a stronger border, not a violet fill — brand restraint. */
29
+ .pl-btn--primary {
30
+ border-color: var(--pl-color-fg);
31
+ }
32
+ .pl-btn--primary:hover {
33
+ background: var(--pl-color-fg);
34
+ color: var(--pl-color-bg);
35
+ }
36
+ .pl-btn:disabled {
37
+ opacity: 0.5;
38
+ cursor: not-allowed;
39
+ }
40
+
41
+ .pl-badge {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ gap: 0.35rem;
45
+ padding: 0.15rem 0.5rem;
46
+ font-family: var(--pl-font-mono);
47
+ font-size: 11px;
48
+ line-height: 1.4;
49
+ text-transform: lowercase;
50
+ letter-spacing: 0.02em;
51
+ color: var(--pl-color-fg-muted);
52
+ background: var(--pl-color-bg-raised);
53
+ border: var(--pl-border-width) solid var(--pl-color-border);
54
+ border-radius: var(--pl-radius);
55
+ }
56
+ .pl-badge--success {
57
+ color: var(--pl-color-status-success);
58
+ border-color: color-mix(in oklch, var(--pl-color-status-success) 35%, transparent);
59
+ }
60
+ .pl-badge--warning {
61
+ color: var(--pl-color-status-warning);
62
+ border-color: color-mix(in oklch, var(--pl-color-status-warning) 35%, transparent);
63
+ }
64
+ .pl-badge--error {
65
+ color: var(--pl-color-status-error);
66
+ border-color: color-mix(in oklch, var(--pl-color-status-error) 35%, transparent);
67
+ }
68
+ .pl-badge--info {
69
+ color: var(--pl-color-status-info);
70
+ border-color: color-mix(in oklch, var(--pl-color-status-info) 35%, transparent);
71
+ }
72
+
73
+ .pl-card {
74
+ background: var(--pl-color-bg-raised);
75
+ border: var(--pl-border-width) solid var(--pl-color-border);
76
+ border-radius: var(--pl-radius);
77
+ padding: var(--pl-space-4);
78
+ }
79
+
80
+ .pl-eyebrow {
81
+ font-family: var(--pl-font-mono);
82
+ font-size: 11px;
83
+ font-weight: var(--pl-font-weight-medium);
84
+ text-transform: uppercase;
85
+ letter-spacing: 0.08em;
86
+ color: var(--pl-color-fg-muted);
87
+ }
88
+
89
+ .pl-stat__num {
90
+ font-family: var(--pl-font-mono);
91
+ font-size: 1.1rem;
92
+ color: var(--pl-color-fg);
93
+ }
94
+ .pl-stat__label {
95
+ margin-top: 0.15rem;
96
+ font-size: 12px;
97
+ color: var(--pl-color-fg-muted);
98
+ }
99
+
100
+ /* ── layout ── */
101
+ .pl-container {
102
+ max-width: 880px;
103
+ margin: 0 auto;
104
+ padding: 0 1.5rem;
105
+ }
106
+ .pl-section {
107
+ padding: 4rem 0;
108
+ }
109
+
110
+ /* ── stats grid (wraps Stat) ── */
111
+ .pl-stats {
112
+ display: grid;
113
+ grid-template-columns: repeat(2, 1fr);
114
+ gap: 1.5rem 2rem;
115
+ padding: 1.5rem 0;
116
+ border-top: var(--pl-border-width) solid var(--pl-color-border);
117
+ border-bottom: var(--pl-border-width) solid var(--pl-color-border);
118
+ }
119
+ @media (min-width: 640px) {
120
+ .pl-stats {
121
+ grid-template-columns: repeat(4, 1fr);
122
+ }
123
+ }
124
+
125
+ /* ── row (label | body [| status]) ── */
126
+ .pl-row {
127
+ display: grid;
128
+ grid-template-columns: 9rem 1fr;
129
+ gap: 1.25rem;
130
+ padding: 1rem 0;
131
+ border-top: var(--pl-border-width) solid var(--pl-color-border);
132
+ text-decoration: none;
133
+ color: var(--pl-color-fg);
134
+ transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
135
+ }
136
+ .pl-row:last-of-type {
137
+ border-bottom: var(--pl-border-width) solid var(--pl-color-border);
138
+ }
139
+ a.pl-row:hover {
140
+ opacity: 0.7;
141
+ }
142
+ .pl-row--wide {
143
+ grid-template-columns: 9rem 1fr auto;
144
+ }
145
+ .pl-row__label {
146
+ font-family: var(--pl-font-mono);
147
+ font-size: 12px;
148
+ color: var(--pl-color-fg-muted);
149
+ }
150
+ .pl-row__name {
151
+ font-family: var(--pl-font-mono);
152
+ font-size: 13px;
153
+ color: var(--pl-color-fg);
154
+ margin-bottom: 0.25rem;
155
+ }
156
+ .pl-row__desc {
157
+ font-size: 13px;
158
+ line-height: 1.5;
159
+ color: var(--pl-color-fg-muted);
160
+ }
161
+ .pl-row__status {
162
+ font-family: var(--pl-font-mono);
163
+ font-size: 11px;
164
+ text-transform: lowercase;
165
+ color: var(--pl-color-fg-muted);
166
+ }
167
+ @media (max-width: 640px) {
168
+ .pl-row,
169
+ .pl-row--wide {
170
+ grid-template-columns: 1fr;
171
+ gap: 0.25rem;
172
+ }
173
+ }
174
+
175
+ /* ── gradient text (the one gradient — tagline word only) ── */
176
+ .pl-gradient-text {
177
+ background: var(--pl-gradient-brand);
178
+ -webkit-background-clip: text;
179
+ -webkit-text-fill-color: transparent;
180
+ background-clip: text;
181
+ font-weight: 700;
182
+ letter-spacing: -0.02em;
183
+ }
184
+
185
+ /* ── hero ── */
186
+ .pl-hero {
187
+ display: block;
188
+ padding: 5rem 0 3rem;
189
+ }
190
+ .pl-hero h1 {
191
+ margin: 0 0 1.25rem;
192
+ font-size: clamp(2rem, 4vw, 2.75rem);
193
+ font-weight: 500;
194
+ line-height: 1.2;
195
+ letter-spacing: -0.025em;
196
+ }
197
+ .pl-hero__cta {
198
+ display: flex;
199
+ gap: 0.75rem;
200
+ flex-wrap: wrap;
201
+ }
202
+
203
+ /* ── lead + section heading/intro ── */
204
+ .pl-lead {
205
+ margin: 0 0 1.5rem;
206
+ font-size: 1.05rem;
207
+ line-height: 1.6;
208
+ color: var(--pl-color-fg-muted);
209
+ max-width: 60ch;
210
+ }
211
+ .pl-heading {
212
+ margin: 0 0 1rem;
213
+ font-size: 1.4rem;
214
+ font-weight: 500;
215
+ line-height: 1.2;
216
+ letter-spacing: -0.01em;
217
+ color: var(--pl-color-fg);
218
+ }
219
+ .pl-section-intro {
220
+ margin: 0 0 2rem;
221
+ color: var(--pl-color-fg-muted);
222
+ max-width: 60ch;
223
+ line-height: 1.6;
224
+ }
225
+
226
+ /* ── steps (numbered process) ── */
227
+ .pl-steps {
228
+ display: grid;
229
+ gap: 1.5rem;
230
+ }
231
+ .pl-step {
232
+ display: grid;
233
+ grid-template-columns: 2rem 1fr;
234
+ gap: 0.5rem;
235
+ align-items: baseline;
236
+ }
237
+ .pl-step__num {
238
+ font-family: var(--pl-font-mono);
239
+ font-size: 13px;
240
+ color: var(--pl-color-fg-muted);
241
+ }
242
+ .pl-step__title {
243
+ font-family: var(--pl-font-mono);
244
+ font-size: 13px;
245
+ margin-bottom: 0.4rem;
246
+ color: var(--pl-color-fg);
247
+ }
248
+ .pl-step__body {
249
+ color: var(--pl-color-fg-muted);
250
+ font-size: 14px;
251
+ line-height: 1.55;
252
+ }
253
+
254
+ /* ── checks (checklist) ── */
255
+ .pl-checks {
256
+ display: grid;
257
+ gap: 0.5rem;
258
+ }
259
+ .pl-check {
260
+ display: grid;
261
+ grid-template-columns: 1.2rem 1fr;
262
+ gap: 0.5rem;
263
+ align-items: baseline;
264
+ font-size: 14px;
265
+ color: var(--pl-color-fg-muted);
266
+ }
267
+ .pl-check__mark {
268
+ color: var(--pl-color-fg);
269
+ font-family: var(--pl-font-mono);
270
+ }
271
+ .pl-check strong {
272
+ color: var(--pl-color-fg);
273
+ font-weight: 500;
274
+ }
275
+
276
+ /* ── deliverables (two-col, left-border cards) ── */
277
+ .pl-deliverables {
278
+ display: grid;
279
+ gap: 1.5rem;
280
+ }
281
+ @media (min-width: 640px) {
282
+ .pl-deliverables {
283
+ grid-template-columns: 1fr 1fr;
284
+ }
285
+ }
286
+ .pl-deliverable {
287
+ border-left: var(--pl-border-width) solid var(--pl-color-border-strong);
288
+ padding-left: 1rem;
289
+ }
290
+ .pl-deliverable__title {
291
+ font-family: var(--pl-font-mono);
292
+ font-size: 13px;
293
+ margin-bottom: 0.4rem;
294
+ color: var(--pl-color-fg);
295
+ }
296
+ .pl-deliverable__body {
297
+ color: var(--pl-color-fg-muted);
298
+ font-size: 14px;
299
+ line-height: 1.55;
300
+ }
301
+
302
+ /* ── post list (blog index) ── */
303
+ .pl-post-list {
304
+ display: grid;
305
+ gap: 0;
306
+ }
307
+ .pl-post-item {
308
+ display: block;
309
+ padding: 1.25rem 0;
310
+ border-top: var(--pl-border-width) solid var(--pl-color-border);
311
+ text-decoration: none;
312
+ color: var(--pl-color-fg);
313
+ transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
314
+ }
315
+ .pl-post-item:last-of-type {
316
+ border-bottom: var(--pl-border-width) solid var(--pl-color-border);
317
+ }
318
+ .pl-post-item:hover {
319
+ opacity: 0.7;
320
+ }
321
+ .pl-post-item__meta {
322
+ font-family: var(--pl-font-mono);
323
+ font-size: 11px;
324
+ color: var(--pl-color-fg-muted);
325
+ text-transform: uppercase;
326
+ letter-spacing: 0.05em;
327
+ margin-bottom: 0.4rem;
328
+ }
329
+ .pl-post-item__title {
330
+ font-size: 1.05rem;
331
+ font-weight: 500;
332
+ margin-bottom: 0.4rem;
333
+ }
334
+ .pl-post-item__excerpt {
335
+ color: var(--pl-color-fg-muted);
336
+ font-size: 14px;
337
+ line-height: 1.55;
338
+ }
339
+
340
+ /* ── empty state ── */
341
+ .pl-empty {
342
+ color: var(--pl-color-fg-muted);
343
+ font-family: var(--pl-font-mono);
344
+ font-size: 13px;
345
+ padding: 1rem 0;
346
+ }
347
+
348
+ /* ── prose (long-form rich text) ── */
349
+ .pl-prose {
350
+ font-size: 1rem;
351
+ line-height: 1.7;
352
+ color: var(--pl-color-fg);
353
+ }
354
+ .pl-prose h2 {
355
+ font-size: 1.3rem;
356
+ margin-top: 2rem;
357
+ margin-bottom: 0.75rem;
358
+ }
359
+ .pl-prose h3 {
360
+ font-size: 1.1rem;
361
+ margin-top: 1.5rem;
362
+ margin-bottom: 0.5rem;
363
+ }
364
+ .pl-prose p {
365
+ margin: 1rem 0;
366
+ }
367
+ .pl-prose ul,
368
+ .pl-prose ol {
369
+ margin: 1rem 0;
370
+ padding-left: 1.25rem;
371
+ }
372
+ .pl-prose li {
373
+ margin: 0.3rem 0;
374
+ }
375
+ .pl-prose code {
376
+ background: var(--pl-color-bg-raised);
377
+ padding: 0.1rem 0.35rem;
378
+ border-radius: var(--pl-radius);
379
+ color: var(--pl-color-fg);
380
+ font-size: 0.88em;
381
+ }
382
+ .pl-prose pre {
383
+ background: var(--pl-color-bg-raised);
384
+ border: var(--pl-border-width) solid var(--pl-color-border);
385
+ padding: 1rem;
386
+ border-radius: var(--pl-radius);
387
+ overflow-x: auto;
388
+ margin: 1rem 0;
389
+ }
390
+ .pl-prose pre code {
391
+ background: transparent;
392
+ padding: 0;
393
+ }
394
+ .pl-prose blockquote {
395
+ border-left: 2px solid var(--pl-color-border-strong);
396
+ padding-left: 1rem;
397
+ margin: 1.5rem 0;
398
+ color: var(--pl-color-fg-muted);
399
+ }
400
+
401
+ /* ── divider ── */
402
+ .pl-divider {
403
+ border: 0;
404
+ border-top: var(--pl-border-width) solid var(--pl-color-border);
405
+ margin: 2rem 0;
406
+ }
407
+
408
+ /* ── callout (note block) ── */
409
+ .pl-callout {
410
+ background: var(--pl-color-bg-raised);
411
+ border: var(--pl-border-width) solid var(--pl-color-border);
412
+ border-left-width: 2px;
413
+ border-radius: var(--pl-radius);
414
+ padding: 0.85rem 1rem;
415
+ }
416
+ .pl-callout__title {
417
+ font-family: var(--pl-font-mono);
418
+ font-size: 12px;
419
+ text-transform: lowercase;
420
+ letter-spacing: 0.02em;
421
+ color: var(--pl-color-fg);
422
+ margin-bottom: 0.35rem;
423
+ }
424
+ .pl-callout__body {
425
+ font-size: 14px;
426
+ line-height: 1.6;
427
+ color: var(--pl-color-fg-muted);
428
+ }
429
+ .pl-callout--success {
430
+ border-left-color: var(--pl-color-status-success);
431
+ }
432
+ .pl-callout--warning {
433
+ border-left-color: var(--pl-color-status-warning);
434
+ }
435
+ .pl-callout--error {
436
+ border-left-color: var(--pl-color-status-error);
437
+ }
438
+ .pl-callout--info {
439
+ border-left-color: var(--pl-color-status-info);
440
+ }
441
+
442
+ /* ── kbd / inline token ── */
443
+ .pl-kbd {
444
+ font-family: var(--pl-font-mono);
445
+ font-size: 0.82em;
446
+ padding: 0.1rem 0.4rem;
447
+ color: var(--pl-color-fg);
448
+ background: var(--pl-color-bg-raised);
449
+ border: var(--pl-border-width) solid var(--pl-color-border);
450
+ border-bottom-width: 2px;
451
+ border-radius: var(--pl-radius);
452
+ }
453
+
454
+ /* ── standalone link ── */
455
+ .pl-link {
456
+ color: inherit;
457
+ text-decoration: underline;
458
+ text-underline-offset: 3px;
459
+ text-decoration-color: var(--pl-color-border-strong);
460
+ transition:
461
+ text-decoration-color var(--pl-motion-fast) var(--pl-motion-ease),
462
+ color var(--pl-motion-fast) var(--pl-motion-ease);
463
+ }
464
+ .pl-link:hover {
465
+ text-decoration-color: var(--pl-color-fg);
466
+ }