diff --git a/app/components/AdminNav/index.tsx b/app/components/AdminNav/index.tsx index abdf245..075086c 100644 --- a/app/components/AdminNav/index.tsx +++ b/app/components/AdminNav/index.tsx @@ -9,12 +9,12 @@ export function AdminNav({ current }: { current: "accounts" | "logs" }) { - diff --git a/app/components/AutomationCard/styles.css.ts b/app/components/AutomationCard/styles.css.ts index 4b3969c..17003db 100644 --- a/app/components/AutomationCard/styles.css.ts +++ b/app/components/AutomationCard/styles.css.ts @@ -3,24 +3,22 @@ import { vars } from "../../styles/theme.css.ts"; import { space } from "../../styles/tokens/spacing.ts"; import { fontSize, fontWeight, lineHeight } from "../../styles/tokens/typography.ts"; import { radii } from "../../styles/tokens/radii.ts"; +import { cardSurface, cardHover } from "../Card/styles.css.ts"; +// Bespoke layout (clickable overlay, flow diagram, footer) over the shared flat +// card surface. The whole card is a click target, so it carries the interactive +// hover affordance. export const card = style({ + ...cardSurface, position: "relative", display: "flex", flexDirection: "column", gap: space[3], - padding: space[5], - backgroundColor: vars.color.surface, - border: `1px solid ${vars.color.border}`, - borderRadius: radii.lg, - boxShadow: vars.shadow.highlight, - transition: "border-color 160ms ease, box-shadow 160ms ease", - ":hover": { - borderColor: vars.color.accent, - boxShadow: `${vars.shadow.highlight}, ${vars.shadow.md}`, - }, + ":hover": { ...cardHover }, }); +// Featured automations get an Infrared-tinted border - the one place accent +// appears on a card, marking the highlight. export const cardFeatured = style({ borderColor: `color-mix(in oklch, ${vars.color.accent} 35%, ${vars.color.border})`, }); diff --git a/app/components/Badge/index.tsx b/app/components/Badge/index.tsx index 5a5b715..9966065 100644 --- a/app/components/Badge/index.tsx +++ b/app/components/Badge/index.tsx @@ -1,12 +1,4 @@ -import type { Child } from "hono/jsx"; -import * as s from "./styles.css.ts"; - -export function Badge({ - variant: v = "neutral", - children, -}: { - variant?: keyof typeof s.variant; - children: Child; -}) { - return {children}; -} +// Badge is a thin alias over the shared Label primitive. Its status variants +// (success / warning / error / neutral / accent) are Label tones. New code should +// import Label directly; this alias keeps existing status call sites working. +export { Label as Badge } from "../Label/index.tsx"; diff --git a/app/components/Badge/styles.css.ts b/app/components/Badge/styles.css.ts deleted file mode 100644 index 7b71a41..0000000 --- a/app/components/Badge/styles.css.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { styleVariants, style } from "@vanilla-extract/css"; -import { vars } from "../../styles/theme.css.ts"; -import { space } from "../../styles/tokens/spacing.ts"; -import { fontSize, fontWeight } from "../../styles/tokens/typography.ts"; -import { radii } from "../../styles/tokens/radii.ts"; - -const base = style({ - display: "inline-flex", - alignItems: "center", - paddingBlock: "2px", - paddingInline: space[2], - borderRadius: radii.full, - fontSize: fontSize.xs, - fontWeight: fontWeight.medium, - lineHeight: 1.5, - whiteSpace: "nowrap", -}); - -export const variant = styleVariants({ - success: [ - base, - { - backgroundColor: vars.color.successSubtle, - color: vars.color.success, - }, - ], - warning: [ - base, - { - backgroundColor: vars.color.warningSubtle, - color: vars.color.warning, - }, - ], - error: [ - base, - { - backgroundColor: vars.color.errorSubtle, - color: vars.color.error, - }, - ], - neutral: [ - base, - { - backgroundColor: vars.color.surfaceHover, - color: vars.color.textSecondary, - }, - ], - accent: [ - base, - { - backgroundColor: vars.color.accentSubtle, - color: vars.color.accent, - }, - ], -}); diff --git a/app/components/Button/index.test.tsx b/app/components/Button/index.test.tsx new file mode 100644 index 0000000..4ea96d6 --- /dev/null +++ b/app/components/Button/index.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { render } from "hono/jsx/dom"; +import { Button } from "./index.js"; +import * as s from "./styles.css.js"; + +function mount(node: ReturnType) { + const container = document.createElement("div"); + document.body.appendChild(container); + render(node, container); + return container; +} + +describe("Button", () => { + it("renders a ); + const el = c.querySelector("button"); + expect(el).not.toBeNull(); + expect(c.querySelector("a")).toBeNull(); + expect(el!.className).toContain(s.variant.primary); + expect(el!.className).toContain(s.size.lg); + }); + + it("renders an when href is set, and never a ); + const a = c.querySelector("a"); + expect(a).not.toBeNull(); + expect(a!.getAttribute("href")).toBe("/automations"); + expect(c.querySelector("button")).toBeNull(); + }); + + it("applies the expected class for each box type and size", () => { + for (const v of ["primary", "secondary", "tertiary"] as const) { + for (const sz of ["lg", "sm"] as const) { + const c = mount( + , + ); + const el = c.querySelector("button")!; + expect(el.className).toContain(s.variant[v]); + expect(el.className).toContain(s.size[sz]); + } + } + }); + + it("uses the font-only linkSize for the link variant, not the box size", () => { + const c = mount( + , + ); + const el = c.querySelector("button")!; + expect(el.className).toContain(s.variant.link); + expect(el.className).toContain(s.linkSize.sm); + expect(el.className).not.toContain(s.size.sm); + }); + + it("renders a disabled, non-interactive button", () => { + const c = mount(); + const el = c.querySelector("button")!; + expect(el.disabled).toBe(true); + }); + + it("applies the block class only when fullWidth is set", () => { + const plain = mount().querySelector("button")!; + expect(plain.className).not.toContain(s.block); + const wide = mount().querySelector("button")!; + expect(wide.className).toContain(s.block); + expect(wide.className).toContain(s.variant.primary); + }); + + it("converts disabled to aria-disabled on an and removes href", () => { + const c = mount( + , + ); + const a = c.querySelector("a")!; + expect(a.getAttribute("aria-disabled")).toBe("true"); + expect(a.getAttribute("href")).toBeNull(); + expect(a.getAttribute("tabindex")).toBe("-1"); + expect(c.querySelector("button")).toBeNull(); + }); +}); diff --git a/app/components/Button/index.tsx b/app/components/Button/index.tsx index 60ff4e7..92e3e46 100644 --- a/app/components/Button/index.tsx +++ b/app/components/Button/index.tsx @@ -6,6 +6,7 @@ type ButtonProps = { size?: keyof typeof s.size; type?: "button" | "submit" | "reset"; disabled?: boolean; + fullWidth?: boolean; href?: string; children: Child; [key: string]: unknown; @@ -13,24 +14,37 @@ type ButtonProps = { export function Button({ variant: v = "primary", - size: sz = "md", + size: sz = "lg", type = "button", + disabled, + fullWidth, href, children, ...rest }: ButtonProps) { - const className = `${s.variant[v]} ${s.size[sz]}`; + // The link variant is an underlined inline button, not a box, so it takes the + // font-only linkSize instead of the padded box size. + const sizeClass = v === "link" ? s.linkSize[sz] : s.size[sz]; + const className = `${s.variant[v]} ${sizeClass}${fullWidth ? ` ${s.block}` : ""}`; if (href) { + // does not support the disabled attribute; convert to aria pattern. + // Omitting href prevents navigation; aria-disabled signals the state to AT. return ( - + {children} ); } return ( - ); diff --git a/app/components/Button/styles.css.ts b/app/components/Button/styles.css.ts index d4f4fe3..4fe4840 100644 --- a/app/components/Button/styles.css.ts +++ b/app/components/Button/styles.css.ts @@ -1,106 +1,137 @@ import { style, styleVariants } from "@vanilla-extract/css"; import { vars } from "../../styles/theme.css.ts"; -import { space } from "../../styles/tokens/spacing.ts"; import { fontSize, fontWeight } from "../../styles/tokens/typography.ts"; import { radii } from "../../styles/tokens/radii.ts"; +// Figma button matrix: primary / secondary / tertiary / link x lg / sm x +// default / hover / disabled. The system is fully neutral (the Infrared accent is +// reserved for inline links and highlights, not buttons) and flat (no shadows). +// A 2px transparent border on the base keeps box sizes aligned with the tertiary +// outline, which carries a real 2px border. const base = style({ display: "inline-flex", alignItems: "center", justifyContent: "center", - gap: space[2], - borderRadius: radii.md, - fontWeight: fontWeight.medium, + gap: "8px", + boxSizing: "border-box", + fontWeight: fontWeight.semibold, + letterSpacing: "0.14px", + lineHeight: "20px", cursor: "pointer", - border: "1px solid transparent", + border: "2px solid transparent", textDecoration: "none", whiteSpace: "nowrap", - lineHeight: 1, selectors: { "&:disabled, &[aria-disabled='true']": { - opacity: 0.5, cursor: "not-allowed", }, }, }); export const variant = styleVariants({ + // Solid prominent button. Near-black on light, near-white on dark. primary: [ base, { - backgroundColor: vars.color.accent, - color: vars.color.accentText, - boxShadow: `${vars.shadow.highlight}, ${vars.shadow.sm}`, + backgroundColor: vars.color.solid, + color: vars.color.solidText, ":hover": { - backgroundColor: vars.color.accentHover, - color: "white", + backgroundColor: vars.color.solidHover, + color: vars.color.solidText, }, - ":active": { - backgroundColor: vars.color.accentActive, - boxShadow: "none", + selectors: { + "&:disabled, &[aria-disabled='true']": { + backgroundColor: vars.color.solidDisabled, + color: vars.color.solidDisabledText, + }, }, }, ], + // Filled neutral surface. secondary: [ base, { - backgroundColor: vars.color.surface, - color: vars.color.text, - borderColor: vars.color.border, - boxShadow: `${vars.shadow.highlight}, ${vars.shadow.sm}`, + backgroundColor: vars.color.subtle, + color: vars.color.heading, ":hover": { - backgroundColor: vars.color.surfaceHover, - borderColor: vars.color.text, + backgroundColor: vars.color.subtleHover, + color: vars.color.heading, }, - ":active": { - boxShadow: "none", + selectors: { + "&:disabled, &[aria-disabled='true']": { + color: vars.color.textMuted, + }, }, }, ], - ghost: [ + // Outlined. Border strengthens on hover; surface fills on hover. + tertiary: [ base, { backgroundColor: "transparent", color: vars.color.textSecondary, + borderColor: vars.color.borderSubtle, ":hover": { backgroundColor: vars.color.surfaceHover, - color: vars.color.text, + color: vars.color.heading, + borderColor: vars.color.borderStrong, + }, + selectors: { + "&:disabled, &[aria-disabled='true']": { + color: vars.color.textDisabled, + }, }, }, ], - danger: [ + // Inline-style underlined button. Ignores the box sizing (see linkSize). + link: [ base, { - backgroundColor: vars.color.error, - color: "white", - boxShadow: `${vars.shadow.highlight}, ${vars.shadow.sm}`, + backgroundColor: "transparent", + color: vars.color.textSecondary, + border: "none", + borderBottom: `2px solid ${vars.color.borderSubtle}`, + borderRadius: 0, + paddingInline: 0, + paddingBlock: "2px", + minBlockSize: "auto", ":hover": { - opacity: 0.9, + backgroundColor: vars.color.surfaceHover, + color: vars.color.heading, + borderBottomColor: vars.color.borderStrong, }, - ":active": { - boxShadow: "none", + selectors: { + "&:disabled, &[aria-disabled='true']": { + color: vars.color.textDisabled, + }, }, }, ], }); +// Box sizing for primary / secondary / tertiary. export const size = styleVariants({ - sm: { - fontSize: fontSize.sm, - paddingBlock: space[1], - paddingInline: space[4], - minBlockSize: "32px", - }, - md: { - fontSize: fontSize.base, - paddingBlock: space[2], - paddingInline: space[5], - minBlockSize: "40px", - }, lg: { - fontSize: fontSize.md, - paddingBlock: space[3], - paddingInline: space[6], + fontSize: fontSize.sm, + paddingBlock: "12px", + paddingInline: "20px", minBlockSize: "48px", + borderRadius: radii.lg, + }, + sm: { + fontSize: fontSize.sm, + paddingBlock: "8px", + paddingInline: "16px", + minBlockSize: "36px", + borderRadius: radii.md, }, }); + +// The link variant has no box; size only drives the font step. +export const linkSize = styleVariants({ + lg: { fontSize: fontSize.sm }, + sm: { fontSize: fontSize.xs, letterSpacing: "0.12px", lineHeight: "16px" }, +}); + +// Opt-in full-width modifier for sole form submits (login, settings forms). +export const block = style({ inlineSize: "100%" }); diff --git a/app/components/Card/index.tsx b/app/components/Card/index.tsx index c12ae74..b61ac0c 100644 --- a/app/components/Card/index.tsx +++ b/app/components/Card/index.tsx @@ -2,7 +2,7 @@ import type { Child } from "hono/jsx"; import * as s from "./styles.css.ts"; export function Card({ - variant: v = "flat", + variant: v = "default", children, }: { variant?: keyof typeof s.variant; diff --git a/app/components/Card/styles.css.ts b/app/components/Card/styles.css.ts index 770a4e1..32b5f50 100644 --- a/app/components/Card/styles.css.ts +++ b/app/components/Card/styles.css.ts @@ -3,32 +3,33 @@ import { vars } from "../../styles/theme.css.ts"; import { space } from "../../styles/tokens/spacing.ts"; import { radii } from "../../styles/tokens/radii.ts"; -const baseStyles = { +// Figma card: flat white surface, 12px radius, 24px padding. The only state +// difference is the border. Default is a hairline borderSubtle (midnight-200); +// the emphasized state thickens and darkens it to `border` (midnight-300). +// Exported so bespoke surfaces (AutomationCard, FetchCard) can reuse the same +// flat card tokens instead of re-declaring bg/border/radius/padding. +export const cardSurface = { backgroundColor: vars.color.surface, borderRadius: radii.lg, paddingBlock: space[5], paddingInline: space[5], + border: `1px solid ${vars.color.borderSubtle}`, +} as const; + +// Hover emphasis shared with interactive cards: thicken the border via an inset +// ring (drawn inside the box) so the surrounding grid never reflows. +export const cardHover = { + borderColor: vars.color.border, + boxShadow: `inset 0 0 0 1px ${vars.color.border}`, } as const; export const variant = styleVariants({ - flat: { - ...baseStyles, - border: `1px solid ${vars.color.border}`, - boxShadow: vars.shadow.highlight, - }, - raised: { - ...baseStyles, - border: `1px solid ${vars.color.borderSubtle}`, - boxShadow: `${vars.shadow.highlight}, ${vars.shadow.md}`, - }, + default: { ...cardSurface }, + // Clickable card. Hover emphasizes the border via an inset ring (drawn inside + // the box) so the surrounding grid never reflows. interactive: { - ...baseStyles, - border: `1px solid ${vars.color.border}`, - boxShadow: vars.shadow.highlight, + ...cardSurface, cursor: "pointer", - ":hover": { - borderColor: vars.color.accent, - boxShadow: `${vars.shadow.highlight}, ${vars.shadow.md}`, - }, + ":hover": { ...cardHover }, }, }); diff --git a/app/components/CodeBlock/styles.css.ts b/app/components/CodeBlock/styles.css.ts index 52a1cd6..b815f5e 100644 --- a/app/components/CodeBlock/styles.css.ts +++ b/app/components/CodeBlock/styles.css.ts @@ -21,6 +21,9 @@ export const codeBlock = style({ export const inlineCode = style({ fontFamily: fontFamily.mono, fontSize: "0.875em", + // Always read as neutral Midnight code, even inside a link (NSID codes are + // often wrapped in anchors); do not inherit the Infrared link color. + color: vars.color.text, backgroundColor: vars.color.code, paddingInline: "6px", paddingBlock: "2px", diff --git a/app/components/FetchCard/styles.css.ts b/app/components/FetchCard/styles.css.ts index 76b70e4..a73868b 100644 --- a/app/components/FetchCard/styles.css.ts +++ b/app/components/FetchCard/styles.css.ts @@ -3,17 +3,17 @@ import { vars } from "../../styles/theme.css.ts"; import { radii } from "../../styles/tokens/radii.ts"; import { space } from "../../styles/tokens/spacing.ts"; import { fontSize, fontWeight } from "../../styles/tokens/typography.ts"; +import { cardSurface } from "../Card/styles.css.ts"; +// Static informational card over the shared flat surface, kept at a denser +// 16px padding. export const card = style({ + ...cardSurface, display: "flex", flexDirection: "column", gap: space[3], paddingBlock: space[4], paddingInline: space[4], - borderRadius: radii.lg, - border: `1px solid ${vars.color.border}`, - backgroundColor: vars.color.surface, - boxShadow: vars.shadow.highlight, }); export const headerRow = style({ diff --git a/app/components/Label/index.test.tsx b/app/components/Label/index.test.tsx new file mode 100644 index 0000000..df879dd --- /dev/null +++ b/app/components/Label/index.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { render } from "hono/jsx/dom"; +import { Label } from "./index.js"; +import { Badge } from "../Badge/index.js"; +import * as s from "./styles.css.js"; + +function mount(node: ReturnType) { + const container = document.createElement("div"); + document.body.appendChild(container); + render(node, container); + return container; +} + +describe("Label", () => { + it("renders each of the five Figma visual types", () => { + for (const v of ["neutral", "success", "info", "special", "accent"] as const) { + const c = mount(); + const el = c.querySelector("span")!; + expect(el.className).toContain(s.variant[v]); + } + }); + + it("supports the folded-in status tones", () => { + for (const v of ["warning", "error"] as const) { + const c = mount(); + expect(c.querySelector("span")!.className).toContain(s.variant[v]); + } + }); + + it("Badge is an alias that renders the equivalent Label tone", () => { + const badge = mount(Failure); + const label = mount(); + expect(badge.querySelector("span")!.className).toBe(label.querySelector("span")!.className); + }); +}); diff --git a/app/components/Label/index.tsx b/app/components/Label/index.tsx new file mode 100644 index 0000000..b98c660 --- /dev/null +++ b/app/components/Label/index.tsx @@ -0,0 +1,15 @@ +import type { Child } from "hono/jsx"; +import * as s from "./styles.css.ts"; + +// Shared pill primitive (Figma Label). `variant` selects a tone; the five Figma +// visual types are neutral / success / info / special / accent, with warning and +// error added so status pills (formerly Badge) share the same primitive. +export function Label({ + variant: v = "neutral", + children, +}: { + variant?: keyof typeof s.variant; + children: Child; +}) { + return {children}; +} diff --git a/app/components/Label/styles.css.ts b/app/components/Label/styles.css.ts new file mode 100644 index 0000000..2bd4e5f --- /dev/null +++ b/app/components/Label/styles.css.ts @@ -0,0 +1,35 @@ +import { style, styleVariants } from "@vanilla-extract/css"; +import { palette as p } from "../../styles/tokens/palette.ts"; +import { space } from "../../styles/tokens/spacing.ts"; +import { fontFamily, fontSize, fontWeight, letterSpacing } from "../../styles/tokens/typography.ts"; + +// Figma Label: a mono pill with a solid pastel background and dark text. The +// pastel-on-dark-text pairing reads on both themes, so these tones are +// theme-independent (drawn straight from the palette, not the semantic theme +// vars). Tones cover the five Figma visual types and fold in Badge's status +// meanings: since the brand palette has no red/amber, accent/warning/error use +// graduated Infrared stops (light -> strong) to stay distinguishable in-brand. +const base = style({ + display: "inline-flex", + alignItems: "center", + paddingBlock: "6px", + paddingInline: space[2], + borderRadius: "6px", + fontFamily: fontFamily.mono, + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + lineHeight: 1, + letterSpacing: letterSpacing.wide, + color: p.midnight[900], + whiteSpace: "nowrap", +}); + +export const variant = styleVariants({ + neutral: [base, { backgroundColor: p.midnight[200] }], // Figma "simple" + success: [base, { backgroundColor: p.glow[200] }], // Figma type2 (create) + info: [base, { backgroundColor: p.sky[300] }], // Figma type4 (save) + special: [base, { backgroundColor: p.aurora[200] }], // Figma type3 (update) + accent: [base, { backgroundColor: p.infrared[200] }], // Figma type5 (post) + warning: [base, { backgroundColor: p.infrared[300] }], + error: [base, { backgroundColor: p.infrared[400] }], +}); diff --git a/app/components/Layout/Header/index.tsx b/app/components/Layout/Header/index.tsx index f4f6486..48bcfb6 100644 --- a/app/components/Layout/Header/index.tsx +++ b/app/components/Layout/Header/index.tsx @@ -17,10 +17,9 @@ export function Header({ user }: { user?: { handle: string } | null }) {
- diff --git a/app/components/Layout/Header/styles.css.ts b/app/components/Layout/Header/styles.css.ts index 8abb825..782839f 100644 --- a/app/components/Layout/Header/styles.css.ts +++ b/app/components/Layout/Header/styles.css.ts @@ -34,8 +34,8 @@ export const brand = style({ }); export const logo = style({ - inlineSize: "24px", - blockSize: "24px", + blockSize: "22px", + inlineSize: "auto", }); export const nav = style({ @@ -59,7 +59,7 @@ export const navLink = style({ export const navLinkActive = style({ color: vars.color.text, textDecoration: "underline", - textDecorationColor: vars.color.accent, + textDecorationColor: vars.color.text, textDecorationThickness: "1px", textUnderlineOffset: "4px", }); @@ -103,7 +103,7 @@ export const avatar = style({ marginInlineStart: space[1], ":hover": { backgroundColor: vars.color.accentHover, - color: "white", + color: vars.color.accentText, }, }); diff --git a/app/islands/AutomationForm.css.ts b/app/islands/AutomationForm.css.ts index 19d1c82..e4b7c4e 100644 --- a/app/islands/AutomationForm.css.ts +++ b/app/islands/AutomationForm.css.ts @@ -252,35 +252,9 @@ export const addBtn = style({ }, }); -export const submitBtn = style({ - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - paddingBlock: space[2], - paddingInline: space[5], - fontSize: fontSize.base, - fontWeight: fontWeight.medium, - color: vars.color.accentText, - backgroundColor: vars.color.accent, - border: "1px solid transparent", - borderRadius: radii.md, - cursor: "pointer", - minBlockSize: "40px", - lineHeight: 1, - ":hover": { - backgroundColor: vars.color.accentHover, - }, - ":active": { - backgroundColor: vars.color.accentActive, - }, - selectors: { - "&:disabled": { - opacity: 0.5, - cursor: "not-allowed", - }, - }, -}); - +// The primary submit uses the shared Button primitive (neutral primary). The +// bespoke accent submitBtn was removed in the brand sweep (R10); submitWrap / +// stickySubmit below only handle its positioning. export const submitWrap = style({ display: "flex", justifyContent: "flex-end", diff --git a/app/islands/AutomationForm.tsx b/app/islands/AutomationForm.tsx index d2058da..fed9743 100644 --- a/app/islands/AutomationForm.tsx +++ b/app/islands/AutomationForm.tsx @@ -14,6 +14,7 @@ import { } from "../../lib/automations/action-catalogue.js"; import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; import { ActionHeader } from "../components/ActionHeader/index.js"; +import { Button } from "../components/Button/index.js"; import { ChevronDown, GripVertical, Plus } from "../icons.js"; import { actionIcon } from "../styles/action-header.css.ts"; import * as s from "./AutomationForm.css.ts"; @@ -2878,9 +2879,8 @@ export default function AutomationForm({ {error &&
{error}
}
- +
diff --git a/app/islands/AutomationToolbar.tsx b/app/islands/AutomationToolbar.tsx index 78272ed..7b83fbe 100644 --- a/app/islands/AutomationToolbar.tsx +++ b/app/islands/AutomationToolbar.tsx @@ -17,7 +17,7 @@ type FlashTarget = "active" | "dryRun" | "delete" | "share"; type Flash = { target: FlashTarget; kind: "ok" | "err"; text: string }; const quiet = `${variant.secondary} ${size.sm} ${s.quiet}`; -const ghost = `${variant.ghost} ${size.sm}`; +const tertiary = `${variant.tertiary} ${size.sm}`; const tooltipClass = { start: { ok: s.tooltipStart, err: s.tooltipStartError }, @@ -214,19 +214,19 @@ export default function AutomationToolbar({ - + Edit - + Duplicate - + Logs + )} @@ -160,14 +156,9 @@ export default function NotificationSettings({ initialStatus, initialCategories Changed your mind? There is nothing to cancel: an unapproved request expires on its own after 7 days.

- + )} @@ -180,14 +171,9 @@ export default function NotificationSettings({ initialStatus, initialCategories {status === "revoked" && ( <>
Airglow can no longer send you notifications.
- + )} diff --git a/app/islands/RevokeSessions.css.ts b/app/islands/RevokeSessions.css.ts index 309a33d..6215b51 100644 --- a/app/islands/RevokeSessions.css.ts +++ b/app/islands/RevokeSessions.css.ts @@ -76,7 +76,9 @@ export const submitButton = style({ paddingBlock: space[2], paddingInline: space[4], backgroundColor: vars.color.error, - color: "white", + // accentText resolves to the readable text color per theme (white on the + // dark light-mode error, near-black on the lighter dark-mode error). + color: vars.color.accentText, border: "1px solid transparent", borderRadius: radii.md, fontSize: fontSize.sm, diff --git a/app/islands/SecretsManager.css.ts b/app/islands/SecretsManager.css.ts index ec21696..f5a5916 100644 --- a/app/islands/SecretsManager.css.ts +++ b/app/islands/SecretsManager.css.ts @@ -49,27 +49,8 @@ export const input = style({ }, }); -export const submitBtn = style({ - paddingBlock: space[2], - paddingInline: space[4], - fontSize: fontSize.base, - fontWeight: fontWeight.medium, - color: vars.color.accentText, - backgroundColor: vars.color.accent, - border: "1px solid transparent", - borderRadius: radii.md, - cursor: "pointer", - whiteSpace: "nowrap", - ":hover": { - backgroundColor: vars.color.accentHover, - }, - selectors: { - "&:disabled": { - opacity: 0.5, - cursor: "not-allowed", - }, - }, -}); +// "Save secret" uses the shared Button primitive (neutral primary); the bespoke +// accent submitBtn was removed in the brand sweep (R10). export const secretsList = style({ display: "flex", diff --git a/app/islands/SecretsManager.tsx b/app/islands/SecretsManager.tsx index 35945cb..f3eb585 100644 --- a/app/islands/SecretsManager.tsx +++ b/app/islands/SecretsManager.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from "hono/jsx"; +import { Button } from "../components/Button/index.js"; import * as s from "./SecretsManager.css.ts"; type SecretEntry = { @@ -139,9 +140,9 @@ export default function SecretsManager({ initial }: { initial: SecretEntry[] }) required />
- +

diff --git a/app/routes/_renderer.tsx b/app/routes/_renderer.tsx index 55b68c7..140967e 100644 --- a/app/routes/_renderer.tsx +++ b/app/routes/_renderer.tsx @@ -9,13 +9,13 @@ import "../styles/sprinkles.css.js"; // 1. Blocking script: set data-theme before paint // 2. Critical inline CSS: base colors, font, line-height — no external request needed -// Uses the same OKLCH values as the theme tokens. When full CSS loads, it takes over seamlessly. +// Mirrors the Midnight bg/text token values. When full CSS loads, it takes over seamlessly. const themeBootstrapBody = `(function(){var t=localStorage.getItem('theme');if(t){document.documentElement.dataset.theme=t}else if(matchMedia('(prefers-color-scheme:dark)').matches){document.documentElement.dataset.theme='dark'}})()`; const headInlineStyles = ``; // Inline ThemeToggle island CSS (~300B) in prod. The island loads dynamically @@ -114,6 +114,23 @@ export default jsxRenderer(({ children, title, description, ogImage }, c) => { {headInline} + + {/* The only Junicode glyphs we render are the h1 display headline, which + is italic. Preload the italic face (not Roman, which is never used) so + the hero does not flash the fallback serif before the face swaps in. */} + {!import.meta.env.PROD && } {inlineIslandCss} @@ -122,8 +139,8 @@ export default jsxRenderer(({ children, title, description, ogImage }, c) => { {speculationRules} {pageTitle} - - + + {/* Open Graph */} diff --git a/app/routes/admin/accounts.tsx b/app/routes/admin/accounts.tsx index d7c1c96..bc3d080 100644 --- a/app/routes/admin/accounts.tsx +++ b/app/routes/admin/accounts.tsx @@ -55,7 +55,7 @@ export default createRoute(async (c) => { {rows.length === 0 ? ( - +

No users match this search.

) : ( diff --git a/app/routes/admin/logs.tsx b/app/routes/admin/logs.tsx index caad639..a030d5c 100644 --- a/app/routes/admin/logs.tsx +++ b/app/routes/admin/logs.tsx @@ -48,7 +48,7 @@ export default createRoute(async (c) => { description="Browse delivery logs across every automation." actions={} /> - + }>
- +

Authentication Failed

{message} diff --git a/app/routes/auth/login.tsx b/app/routes/auth/login.tsx index 3e3585c..34299bc 100644 --- a/app/routes/auth/login.tsx +++ b/app/routes/auth/login.tsx @@ -15,6 +15,7 @@ import { Header } from "../../components/Layout/Header/index.js"; import { Container } from "../../components/Layout/Container/index.js"; import { Card } from "../../components/Card/index.js"; import { Alert } from "../../components/Alert/index.js"; +import { Button } from "../../components/Button/index.js"; import { Stack } from "../../components/Layout/Stack/index.js"; import { loginWrapper, @@ -22,7 +23,6 @@ import { formGroup, loginInput, loginLabel, - loginButton, } from "../../styles/pages/login.css.js"; export default createRoute(async (c) => { @@ -55,7 +55,7 @@ export default createRoute(async (c) => { }>
- +
@@ -80,9 +80,9 @@ export default createRoute(async (c) => { autofocus />
- +
diff --git a/app/routes/automations/new.tsx b/app/routes/automations/new.tsx index 6bfcfdf..3d5e7a3 100644 --- a/app/routes/automations/new.tsx +++ b/app/routes/automations/new.tsx @@ -56,7 +56,7 @@ export default createRoute(async (c) => { }> - + {
-
How it works

Three steps, any AT Protocol event.

Airglow subscribes to Jetstream, matches your filter, and runs the action you picked. @@ -157,7 +156,6 @@ export default createRoute(async (c) => {

-
Gallery

Automations people run

Duplicate any of these to your account in one click.

@@ -189,7 +187,6 @@ export default createRoute(async (c) => {
-
Built to run

Three guarantees, no surprises.

@@ -232,7 +229,6 @@ export default createRoute(async (c) => {
-
The ecosystem

Popular lexicons

Airglow works with any lexicon: Bluesky, community, or your own. Here are a few diff --git a/app/routes/lexicons/[nsid].tsx b/app/routes/lexicons/[nsid].tsx index 29e86b0..f790a21 100644 --- a/app/routes/lexicons/[nsid].tsx +++ b/app/routes/lexicons/[nsid].tsx @@ -80,7 +80,7 @@ export default createRoute(async (c) => { description={description} actions={

- {viewer && ( @@ -154,7 +154,7 @@ export default createRoute(async (c) => { {ownerHandle && (
) : ( - +

No automations are using this lexicon yet.

diff --git a/app/routes/og-image.tsx b/app/routes/og-image.tsx deleted file mode 100644 index c06da9d..0000000 --- a/app/routes/og-image.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { createRoute } from "honox/factory"; -import { raw } from "hono/html"; - -const ogStyles = raw(``); - -export default createRoute((c) => { - return c.html( - - - - - {ogStyles} - - -
- -

Airglow

-

Webhooks & Automations for the AT Protocol

- airglow.run -
- - , - ); -}); diff --git a/app/routes/settings/account.tsx b/app/routes/settings/account.tsx index 625fed3..1fb040b 100644 --- a/app/routes/settings/account.tsx +++ b/app/routes/settings/account.tsx @@ -30,7 +30,7 @@ export default createRoute((c) => { - +

Identity

diff --git a/app/routes/settings/logs.tsx b/app/routes/settings/logs.tsx index 3d85f71..85e3096 100644 --- a/app/routes/settings/logs.tsx +++ b/app/routes/settings/logs.tsx @@ -27,7 +27,7 @@ export default createRoute(async (c) => { - +

No automations yet.

@@ -65,7 +65,7 @@ export default createRoute(async (c) => { - + { - + diff --git a/app/routes/settings/secrets.tsx b/app/routes/settings/secrets.tsx index 8ee0bec..eb27390 100644 --- a/app/routes/settings/secrets.tsx +++ b/app/routes/settings/secrets.tsx @@ -19,7 +19,7 @@ export default createRoute(async (c) => { - +

Secrets are not configured on this instance.

@@ -47,7 +47,7 @@ export default createRoute(async (c) => { - + diff --git a/app/routes/settings/security.tsx b/app/routes/settings/security.tsx index 6be5e91..20ba629 100644 --- a/app/routes/settings/security.tsx +++ b/app/routes/settings/security.tsx @@ -89,7 +89,7 @@ export default createRoute(async (c) => { - + {events.length === 0 ? (

No activity yet.

@@ -125,7 +125,7 @@ export default createRoute(async (c) => { } @@ -63,12 +63,12 @@ export default createRoute(async (c) => { + } /> - + { + } @@ -62,12 +62,12 @@ export default createRoute(async (c) => { + } /> - + { {isOwner && (activity || rateLimitCounts) && (
{activity && ( - +

Recent activity @@ -261,7 +261,7 @@ export default createRoute(async (c) => { )} {rateLimitCounts && ( - +

Rate limits @@ -291,7 +291,7 @@ export default createRoute(async (c) => { )} - +
Lexicon
@@ -326,7 +326,7 @@ export default createRoute(async (c) => { {auto.conditions.length > 0 && ( - +

Conditions @@ -369,7 +369,7 @@ export default createRoute(async (c) => { (a) => actionTypeKey(a) === typeKey, ).length; return ( - + { + } @@ -71,7 +71,7 @@ export default createRoute(async (c) => { back={{ href: `/u/${viewer.handle}/${rkey}`, label: auto.name }} title={`Logs: ${auto.name}`} /> - + Midnight / Infrared / Sky / Glow / Aurora"] + S["Semantic tokens (colors.ts -> theme.css.ts vars)
bg, surface, text, accent, success, error..."] + B["Per-app brand layer (rationalized)
bsky, cosmik, sifa, tangled... + ColorKey union"] + SP["sprinkles.css.ts (atomic props)"] + C["Component + page CSS
app/components, app/styles/pages, island *.css.ts"] + OG["OG rasterizer (satori)
hard-coded hex mirror of light semantics"] + + P --> S + S --> SP + S --> C + B --> C + SP --> C + S -. "manually mirrored as hex" .-> OG +``` + +The dashed edge to OG is the one place values are copied by hand (satori can't parse +OKLCH) — flagged so the OG redesign updates it deliberately rather than letting it +drift. + +--- + +## Implementation Units + +Phased: Foundation (U1-U4) → Primitives (U5-U8) → Assets (U9-U10) → Page sweep +(U11-U16). Pages depend on foundation and primitives landing first. + +### Phase 1 — Foundation + +### U1. Self-host the brand typefaces + +- Goal: Replace system-fonts-only with self-hosted Overused Grotesk, LT Superior, + and Junicode, preserving FOUC behavior. (R1, R2) +- Dependencies: none +- Files: + - `public/fonts/` — add woff2 (site) and TTF/OTF (OG) for the three faces; + remove Inter once OG migrates (U10). + - `app/styles/tokens/typography.ts` — update both `fontFamily.sans` and + `fontFamily.ui` (brand faces first, then fallbacks); this is the source + `global.css.ts` re-exports. + - `app/styles/global.css.ts` — `@font-face` (`globalFontFace`), `font-display` + choice. The `html` font-family already reads `fontFamily.sans`. + - `app/routes/_renderer.tsx` — update inline critical font stack (line ~16); add + `` near the icon + link (~120). + - `vite.config.ts` / build — verify `public/fonts` is copied to `dist/fonts` for + the prod static handler (the OG loader expects `./dist/fonts`). + - Convention note: update the "// System font stacks — no external fonts loaded" + comment in `app/styles/tokens/typography.ts:1`. The font convention lives there + (and in user memory), not in `CLAUDE.md` — do not edit a non-existent CLAUDE.md + rule. +- Approach: First confirm a satori-loadable static format (TTF/OTF) exists for each + of the three faces; if one is missing, decide its OG substitution deliberately (see + Open Questions) rather than letting OG fall back silently. Use `font-display: swap` + (or `optional`). Subset to Latin to bound payload, but verify the subset covers real + content (handles, NSIDs, automation titles can carry non-ASCII glyphs). Keep three + sync points aligned: `typography.ts` (sans + ui), the inline critical stack in + `_renderer.tsx:16`, and the `@font-face` set. Do not touch the OG TTF loader yet + (U10 owns OG). +- Patterns to follow: existing inline-critical-CSS approach in `_renderer.tsx`; the + dev-CSS plugin in `vite.config.ts:29-66` already serves vanilla CSS in dev. +- Test scenarios: Test expectation: none -- font wiring is verified by build + + visual/network check (preload fires, woff2 served, no FOUC), not unit tests. +- Verification: `vp build` succeeds; dev and prod render the brand faces; preload + links present; no flash of fallback on reload; OG route still renders (still on + Inter TTF until U10). + +### U2. Primitive color scale layer + derived theming + +- Goal: Introduce the five Figma scales as primitives and re-point semantic tokens + to them; remove the hand-maintained dark mirror. (R4, R5) +- Dependencies: none +- Files: + - `app/styles/tokens/palette.ts` (new) — Midnight (100-1000), Infrared, Sky, Glow, + Aurora (100-700) as OKLCH ramps. + - `app/styles/tokens/colors.ts` — rewrite `lightColors`/`darkColors` to map + semantic names onto palette steps. + - `app/styles/theme.css.ts` — keep `vars` shape; derive the + `:root:not([data-theme])` block (`:84-141`) for colors, shadows, and focus.ring + (see Key Decisions). + - `app/styles/tokens/index.ts` — export palette. + - `app/routes/_renderer.tsx` — update the inline critical-CSS bg/text OKLCH + (`:16-19`) and the `theme-color` meta (`:125-126`) to the new bg values for both + themes; these are hard-coded copies of the bg/text tokens this unit changes. + - `app/styles/global.css.ts` — wrap the universal color/bg/border/shadow transition + (`:91-95`) in `@media (prefers-reduced-motion: no-preference)` while touching the + token layer. + - `lib/og/automation.ts` — update the `COLORS` hex mirror (`:90-99`) to the new + light-token equivalents (satori needs hex), so OG does not drift during the gap + before U10. +- Approach: Pull exact step values from the Figma Colors node (`152:457`) via + `get_variable_defs`. Semantic mapping decisions (which step is `bg`, `accent`, + etc.) follow the brand guideline usage. Check WCAG AA contrast (4.5:1 body, 3:1 + large text) for text-on-surface and accent-on-bg before committing the mapping. +- Patterns to follow: existing `createGlobalThemeContract` / `createGlobalTheme` + structure in `theme.css.ts`. +- Test scenarios: Test expectation: none -- token values verified by visual review + in both themes; build proves the contract still resolves. +- Verification: `vp check` passes; every semantic var (color, shadow, focus) + resolves in light, dark, and first-paint prefers-dark; theme toggle works; the + inline critical CSS, `theme-color`, and OG `COLORS` all reflect the new bg/text + values; the dark block no longer lists vars by hand. + +### U3. Typography scale tokens + +- Goal: Express the Figma type scale (subheading, heading xs-2xl, body + regular/semibold/bold xs-xl, mono medium/bold xs-xl). (R3) +- Dependencies: U1 (fonts available) +- Files: + - `app/styles/tokens/typography.ts` — `fontSize`, `fontWeight`, `lineHeight`, + `letterSpacing` to match Figma steps. + - `app/styles/global.css.ts` — heading element sizes (`h1`-`h4`) mapped to the new + scale. + - `app/styles/sprinkles.css.ts` — confirm `fontSize`/`fontWeight`/`lineHeight` + atoms still cover usage. +- Approach: Read exact numbers from the Figma Headings/Body/Mono nodes + (`152:311`, `152:346`, `152:415`). Confirm Junicode's role (display/all-headings) + before mapping `h*`. +- Patterns to follow: current `global.css.ts` heading rules. +- Test scenarios: Test expectation: none -- scale verified visually. +- Verification: headings/body/mono render at the spec sizes/weights in both themes. + +### U4. Rationalize the per-app brand palette + +- Goal: Audit and align the per-app colors to the new scales; prune unused. (R6) +- Dependencies: U2 (scales exist) +- Files: + - `app/styles/tokens/colors.ts` — per-app values (base + `*Subtle`). + - `app/styles/theme.css.ts` — contract names + derived dark mirror entries. + - `lib/automations/follow-targets.ts` — `ColorKey` union (prune dropped keys). + - `lib/automations/action-catalogue.ts` — the `catId` union (`:33`) and `colorKey` + propagation; `data-cat` is rendered from `info.colorKey ?? info.catId` + (`app/components/ActionHeader/index.tsx:35`), so catIds are the other half of the + value space. + - `app/styles/action-header.css.ts` — `data-cat` selectors (primary consumer). + - `app/islands/AutomationForm.css.ts` — `catDot` category-level keys. + - Editors referencing colorKeys: `app/islands/action-editors/*` (semble-save, + bsky-post, kipclip-annotation, calendar-rsvp) only if keys are renamed. +- Approach: Decide which per-app colors survive (keep ones tied to live action + types; drop orphans). Align retained hues' lightness/chroma to the new scales so + tiles read on-brand. Keep keys string-matched via `data-cat`; reconcile both the + `ColorKey` union and the `catId` union so every value that can reach `data-cat` has + a matching selector. Note the `social` catId currently has no selector (it always + overrides to a per-target colorKey) — preserve that invariant rather than "fixing" + it. +- Patterns to follow: the `ColorKey`-to-`data-cat` contract noted in + `lib/automations/follow-targets.ts`. +- Test scenarios: + - Happy path: every action type still resolves to a defined tile color (no + unstyled `data-cat`). + - Edge case: a pruned key has no remaining references in editors/catalogue. +- Verification: `vp check` passes; action-header tiles and cat dots render correct + colors for all current action types in both themes. + +### Phase 2 — Primitives + +### U5. Restyle Button to the Figma matrix + +- Goal: Button implements primary/secondary/tertiary/link × default/hover/disabled + × lg/sm. (R7, R10) +- Dependencies: U2, U3 +- Files: + - `app/components/Button/index.tsx`, `app/components/Button/styles.css.ts`, + `app/components/Button/index.test.tsx` (new). + - Call sites using old variant names (`ghost`, `danger`) and unsized call sites + across `app/routes`, `app/islands`, `app/components`. +- Approach: Rename `styleVariants` to the Figma set; keep the ``-when-`href` + behavior. Two migration hazards beyond the rename: (1) the existing `md` size has + no Figma equivalent and is the current default prop — collapse it into `lg` and set + the default to `lg`, or the ~11 unsized call sites break. (2) `danger` has no Figma + type — decide before migrating whether to extend the matrix with a `destructive` + type or keep it as a local composition over `tertiary` (`AutomationToolbar` + composes `ghost`+`danger` today; see Open Questions). Map removed variants at call + sites via a typed old->new map so a missed migration is a compile error, not a + silent remap to a wrong-but-valid variant. Confirm `:focus-visible` is visible on + all four variant backgrounds in both themes (the component has no focus rule today; + it inherits the global ring) — add per-variant focus styles if the global ring + fails on any. Disabled state per Figma; respect the pre-hydration disabled + convention for island-hosted buttons. +- Patterns to follow: existing `styleVariants` structure in `Button/styles.css.ts`. +- Test scenarios: + - Happy path: renders each type/size; renders `` with `href`, `