diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 2004fd52..6d9fe575 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -15,6 +15,7 @@ on: - "cmd/**" - "internal/**" - "web/**" + - "design-system/**" - ".github/workflows/build-image.yml" workflow_dispatch: @@ -52,7 +53,10 @@ jobs: dockerfile: Dockerfile - image: e2a-web display_name: web image - context: web + # Repo root: the web image needs the sibling @e2a/ui workspace + # (file:../design-system). web/Dockerfile.dockerignore trims the + # context. See web/Dockerfile. + context: . dockerfile: web/Dockerfile steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28012fd6..bea8ea74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -418,3 +418,23 @@ jobs: run: npm install --package-lock=false - name: Check generated code is up to date run: make generate-sdk-check + + design-system-dist: + name: Design system dist freshness + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + # @e2a/ui's dist/ is committed (web consumes it via a file: dep and its + # Docker image copies it). Rebuild and fail on drift, same idiom as the + # OpenAPI/SDK freshness gates above. + - name: Install workspace deps + run: npm install --package-lock=false + - name: Rebuild @e2a/ui + run: npm run build --workspace @e2a/ui + - name: Check dist/ is up to date + run: git diff --exit-code design-system/dist diff --git a/design-system/.gitignore b/design-system/.gitignore index 038cde2f..a3eb9d33 100644 --- a/design-system/.gitignore +++ b/design-system/.gitignore @@ -1,6 +1,8 @@ node_modules/ -dist/ storybook-static/ +# dist/ is intentionally committed: web consumes @e2a/ui via a file: dep and its +# Docker image copies the built output, both of which need dist/ present. A CI +# freshness check (design-system-dist job) rebuilds it and fails on drift. *.log .DS_Store diff --git a/design-system/dist/index.d.ts b/design-system/dist/index.d.ts new file mode 100644 index 00000000..36769d4c --- /dev/null +++ b/design-system/dist/index.d.ts @@ -0,0 +1,151 @@ +import * as react from 'react'; +import { ButtonHTMLAttributes, ReactNode, CSSProperties, InputHTMLAttributes, HTMLAttributes } from 'react'; + +type ButtonVariant = "primary" | "ghost" | "mono"; +type ButtonProps = ButtonHTMLAttributes & { + /** Visual style. `primary` = ember fill, `ghost` = bordered, `mono` = ink/console. */ + variant?: ButtonVariant; +}; +/** + * Loft button. Three variants drawn entirely from design tokens, so it + * re-themes automatically under `.dark`. Forwards all native button props. + */ +declare function Button({ variant, className, type, children, ...rest }: ButtonProps): react.JSX.Element; + +type ChipTone = "success" | "warn" | "info" | "accent" | "danger" | "neutral"; +type ChipProps = { + children: ReactNode; + /** Semantic color. Defaults to `neutral`. */ + tone?: ChipTone; + /** Render in the monospace face (for ids, codes, statuses). */ + mono?: boolean; + className?: string; +}; +/** Small rounded status/label pill, tinted by semantic tone. */ +declare function Chip({ children, tone, mono, className, }: ChipProps): react.JSX.Element; + +type DotTone = "success" | "warn" | "accent" | "danger" | "neutral"; +type DotProps = { + /** Status color. Defaults to `success`. */ + tone?: DotTone; +}; +/** Tiny status dot — decorative, pair it with a text label for meaning. */ +declare function Dot({ tone }: DotProps): react.JSX.Element; + +type EyebrowProps = { + children: ReactNode; + className?: string; +}; +/** Small uppercase mono kicker that sits above a heading. */ +declare function Eyebrow({ children, className }: EyebrowProps): react.JSX.Element; + +type Theme = "system" | "light" | "dark"; +type ThemeToggleProps = { + /** The currently selected theme. */ + value: Theme; + /** Called when the user picks a different theme. */ + onChange: (theme: Theme) => void; + className?: string; +}; +declare function ThemeToggle({ value, onChange, className }: ThemeToggleProps): react.JSX.Element; + +type InkLineKind = "comment" | "prompt" | "string" | "accent" | "plain"; +type InkLine = { + c?: InkLineKind; + text: string; + fg?: string; + node?: undefined; +} | { + node: ReactNode; + c?: undefined; + text?: undefined; + fg?: undefined; +}; +type InkConsoleProps = { + /** Lines to render. Each is either tokenized text (`{ text, c }`) or a raw `{ node }`. */ + lines: InkLine[]; + title?: string; + lang?: string; + /** Show the copy button (copies all text lines). Defaults to true. */ + copy?: boolean; + height?: number | string; + className?: string; +}; +/** + * Agent-native console surface. Renders on the dark "ink" palette regardless + * of theme, with syntax-tinted lines and an optional copy button. + */ +declare function InkConsole({ lines, title, lang, copy, height, className, }: InkConsoleProps): react.JSX.Element; + +type LogoVariant = "wordmark" | "mark"; +type LogoTone = "color" | "mono" | "ink"; +type LogoProps = { + /** `wordmark` = the "e2a" lockup; `mark` = the boxed "2" monogram. */ + variant?: LogoVariant; + /** + * `color` — foreground + ember accent, theme-aware. + * `mono` — single `currentColor` (inherits text color). + * `ink` — light lockup on the dark ink panel. + */ + tone?: LogoTone; + /** Rendered height in px; width derives from the aspect ratio. */ + height?: number; + /** Accessible label. */ + title?: string; + className?: string; + style?: CSSProperties; +}; +/** + * The e2a logo. One themeable component covering the wordmark and the boxed + * monogram; the `color` tone is drawn from Loft tokens, so it adapts to light + * and dark automatically. `mono` follows `currentColor` for one-color contexts. + */ +declare function Logo({ variant, tone, height, title, className, style, }: LogoProps): react.JSX.Element; + +type FieldProps = { + /** Label rendered above the input. */ + label: string; + /** Optional helper text below the input. */ + hint?: string; + value: string; + onChange: (value: string) => void; +} & Omit, "value" | "onChange">; +/** Labeled text input with an optional hint. Controlled via `value`/`onChange`. */ +declare function Field({ label, hint, value, onChange, className, type, ...props }: FieldProps): react.JSX.Element; + +type AvatarProps = { + /** Display name; used for initials and (if no email) the color seed. */ + name?: string; + /** Email; used as the color seed and for initials when no name is given. */ + email?: string; + /** Pixel size of the square. Defaults to 24. */ + size?: number; +}; +/** + * Square avatar with a deterministic color from the Loft `--av-1…8` palette, + * seeded by email (or name), showing the person's initials. + */ +declare function Avatar({ name, email, size }: AvatarProps): react.JSX.Element; + +type CollapsibleProps = { + /** Eyebrow label on the left of the trigger. */ + label: string; + /** Optional mono meta line on the right of the trigger. */ + meta?: ReactNode; + defaultOpen?: boolean; + /** Controlled mode — when provided, `open` overrides internal state. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; + children: ReactNode; +}; +/** Header-with-chevron disclosure section. Controlled or uncontrolled. */ +declare function Collapsible({ label, meta, defaultOpen, open: controlledOpen, onOpenChange, children, }: CollapsibleProps): react.JSX.Element; + +type CardProps = HTMLAttributes; +/** + * Surface container — the panel background, border, and radius that wrap most + * content blocks. Forwards native div props; compose freely inside. + */ +declare function Card({ className, children, ...props }: CardProps): react.JSX.Element; + +export { Avatar, type AvatarProps, Button, type ButtonProps, type ButtonVariant, Card, type CardProps, Chip, type ChipProps, type ChipTone, Collapsible, type CollapsibleProps, Dot, type DotProps, type DotTone, Eyebrow, type EyebrowProps, Field, type FieldProps, InkConsole, type InkConsoleProps, type InkLine, type InkLineKind, Logo, type LogoProps, type LogoTone, type LogoVariant, type Theme, ThemeToggle, type ThemeToggleProps }; diff --git a/design-system/dist/index.js b/design-system/dist/index.js new file mode 100644 index 00000000..902166b2 --- /dev/null +++ b/design-system/dist/index.js @@ -0,0 +1,457 @@ +"use client"; + +// src/Button/Button.tsx +import { jsx } from "react/jsx-runtime"; +function Button({ + variant = "primary", + className = "", + type = "button", + children, + ...rest +}) { + return /* @__PURE__ */ jsx( + "button", + { + type, + className: `loft-btn loft-btn--${variant} ${className}`.trim(), + ...rest, + children + } + ); +} + +// src/Chip/Chip.tsx +import { jsx as jsx2 } from "react/jsx-runtime"; +function Chip({ + children, + tone = "neutral", + mono = false, + className = "" +}) { + return /* @__PURE__ */ jsx2( + "span", + { + className: `loft-chip loft-chip--${tone}${mono ? " loft-chip--mono" : ""} ${className}`.trim(), + children + } + ); +} + +// src/Dot/Dot.tsx +import { jsx as jsx3 } from "react/jsx-runtime"; +function Dot({ tone = "success" }) { + return /* @__PURE__ */ jsx3("span", { "aria-hidden": true, className: `loft-dot loft-dot--${tone}` }); +} + +// src/Eyebrow/Eyebrow.tsx +import { jsx as jsx4 } from "react/jsx-runtime"; +function Eyebrow({ children, className = "" }) { + return /* @__PURE__ */ jsx4("span", { className: `loft-eyebrow ${className}`.trim(), children }); +} + +// src/ThemeToggle/ThemeToggle.tsx +import { Fragment, jsx as jsx5, jsxs } from "react/jsx-runtime"; +var OPTIONS = [ + { + value: "system", + label: "System theme", + icon: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx5("rect", { x: "3", y: "4", width: "18", height: "12", rx: "2" }), + /* @__PURE__ */ jsx5("path", { d: "M8 20h8M12 16v4" }) + ] }) + }, + { + value: "light", + label: "Light theme", + icon: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx5("circle", { cx: "12", cy: "12", r: "4" }), + /* @__PURE__ */ jsx5("path", { d: "M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" }) + ] }) + }, + { + value: "dark", + label: "Dark theme", + icon: /* @__PURE__ */ jsx5("path", { d: "M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z" }) + } +]; +function ThemeToggle({ value, onChange, className = "" }) { + return /* @__PURE__ */ jsx5( + "div", + { + role: "radiogroup", + "aria-label": "Color theme", + className: `loft-seg ${className}`.trim(), + children: OPTIONS.map((opt) => { + const active = value === opt.value; + return /* @__PURE__ */ jsx5( + "button", + { + type: "button", + role: "radio", + "aria-checked": active, + "aria-label": opt.label, + title: opt.label, + onClick: () => onChange(opt.value), + className: "loft-seg__opt", + children: /* @__PURE__ */ jsx5( + "svg", + { + width: "15", + height: "15", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "1.6", + strokeLinecap: "round", + strokeLinejoin: "round", + "aria-hidden": true, + children: opt.icon + } + ) + }, + opt.value + ); + }) + } + ); +} + +// src/InkConsole/InkConsole.tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime"; +var kindColor = { + comment: "var(--ink-fg-muted)", + prompt: "var(--machine)", + string: "var(--spectral)", + accent: "var(--accent)", + plain: "var(--ink-fg)" +}; +function plainText(lines) { + return lines.map((l) => l.node ? "" : l.text ?? "").filter(Boolean).join("\n"); +} +function InkConsole({ + lines, + title, + lang, + copy = true, + height, + className = "" +}) { + const showHeader = Boolean(title || lang || copy); + const [copied, setCopied] = useState(false); + const copyTimer = useRef(null); + useEffect(() => { + return () => { + if (copyTimer.current !== null) clearTimeout(copyTimer.current); + }; + }, []); + const onCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(plainText(lines)); + setCopied(true); + if (copyTimer.current !== null) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => { + setCopied(false); + copyTimer.current = null; + }, 1200); + } catch { + } + }, [lines]); + return /* @__PURE__ */ jsxs2("div", { className: `loft-console ${className}`.trim(), style: { height }, children: [ + showHeader && /* @__PURE__ */ jsxs2("div", { className: "loft-console__header", children: [ + title && /* @__PURE__ */ jsx6("span", { className: "loft-console__title", children: title }), + lang && /* @__PURE__ */ jsx6( + "span", + { + className: `loft-console__lang${title ? " loft-console__lang--gap" : ""}`, + children: lang + } + ), + /* @__PURE__ */ jsx6("span", { className: "loft-console__spacer" }), + copy && /* @__PURE__ */ jsxs2(Button, { variant: "mono", onClick: onCopy, "aria-label": "Copy to clipboard", children: [ + /* @__PURE__ */ jsxs2( + "svg", + { + width: "10", + height: "10", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + "aria-hidden": true, + children: [ + /* @__PURE__ */ jsx6("rect", { x: "9", y: "9", width: "11", height: "11", rx: "2" }), + /* @__PURE__ */ jsx6("path", { d: "M5 15V5a2 2 0 012-2h10" }) + ] + } + ), + copied ? "copied" : "copy" + ] }) + ] }), + /* @__PURE__ */ jsx6("div", { className: "loft-console__body", children: lines.map((l, i) => { + if (l.node !== void 0) { + return /* @__PURE__ */ jsx6("div", { children: l.node }, i); + } + const color = l.fg ?? kindColor[l.c ?? "plain"]; + return /* @__PURE__ */ jsx6("div", { className: "loft-console__line", style: { color }, children: l.text }, i); + }) }) + ] }); +} + +// src/Logo/Logo.tsx +import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime"; +var FONT = "var(--f-ui), 'Inter', ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif"; +var fillStyle = (value) => ({ fill: value }); +function Logo({ + variant = "wordmark", + tone = "color", + height, + title = "e2a", + className, + style +}) { + const mono = tone === "mono"; + if (variant === "mark") { + const h2 = height ?? 32; + return /* @__PURE__ */ jsxs3( + "svg", + { + role: "img", + "aria-label": title, + className, + style, + width: h2, + height: h2, + viewBox: "0 0 256 256", + children: [ + /* @__PURE__ */ jsx7( + "rect", + { + width: "256", + height: "256", + rx: "56", + style: mono ? { fill: "none", stroke: "currentColor", strokeWidth: 12 } : { fill: "var(--ink)" } + } + ), + /* @__PURE__ */ jsx7( + "text", + { + x: "128", + y: "178", + textAnchor: "middle", + fontWeight: 700, + fontSize: 200, + letterSpacing: -12, + style: { ...fillStyle(mono ? "currentColor" : "var(--ink-fg)"), fontFamily: FONT }, + children: "2" + } + ) + ] + } + ); + } + const h = height ?? 24; + const w = h * 3.2; + const ink = tone === "ink"; + const textFill = mono ? "currentColor" : ink ? "var(--ink-fg)" : "var(--fg)"; + const twoFill = mono ? "currentColor" : "var(--accent)"; + return /* @__PURE__ */ jsxs3( + "svg", + { + role: "img", + "aria-label": title, + className, + style, + width: w, + height: h, + viewBox: "0 0 640 200", + children: [ + ink && /* @__PURE__ */ jsx7("rect", { width: "640", height: "200", style: fillStyle("var(--ink)") }), + /* @__PURE__ */ jsxs3( + "text", + { + x: "320", + y: "148", + textAnchor: "middle", + fontWeight: 600, + fontSize: 176, + letterSpacing: -12, + style: { ...fillStyle(textFill), fontFamily: FONT }, + children: [ + /* @__PURE__ */ jsx7("tspan", { children: "e" }), + /* @__PURE__ */ jsx7("tspan", { style: fillStyle(twoFill), children: "2" }), + /* @__PURE__ */ jsx7("tspan", { children: "a" }) + ] + } + ) + ] + } + ); +} + +// src/Field/Field.tsx +import { jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime"; +function Field({ + label, + hint, + value, + onChange, + className = "", + type = "text", + ...props +}) { + return /* @__PURE__ */ jsxs4("label", { className: `loft-field ${className}`.trim(), children: [ + /* @__PURE__ */ jsx8("span", { className: "loft-field__label", children: label }), + /* @__PURE__ */ jsx8( + "input", + { + ...props, + type, + className: "loft-field__input", + value, + onChange: (e) => onChange(e.target.value) + } + ), + hint && /* @__PURE__ */ jsx8("span", { className: "loft-field__hint", children: hint }) + ] }); +} + +// src/Avatar/Avatar.tsx +import { jsx as jsx9 } from "react/jsx-runtime"; +function hashTo8(input) { + let h = 0; + for (let i = 0; i < input.length; i++) { + h = h * 31 + input.charCodeAt(i) | 0; + } + return (h % 8 + 8) % 8 + 1; +} +function initials(name, email) { + const trimmed = name?.trim(); + if (trimmed) { + const parts = trimmed.split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return parts[0].slice(0, 2).toUpperCase(); + } + const local = (email ?? "").split("@")[0] || email || "?"; + return local.slice(0, 2).toUpperCase(); +} +function Avatar({ name, email, size = 24 }) { + const seed = (email || name || "").toLowerCase(); + const bucket = hashTo8(seed); + return /* @__PURE__ */ jsx9( + "span", + { + "aria-hidden": true, + style: { + width: size, + height: size, + borderRadius: 4, + background: `var(--av-${bucket})`, + color: "#fff", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + fontSize: Math.round(size * 0.42), + fontWeight: 700, + flexShrink: 0, + letterSpacing: "0.02em" + }, + children: initials(name, email) + } + ); +} + +// src/Collapsible/Collapsible.tsx +import { useState as useState2, useSyncExternalStore } from "react"; +import { jsx as jsx10, jsxs as jsxs5 } from "react/jsx-runtime"; +function usePrefersReducedMotion() { + return useSyncExternalStore( + subscribeReducedMotion, + getReducedMotionSnapshot, + getReducedMotionServerSnapshot + ); +} +function subscribeReducedMotion(onChange) { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return () => { + }; + } + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); +} +function getReducedMotionSnapshot() { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} +function getReducedMotionServerSnapshot() { + return false; +} +function Collapsible({ + label, + meta, + defaultOpen = false, + open: controlledOpen, + onOpenChange, + children +}) { + const [uncontrolled, setUncontrolled] = useState2(defaultOpen); + const open = controlledOpen ?? uncontrolled; + const reduced = usePrefersReducedMotion(); + const setOpen = (next) => { + if (controlledOpen === void 0) setUncontrolled(next); + onOpenChange?.(next); + }; + return /* @__PURE__ */ jsxs5("section", { className: "loft-collapsible", children: [ + /* @__PURE__ */ jsxs5( + "button", + { + type: "button", + onClick: () => setOpen(!open), + "aria-expanded": open, + className: `loft-collapsible__trigger${open ? " loft-collapsible__trigger--open" : ""}`, + children: [ + /* @__PURE__ */ jsx10( + "span", + { + "aria-hidden": true, + className: "loft-collapsible__chevron", + style: { + transform: open ? "rotate(90deg)" : "rotate(0deg)", + transition: reduced ? "none" : "transform 120ms ease" + }, + children: "\u25B6" + } + ), + /* @__PURE__ */ jsx10(Eyebrow, { children: label }), + /* @__PURE__ */ jsx10("span", { className: "loft-collapsible__spacer" }), + meta && /* @__PURE__ */ jsx10("span", { className: "loft-collapsible__meta", children: meta }) + ] + } + ), + open && children + ] }); +} + +// src/Card/Card.tsx +import { jsx as jsx11 } from "react/jsx-runtime"; +function Card({ className = "", children, ...props }) { + return /* @__PURE__ */ jsx11("div", { className: `loft-card ${className}`.trim(), ...props, children }); +} +export { + Avatar, + Button, + Card, + Chip, + Collapsible, + Dot, + Eyebrow, + Field, + InkConsole, + Logo, + ThemeToggle +}; diff --git a/design-system/dist/styles.css b/design-system/dist/styles.css new file mode 100644 index 00000000..3c301812 --- /dev/null +++ b/design-system/dist/styles.css @@ -0,0 +1,411 @@ +/* Loft · component styles + * ─────────────────────── + * The single stylesheet a consumer loads once (import "@e2a/ui/styles.css"). + * It pulls in the tokens, sets a minimal base, then defines one class per + * component. Components reference these classes (no Tailwind dependency). + */ + +/* tokens.css inlined at build time — see scripts/flatten-css.mjs */ +/* Loft · design tokens (colors, type, spacing, radii, shadows) + * ─────────────────────────────────────────────────────────── + * Lifted from the e2a web app's globals.css so this package is the + * single source of truth for the design language. Light values live on + * :root; the `.dark` class (set on ) overrides them. + * + * Fonts are referenced by family name (Geist, JetBrains Mono, Instrument + * Serif). The library does not bundle the font files — consumers load them + * (e.g. next/font, @fontsource, or a ). System fallbacks apply + * otherwise, so components still render correctly without them. + */ + +:root { + /* ── Surfaces — Drive shell (warm cream) ── */ + --bg: #FAF7F2; + --bg-panel: #FFFFFF; + --bg-elev: #F2ECE2; + --bg-sunken: #ECE4D6; + + /* ── Ink (agent-native) ── */ + --ink: #1A1714; + --ink-elev: #23201C; + --ink-fg: #E8E3D8; + --ink-fg-muted: #8C857A; + --ink-border: #2E2A24; + + /* ── Text ── */ + --fg: #1A1714; + --fg-strong: #1A1714; + --fg-muted: #6E665B; + --fg-subtle: #9A9082; + + /* ── Borders ── */ + --border: #E5DED3; + --border-sub: #EFE9DD; + --border-strong: #D5CCBC; + + /* ── Accent · Ember ── */ + --accent: #E26534; + --accent-soft: #FBE9DF; + --accent-strong: #A84218; + --accent-fill: #B84A20; + --accent-fill-hov: #9A3D1A; + --accent-fg: #FFFFFF; + + /* ── Agent-context on ink ── */ + --spectral: #6FDDE5; + --machine: #B6F36E; + + /* ── Semantic colors (decorative + -strong text pair) ── */ + --info: #2D6CFF; --info-bg: #E4ECFF; --info-strong: #0050D6; + --warn: #C78400; --warn-bg: #FFF1D1; --warn-strong: #8F5F00; + --danger: #CC2E2E; --danger-bg: #FBE3E0; --danger-strong: #A82020; + --success: #0F7A4D; --success-bg: #DFF3E8; --success-strong: #0B5C3A; + + /* ── Avatar palette (deterministic by hash → 1..8) ── */ + --av-1:#A84218; --av-2:#0F7A4D; --av-3:#0050D6; --av-4:#7A4FE0; + --av-5:#B43E8F; --av-6:#8F5F00; --av-7:#4A4A4A; --av-8:#1A1714; + + /* ── Radii ── */ + --r-sm: 4px; + --r-md: 6px; + --r-lg: 10px; + --r-xl: 16px; + + /* ── Shadows ── */ + --sh-1: 0 1px 0 rgba(26,23,20,.04), 0 1px 2px rgba(26,23,20,.04); + --sh-2: 0 2px 8px rgba(26,23,20,.06), 0 12px 32px rgba(26,23,20,.06); + --sh-pop: 0 24px 48px -16px rgba(26,23,20,.18), 0 4px 12px rgba(26,23,20,.06); + + /* ── Focus ── */ + --focus-ring: 0 0 0 3px rgba(226,101,52,0.30); + + /* ── Type families ── */ + --f-ui: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --f-mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, monospace; + --f-editorial: "Instrument Serif", Georgia, serif; + + /* ── Type scale (semantic) ── */ + --fs-display: 36px; --fw-display: 700; --tracking-display: -0.02em; + --fs-h1: 24px; --fw-h1: 700; --tracking-h1: -0.01em; + --fs-h2: 18px; --fw-h2: 600; + --fs-h3: 15px; --fw-h3: 600; + --fs-body: 14px; + --fs-small: 12px; + --fs-mono: 13px; + --fs-id: 11px; + --fs-eyebrow: 11px; + + /* ── Spacing grid (4px) ── */ + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px; +} + +/* ── Dark theme — applied via `.dark` on ── */ +.dark { + --bg: #13110E; + --bg-panel: #1A1714; + --bg-elev: #221F1B; + --bg-sunken: #0D0B09; + + --ink: #0A0907; + --ink-elev: #13110E; + --ink-fg: #E8E3D8; + --ink-fg-muted: #8C857A; + --ink-border: #221F1B; + + --fg: #ECE6D9; + --fg-strong: #ECE6D9; + --fg-muted: #A8A092; + --fg-subtle: #6E665B; + + --border: #2E2A24; + --border-sub: #25221E; + --border-strong: #3A352E; + + --accent: #F08055; + --accent-soft: #3A1B0E; + --accent-strong: #F5A07E; + --accent-fill: #E26534; + --accent-fill-hov: #F08055; + --accent-fg: #13110E; + + --info: #79B4FF; --info-bg: #0F2046; --info-strong: #A6CCFF; + --warn: #E4A82F; --warn-bg: #2E1F00; --warn-strong: #F2C257; + --danger: #FF6B6B; --danger-bg: #2E1010; --danger-strong: #FF9292; + --success: #5AC68A; --success-bg: #0B2418; --success-strong: #8FE0B3; + + --sh-1: 0 1px 0 rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3); + --sh-2: 0 2px 8px rgba(0,0,0,.4), 0 12px 32px rgba(0,0,0,.5); + --sh-pop: 0 24px 48px -16px rgba(0,0,0,.6), 0 4px 12px rgba(0,0,0,.4); + + --focus-ring: 0 0 0 3px rgba(240,128,85,0.45); +} + + +/* ── Base ── */ +body { + background: var(--bg); + color: var(--fg); + font-family: var(--f-ui); + font-size: var(--fs-body); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* ── Button ──────────────────────────────────────────────── */ +.loft-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + font-family: var(--f-ui); +} +.loft-btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} +.loft-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.loft-btn--primary { + font-size: 13px; + font-weight: 500; + padding: 8px 16px; + border-radius: var(--r-md); + background: var(--accent-fill); + color: var(--accent-fg); + border: none; +} +.loft-btn--primary:not(:disabled):hover { + background: var(--accent-fill-hov); +} + +.loft-btn--ghost { + font-size: 12px; + font-weight: 500; + padding: 7px 12px; + border-radius: var(--r-md); + background: var(--bg-panel); + color: var(--fg); + border: 1px solid var(--border); +} +.loft-btn--ghost:not(:disabled):hover { + background: var(--bg-elev); +} + +.loft-btn--mono { + gap: 5px; + font-family: var(--f-mono); + font-size: 11px; + font-weight: 500; + padding: 4px 8px; + border-radius: var(--r-sm); + background: var(--ink-elev); + color: var(--ink-fg-muted); + border: 1px solid var(--ink-border); +} + +/* ── Chip ────────────────────────────────────────────────── */ +.loft-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + font-family: var(--f-ui); +} +.loft-chip--mono { + font-family: var(--f-mono); + letter-spacing: 0.02em; +} +.loft-chip--success { background: var(--success-bg); color: var(--success-strong); } +.loft-chip--warn { background: var(--warn-bg); color: var(--warn-strong); } +.loft-chip--info { background: var(--info-bg); color: var(--info-strong); } +.loft-chip--accent { background: var(--accent-soft); color: var(--accent-strong); } +.loft-chip--danger { background: var(--danger-bg); color: var(--danger-strong); } +.loft-chip--neutral { background: var(--bg-elev); color: var(--fg-muted); } + +/* ── Dot ─────────────────────────────────────────────────── */ +.loft-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 9999px; +} +.loft-dot--success { background: var(--success); } +.loft-dot--warn { background: var(--warn); } +.loft-dot--accent { background: var(--accent); } +.loft-dot--danger { background: var(--danger); } +.loft-dot--neutral { background: var(--fg-subtle); } + +/* ── Eyebrow ─────────────────────────────────────────────── */ +.loft-eyebrow { + font-family: var(--f-mono); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent-strong); +} + +/* ── ThemeToggle (segmented radio control) ───────────────── */ +.loft-seg { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--border-sub); + border-radius: var(--r-md); +} +.loft-seg__opt { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: none; + background: transparent; + color: var(--fg-muted); + border-radius: calc(var(--r-md) - 2px); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.loft-seg__opt[aria-checked="true"] { + color: var(--fg); + background: var(--bg-elev); + box-shadow: inset 0 0 0 1px var(--border); +} +.loft-seg__opt:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +/* ── Field (labeled text input) ──────────────────────────── */ +.loft-field { + display: block; +} +.loft-field__label { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 6px; + color: var(--fg); +} +.loft-field__input { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--r-lg); + font-size: 14px; + font-family: var(--f-ui); + background: var(--bg-panel); + color: var(--fg); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.loft-field__input::placeholder { + color: var(--fg-subtle); +} +.loft-field__input:focus { + outline: none; + border-color: var(--accent); + box-shadow: var(--focus-ring); +} +.loft-field__hint { + margin-top: 4px; + font-size: 12px; + color: var(--fg-muted); +} + +/* ── Card (surface container) ────────────────────────────── */ +.loft-card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: var(--sp-4); +} + +/* ── Collapsible (disclosure section) ────────────────────── */ +.loft-collapsible { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; +} +.loft-collapsible__trigger { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + text-align: left; + padding: 12px 18px; + background: transparent; + border: none; + cursor: pointer; +} +.loft-collapsible__trigger--open { + border-bottom: 1px solid var(--border-sub); +} +.loft-collapsible__chevron { + display: inline-block; + color: var(--fg-subtle); + font-size: 10px; +} +.loft-collapsible__spacer { + flex: 1; +} +.loft-collapsible__meta { + font-family: var(--f-mono); + font-size: 11px; + color: var(--fg-subtle); +} + +/* ── InkConsole (agent-native code console) ──────────────── */ +.loft-console { + overflow: hidden; + font-family: var(--f-mono); + background: var(--ink); + border: 1px solid var(--ink-border); + border-radius: var(--r-lg); +} +.loft-console__header { + display: flex; + align-items: center; + padding: 8px 14px; + font-size: 11px; + letter-spacing: 0.02em; + border-bottom: 1px solid var(--ink-border); + background: var(--ink-elev); + color: var(--ink-fg-muted); +} +.loft-console__title { + font-weight: 500; + color: var(--ink-fg); +} +.loft-console__lang { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--spectral); +} +.loft-console__lang--gap { + margin-left: 10px; +} +.loft-console__spacer { + flex: 1; +} +.loft-console__body { + padding: 14px 16px; + font-size: 12.5px; + line-height: 1.6; +} +.loft-console__line { + white-space: pre-wrap; +} diff --git a/design-system/dist/tokens.css b/design-system/dist/tokens.css new file mode 100644 index 00000000..070f59fa --- /dev/null +++ b/design-system/dist/tokens.css @@ -0,0 +1,134 @@ +/* Loft · design tokens (colors, type, spacing, radii, shadows) + * ─────────────────────────────────────────────────────────── + * Lifted from the e2a web app's globals.css so this package is the + * single source of truth for the design language. Light values live on + * :root; the `.dark` class (set on ) overrides them. + * + * Fonts are referenced by family name (Geist, JetBrains Mono, Instrument + * Serif). The library does not bundle the font files — consumers load them + * (e.g. next/font, @fontsource, or a ). System fallbacks apply + * otherwise, so components still render correctly without them. + */ + +:root { + /* ── Surfaces — Drive shell (warm cream) ── */ + --bg: #FAF7F2; + --bg-panel: #FFFFFF; + --bg-elev: #F2ECE2; + --bg-sunken: #ECE4D6; + + /* ── Ink (agent-native) ── */ + --ink: #1A1714; + --ink-elev: #23201C; + --ink-fg: #E8E3D8; + --ink-fg-muted: #8C857A; + --ink-border: #2E2A24; + + /* ── Text ── */ + --fg: #1A1714; + --fg-strong: #1A1714; + --fg-muted: #6E665B; + --fg-subtle: #9A9082; + + /* ── Borders ── */ + --border: #E5DED3; + --border-sub: #EFE9DD; + --border-strong: #D5CCBC; + + /* ── Accent · Ember ── */ + --accent: #E26534; + --accent-soft: #FBE9DF; + --accent-strong: #A84218; + --accent-fill: #B84A20; + --accent-fill-hov: #9A3D1A; + --accent-fg: #FFFFFF; + + /* ── Agent-context on ink ── */ + --spectral: #6FDDE5; + --machine: #B6F36E; + + /* ── Semantic colors (decorative + -strong text pair) ── */ + --info: #2D6CFF; --info-bg: #E4ECFF; --info-strong: #0050D6; + --warn: #C78400; --warn-bg: #FFF1D1; --warn-strong: #8F5F00; + --danger: #CC2E2E; --danger-bg: #FBE3E0; --danger-strong: #A82020; + --success: #0F7A4D; --success-bg: #DFF3E8; --success-strong: #0B5C3A; + + /* ── Avatar palette (deterministic by hash → 1..8) ── */ + --av-1:#A84218; --av-2:#0F7A4D; --av-3:#0050D6; --av-4:#7A4FE0; + --av-5:#B43E8F; --av-6:#8F5F00; --av-7:#4A4A4A; --av-8:#1A1714; + + /* ── Radii ── */ + --r-sm: 4px; + --r-md: 6px; + --r-lg: 10px; + --r-xl: 16px; + + /* ── Shadows ── */ + --sh-1: 0 1px 0 rgba(26,23,20,.04), 0 1px 2px rgba(26,23,20,.04); + --sh-2: 0 2px 8px rgba(26,23,20,.06), 0 12px 32px rgba(26,23,20,.06); + --sh-pop: 0 24px 48px -16px rgba(26,23,20,.18), 0 4px 12px rgba(26,23,20,.06); + + /* ── Focus ── */ + --focus-ring: 0 0 0 3px rgba(226,101,52,0.30); + + /* ── Type families ── */ + --f-ui: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --f-mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, monospace; + --f-editorial: "Instrument Serif", Georgia, serif; + + /* ── Type scale (semantic) ── */ + --fs-display: 36px; --fw-display: 700; --tracking-display: -0.02em; + --fs-h1: 24px; --fw-h1: 700; --tracking-h1: -0.01em; + --fs-h2: 18px; --fw-h2: 600; + --fs-h3: 15px; --fw-h3: 600; + --fs-body: 14px; + --fs-small: 12px; + --fs-mono: 13px; + --fs-id: 11px; + --fs-eyebrow: 11px; + + /* ── Spacing grid (4px) ── */ + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px; +} + +/* ── Dark theme — applied via `.dark` on ── */ +.dark { + --bg: #13110E; + --bg-panel: #1A1714; + --bg-elev: #221F1B; + --bg-sunken: #0D0B09; + + --ink: #0A0907; + --ink-elev: #13110E; + --ink-fg: #E8E3D8; + --ink-fg-muted: #8C857A; + --ink-border: #221F1B; + + --fg: #ECE6D9; + --fg-strong: #ECE6D9; + --fg-muted: #A8A092; + --fg-subtle: #6E665B; + + --border: #2E2A24; + --border-sub: #25221E; + --border-strong: #3A352E; + + --accent: #F08055; + --accent-soft: #3A1B0E; + --accent-strong: #F5A07E; + --accent-fill: #E26534; + --accent-fill-hov: #F08055; + --accent-fg: #13110E; + + --info: #79B4FF; --info-bg: #0F2046; --info-strong: #A6CCFF; + --warn: #E4A82F; --warn-bg: #2E1F00; --warn-strong: #F2C257; + --danger: #FF6B6B; --danger-bg: #2E1010; --danger-strong: #FF9292; + --success: #5AC68A; --success-bg: #0B2418; --success-strong: #8FE0B3; + + --sh-1: 0 1px 0 rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3); + --sh-2: 0 2px 8px rgba(0,0,0,.4), 0 12px 32px rgba(0,0,0,.5); + --sh-pop: 0 24px 48px -16px rgba(0,0,0,.6), 0 4px 12px rgba(0,0,0,.4); + + --focus-ring: 0 0 0 3px rgba(240,128,85,0.45); +} diff --git a/design-system/tsup.config.ts b/design-system/tsup.config.ts index 2a1bc53c..400f9072 100644 --- a/design-system/tsup.config.ts +++ b/design-system/tsup.config.ts @@ -9,7 +9,9 @@ export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], dts: true, - sourcemap: true, + // dist/ is committed (consumed by web via file: + verified fresh in CI), so + // skip sourcemaps to keep the tracked artifact minimal. + sourcemap: false, clean: true, external: ["react", "react-dom", "react/jsx-runtime"], // The bundle includes interactive components (InkConsole, Collapsible) that diff --git a/web/Dockerfile b/web/Dockerfile index 3439fcf6..689eade3 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -3,8 +3,9 @@ # Two-stage build: Next.js produces a static export, Caddy serves it # with a parameterizable reverse-proxy to the e2a API backend. # -# Build context is the `web/` directory: -# docker build -t e2a-web -f web/Dockerfile web +# Build context is the repo ROOT (web depends on the sibling @e2a/ui workspace +# via file:../design-system, which must be inside the context): +# docker build -t e2a-web -f web/Dockerfile . # # Customizing branding/SEO for a non-default deployment: # docker build \ @@ -25,8 +26,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS builder WORKDIR /app -# Install deps first so the layer caches when only source changes. -COPY package.json package-lock.json ./ +# @e2a/ui is a sibling workspace consumed via file:../design-system. Copy it +# (with its committed dist/) so web's file: symlink resolves during install and +# `next build`. No build step needed here — dist/ is prebuilt and committed. +COPY design-system ./design-system + +# Install web deps first so the layer caches when only source changes. +COPY web/package.json web/package-lock.json ./web/ +WORKDIR /app/web RUN npm ci --no-audit --prefer-offline # Empty defaults make the published OSS image generic. The hosted @@ -51,7 +58,7 @@ ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \ NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION=$NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION \ NEXT_PUBLIC_BILLING_API=$NEXT_PUBLIC_BILLING_API -COPY . ./ +COPY web ./ RUN npm run build # ── Runtime ───────────────────────────────────────────────────────── @@ -69,8 +76,8 @@ FROM caddy:2-alpine # Install explicitly so the HEALTHCHECK below can't silently break if a # future base image trims busybox. RUN apk add --no-cache wget -COPY --from=builder /app/out /srv -COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=builder /app/web/out /srv +COPY web/Caddyfile /etc/caddy/Caddyfile # Default port; override with WEB_PORT env. Healthcheck reads the same # var so it works regardless of the chosen port. diff --git a/web/Dockerfile.dockerignore b/web/Dockerfile.dockerignore new file mode 100644 index 00000000..ded891ef --- /dev/null +++ b/web/Dockerfile.dockerignore @@ -0,0 +1,36 @@ +# Used INSTEAD of the repo-root .dockerignore when building the web image +# (BuildKit picks .dockerignore when present). The web image builds +# from the repo root and needs web/ + design-system/ (the @e2a/ui workspace); +# everything else is excluded to keep the build context small. +.git +**/node_modules +web/.next +web/out +**/.env* +**/.DS_Store + +# @e2a/ui: keep the committed dist/ (the build consumes it); drop work state. +design-system/ds-bundle +design-system/storybook-static +design-system/.design-sync + +# Go backend + unrelated workspaces — not part of the web image. +cmd +internal +sdks +cli +mcp +migrations +api +tests +docs +examples +assets +bin +plugins +*.go +go.mod +go.sum +Makefile +config*.yaml +docker-compose.yaml diff --git a/web/jest.config.ts b/web/jest.config.ts index 3d0849c7..6cda0ec4 100644 --- a/web/jest.config.ts +++ b/web/jest.config.ts @@ -7,6 +7,13 @@ const config: Config = { testPathIgnorePatterns: ["/node_modules/", "/.next/"], moduleNameMapper: { "^@/(.*)$": "/src/$1", + // @e2a/ui ships ESM-only; resolve it to its TS source so ts-jest transforms + // it like the rest of the suite (jest is CJS and can't load the ESM dist). + "^@e2a/ui$": "/../design-system/src/index.ts", + // ...but the source then resolves react from design-system/node_modules — a + // SECOND copy → null hook dispatcher. Pin react to web's single copy. + "^react$": "/node_modules/react", + "^react/(.*)$": "/node_modules/react/$1", }, transform: { "^.+\\.tsx?$": [ diff --git a/web/next.config.ts b/web/next.config.ts index a418a491..f78ff409 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,12 +1,21 @@ import type { NextConfig } from "next"; +import { fileURLToPath } from "node:url"; import createMDX from "@next/mdx"; const isDev = process.env.NODE_ENV !== "production"; +// The monorepo root (parent of web/). Pinned as Turbopack's root so it resolves +// the sibling @e2a/ui workspace (file:../design-system) — which lives OUTSIDE +// web/. Without this, Turbopack infers web/ as the root and rejects @e2a/ui as +// "outside the project". Portable: resolves to the repo root locally and /app +// in the web Docker image. Also silences the "inferred workspace root" warning. +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + const withMDX = createMDX({}); const nextConfig: NextConfig = { output: isDev ? undefined : "export", + turbopack: { root: repoRoot }, pageExtensions: ["ts", "tsx", "md", "mdx"], ...(isDev && { rewrites: async () => [ diff --git a/web/package-lock.json b/web/package-lock.json index b81b5ccd..9b0c7066 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@e2a/ui": "file:../design-system", "dompurify": "^3.4.11", "next": "^16.2.9", "react": "19.2.7", @@ -37,6 +38,28 @@ "typescript": "^5" } }, + "../design-system": { + "name": "@e2a/ui", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@storybook/react": "^8.6.14", + "@storybook/react-vite": "^8.6.14", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "storybook": "^8.6.14", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vite": "^6.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -699,6 +722,10 @@ "node": ">=18" } }, + "node_modules/@e2a/ui": { + "resolved": "../design-system", + "link": true + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", diff --git a/web/package.json b/web/package.json index 28d5f50a..61cc4131 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "test": "jest" }, "dependencies": { + "@e2a/ui": "file:../design-system", "dompurify": "^3.4.11", "next": "^16.2.9", "react": "19.2.7", diff --git a/web/src/app/(app)/api-keys/page.tsx b/web/src/app/(app)/api-keys/page.tsx index e2e3573a..75445aac 100644 --- a/web/src/app/(app)/api-keys/page.tsx +++ b/web/src/app/(app)/api-keys/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import type { APIKeyData } from "../../components/types"; import { useAgents } from "../../components/hooks/useAgents"; import { PageShell } from "../../components/loft/PageShell"; -import { Chip } from "../../components/loft/Chip"; +import { Chip } from "@e2a/ui"; type SortKey = "last_used" | "created" | "name"; diff --git a/web/src/app/(app)/domains/_components/DomainCard.tsx b/web/src/app/(app)/domains/_components/DomainCard.tsx index dcc09e7e..4d71641b 100644 --- a/web/src/app/(app)/domains/_components/DomainCard.tsx +++ b/web/src/app/(app)/domains/_components/DomainCard.tsx @@ -2,8 +2,7 @@ import { useState } from "react"; import { DNSRecord as DNSRecordField } from "../../../components/Field"; -import { Chip } from "../../../components/loft/Chip"; -import { Dot } from "../../../components/loft/Dot"; +import { Chip, Dot } from "@e2a/ui"; import { verifyDomain, deleteDomain, diff --git a/web/src/app/(app)/get-started/_components/AddressChoice.tsx b/web/src/app/(app)/get-started/_components/AddressChoice.tsx index 340fa379..26bc527b 100644 --- a/web/src/app/(app)/get-started/_components/AddressChoice.tsx +++ b/web/src/app/(app)/get-started/_components/AddressChoice.tsx @@ -1,7 +1,7 @@ "use client"; import type { AddressType } from "../../../components/onboarding/types"; -import { Chip } from "../../../components/loft/Chip"; +import { Chip } from "@e2a/ui"; import { AGENTS_DOMAIN_DISPLAY } from "../../../../lib/site"; import { SelectableCard } from "./SelectableCard"; diff --git a/web/src/app/(app)/get-started/_components/SetupMethodChoice.tsx b/web/src/app/(app)/get-started/_components/SetupMethodChoice.tsx index 57eb1f51..7db82953 100644 --- a/web/src/app/(app)/get-started/_components/SetupMethodChoice.tsx +++ b/web/src/app/(app)/get-started/_components/SetupMethodChoice.tsx @@ -6,7 +6,7 @@ // and no API key; the web path is the click-through fallback. import type { SetupMethod } from "../../../components/onboarding/types"; -import { Chip } from "../../../components/loft/Chip"; +import { Chip } from "@e2a/ui"; import { SelectableCard } from "./SelectableCard"; export function SetupMethodChoice({ diff --git a/web/src/app/(app)/inboxes/(view)/messages/view/page.tsx b/web/src/app/(app)/inboxes/(view)/messages/view/page.tsx index edb927f8..b4c1ccb2 100644 --- a/web/src/app/(app)/inboxes/(view)/messages/view/page.tsx +++ b/web/src/app/(app)/inboxes/(view)/messages/view/page.tsx @@ -26,10 +26,7 @@ import type { InboundMessageDetail, PendingMessageDetail, } from "../../../../../components/types"; -import { Chip } from "../../../../../components/loft/Chip"; -import { Dot } from "../../../../../components/loft/Dot"; -import { Eyebrow } from "../../../../../components/loft/Eyebrow"; -import { InkConsole, type InkLine } from "../../../../../components/loft/InkConsole"; +import { Chip, Dot, Eyebrow, InkConsole, type InkLine } from "@e2a/ui"; import { Collapsible } from "../../../../../components/messages/Collapsible"; import { EmailHtmlBody } from "../../../../../components/messages/EmailHtmlBody"; import { MessageDirectionIcon } from "../../../../../components/messages/MessageDirectionIcon"; diff --git a/web/src/app/(app)/inboxes/(view)/settings/page.tsx b/web/src/app/(app)/inboxes/(view)/settings/page.tsx index f6462859..72d7175d 100644 --- a/web/src/app/(app)/inboxes/(view)/settings/page.tsx +++ b/web/src/app/(app)/inboxes/(view)/settings/page.tsx @@ -7,8 +7,7 @@ import { Suspense, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import useSWR from "swr"; -import { Eyebrow } from "../../../../components/loft/Eyebrow"; -import { Chip } from "../../../../components/loft/Chip"; +import { Chip, Eyebrow } from "@e2a/ui"; import { deleteAgent, getProtection } from "../../../../components/onboarding/api"; import { useAgents } from "../../../../components/hooks/useAgents"; import { diff --git a/web/src/app/(app)/inboxes/_components/AgentCard.tsx b/web/src/app/(app)/inboxes/_components/AgentCard.tsx index ac1e6b3a..ca06df37 100644 --- a/web/src/app/(app)/inboxes/_components/AgentCard.tsx +++ b/web/src/app/(app)/inboxes/_components/AgentCard.tsx @@ -1,7 +1,6 @@ import Link from "next/link"; import type { DashboardAgent } from "../../../components/types"; -import { Chip } from "../../../components/loft/Chip"; -import { Dot } from "../../../components/loft/Dot"; +import { Chip, Dot } from "@e2a/ui"; export function AgentCard({ agent, diff --git a/web/src/app/(app)/reviews/_components/PendingRow.tsx b/web/src/app/(app)/reviews/_components/PendingRow.tsx index 733261f9..5f473f66 100644 --- a/web/src/app/(app)/reviews/_components/PendingRow.tsx +++ b/web/src/app/(app)/reviews/_components/PendingRow.tsx @@ -25,8 +25,7 @@ import type { PendingMessageDetail, PendingMessageSummary, } from "../../../components/types"; -import { Chip } from "../../../components/loft/Chip"; -import { Dot } from "../../../components/loft/Dot"; +import { Chip, Dot } from "@e2a/ui"; import { diffApproveEdits, joinCSV } from "./edits"; function formatQueuedAgo(iso: string): string { diff --git a/web/src/app/blog/page.tsx b/web/src/app/blog/page.tsx index 1cdf963b..7e50252b 100644 --- a/web/src/app/blog/page.tsx +++ b/web/src/app/blog/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { Eyebrow } from "../components/loft/Eyebrow"; +import { Eyebrow } from "@e2a/ui"; import { getPostsSortedByDate } from "./posts"; function formatDate(iso: string): string { diff --git a/web/src/app/components/messages/AgentHeader.tsx b/web/src/app/components/messages/AgentHeader.tsx index c2e3f2d3..9db3fa9f 100644 --- a/web/src/app/components/messages/AgentHeader.tsx +++ b/web/src/app/components/messages/AgentHeader.tsx @@ -11,9 +11,7 @@ import { useState } from "react"; import Link from "next/link"; -import { Chip } from "../loft/Chip"; -import { Dot } from "../loft/Dot"; -import { Eyebrow } from "../loft/Eyebrow"; +import { Chip, Dot, Eyebrow } from "@e2a/ui"; import { CounterpartyAvatar } from "./CounterpartyAvatar"; import { sendAgentTestEmail } from "../onboarding/api"; import type { DashboardAgent } from "../types"; diff --git a/web/src/app/components/messages/Collapsible.tsx b/web/src/app/components/messages/Collapsible.tsx index 4cc34694..ef530433 100644 --- a/web/src/app/components/messages/Collapsible.tsx +++ b/web/src/app/components/messages/Collapsible.tsx @@ -9,7 +9,7 @@ // 0ms instead of the default 120ms transition. import { useState, useSyncExternalStore, type ReactNode } from "react"; -import { Eyebrow } from "../loft/Eyebrow"; +import { Eyebrow } from "@e2a/ui"; export type CollapsibleProps = { label: string; diff --git a/web/src/app/components/messages/MessageStatusChip.tsx b/web/src/app/components/messages/MessageStatusChip.tsx index c5c4f286..52676bcb 100644 --- a/web/src/app/components/messages/MessageStatusChip.tsx +++ b/web/src/app/components/messages/MessageStatusChip.tsx @@ -15,9 +15,7 @@ // does the chip surfaces Failed — delivery is a louder alarm than a // stale-but-recoverable approval. -import { Chip } from "../loft/Chip"; -import { Dot } from "../loft/Dot"; -import type { ChipTone } from "../loft/Chip"; +import { Chip, Dot, type ChipTone } from "@e2a/ui"; export type MessageStatusInput = { direction: "inbound" | "outbound"; diff --git a/web/src/app/components/messages/ThreadBubble.tsx b/web/src/app/components/messages/ThreadBubble.tsx index 7e9a1b96..72daca12 100644 --- a/web/src/app/components/messages/ThreadBubble.tsx +++ b/web/src/app/components/messages/ThreadBubble.tsx @@ -9,8 +9,7 @@ import { useState } from "react"; import useSWR from "swr"; -import { Chip } from "../loft/Chip"; -import { Dot } from "../loft/Dot"; +import { Chip, Dot } from "@e2a/ui"; import { CounterpartyAvatar } from "./CounterpartyAvatar"; import { EmailHtmlBody } from "./EmailHtmlBody"; import { getMessageDetail } from "../onboarding/api"; diff --git a/web/src/app/components/messages/ThreadDetail.tsx b/web/src/app/components/messages/ThreadDetail.tsx index 66ec58f0..8444b11b 100644 --- a/web/src/app/components/messages/ThreadDetail.tsx +++ b/web/src/app/components/messages/ThreadDetail.tsx @@ -6,8 +6,7 @@ // reverse-chronological in the artboard, but for an email-client // reading flow the conversation reads oldest→newest top→bottom. -import { Chip } from "../loft/Chip"; -import { Dot } from "../loft/Dot"; +import { Chip, Dot } from "@e2a/ui"; import { CounterpartyAvatar } from "./CounterpartyAvatar"; import { ThreadBubble } from "./ThreadBubble"; import { PendingCallout } from "./PendingCallout"; diff --git a/web/src/app/globals.css b/web/src/app/globals.css index caf4d52d..8011c757 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -52,7 +52,7 @@ --info: #2D6CFF; --info-bg: #E4ECFF; --info-strong: #0050D6; --warn: #C78400; --warn-bg: #FFF1D1; --warn-strong: #8F5F00; --danger: #CC2E2E; --danger-bg: #FBE3E0; --danger-strong: #A82020; - --success: #0F7A4D; --success-bg: #DFF3E8; + --success: #0F7A4D; --success-bg: #DFF3E8; --success-strong: #0B5C3A; /* ── Kind hues (decorative; always paired with a kind icon) ── */ --k-md: #2D6CFF; @@ -148,7 +148,7 @@ --info: #79B4FF; --info-bg: #0F2046; --info-strong: #A6CCFF; --warn: #E4A82F; --warn-bg: #2E1F00; --warn-strong: #F2C257; --danger: #FF6B6B; --danger-bg: #2E1010; --danger-strong: #FF9292; - --success: #5AC68A; --success-bg: #0B2418; + --success: #5AC68A; --success-bg: #0B2418; --success-strong: #8FE0B3; --k-md: #79B4FF; --k-code: #A989F0; --k-image: #5AC68A; --k-video: #E4A82F; --k-dataset: #E07AC3; --k-skill: #F08055; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index eb9aa156..58c5b79a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,5 +1,8 @@ import type { Metadata } from "next"; import { Geist, Instrument_Serif, JetBrains_Mono } from "next/font/google"; +// @e2a/ui component classes (loft-*). Imported before globals.css so the app's +// globals.css stays authoritative for design tokens and the Tailwind theme. +import "@e2a/ui/styles.css"; import "./globals.css"; import { AuthProvider } from "./components/AuthProvider"; import { ThemeProvider } from "./components/ThemeProvider"; diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index e51ec774..a4d85a18 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useState } from "react"; import { useAuth } from "./components/AuthProvider"; -import { Eyebrow } from "./components/loft/Eyebrow"; +import { Eyebrow } from "@e2a/ui"; import { AGENTS_DOMAIN } from "../lib/site"; type Tab = "cli" | "claude" | "python" | "webhook";