Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: improve lexicon resolver

Hugo c3fc9eda 889c01a6

+2736 -257
+8
app/client.ts
··· 1 1 import { createClient } from "honox/client"; 2 + import "./styles/reset.css.js"; 3 + import "./styles/theme.css.js"; 4 + import "./styles/global.css.js"; 5 + import "./styles/sprinkles.css.js"; 6 + import "./styles/utilities.css.js"; 7 + import "./styles/components.css.js"; 8 + import "./styles/pages/landing.css.js"; 9 + import "./styles/pages/login.css.js"; 2 10 3 11 createClient();
+16
app/components/Alert/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + export function Alert({ 5 + variant: v = "info", 6 + children, 7 + }: { 8 + variant?: keyof typeof s.variant; 9 + children: Child; 10 + }) { 11 + return ( 12 + <div role="alert" class={s.variant[v]}> 13 + {children} 14 + </div> 15 + ); 16 + }
+49
app/components/Alert/styles.css.ts
··· 1 + import { styleVariants, style } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontSize } from "../../styles/tokens/typography.ts"; 5 + import { radii } from "../../styles/tokens/radii.ts"; 6 + 7 + const base = style({ 8 + paddingBlock: space[3], 9 + paddingInline: space[4], 10 + borderRadius: radii.md, 11 + fontSize: fontSize.sm, 12 + borderInlineStart: "3px solid", 13 + boxShadow: vars.shadow.highlight, 14 + }); 15 + 16 + export const variant = styleVariants({ 17 + error: [ 18 + base, 19 + { 20 + backgroundColor: vars.color.errorSubtle, 21 + color: vars.color.error, 22 + borderColor: vars.color.error, 23 + }, 24 + ], 25 + success: [ 26 + base, 27 + { 28 + backgroundColor: vars.color.successSubtle, 29 + color: vars.color.success, 30 + borderColor: vars.color.success, 31 + }, 32 + ], 33 + warning: [ 34 + base, 35 + { 36 + backgroundColor: vars.color.warningSubtle, 37 + color: vars.color.warning, 38 + borderColor: vars.color.warning, 39 + }, 40 + ], 41 + info: [ 42 + base, 43 + { 44 + backgroundColor: vars.color.accentSubtle, 45 + color: vars.color.accent, 46 + borderColor: vars.color.accent, 47 + }, 48 + ], 49 + });
+12
app/components/Badge/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + export function Badge({ 5 + variant: v = "neutral", 6 + children, 7 + }: { 8 + variant?: keyof typeof s.variant; 9 + children: Child; 10 + }) { 11 + return <span class={s.variant[v]}>{children}</span>; 12 + }
+55
app/components/Badge/styles.css.ts
··· 1 + import { styleVariants, style } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../../styles/tokens/typography.ts"; 5 + import { radii } from "../../styles/tokens/radii.ts"; 6 + 7 + const base = style({ 8 + display: "inline-flex", 9 + alignItems: "center", 10 + paddingBlock: "2px", 11 + paddingInline: space[2], 12 + borderRadius: radii.full, 13 + fontSize: fontSize.xs, 14 + fontWeight: fontWeight.medium, 15 + lineHeight: 1.5, 16 + whiteSpace: "nowrap", 17 + }); 18 + 19 + export const variant = styleVariants({ 20 + success: [ 21 + base, 22 + { 23 + backgroundColor: vars.color.successSubtle, 24 + color: vars.color.success, 25 + }, 26 + ], 27 + warning: [ 28 + base, 29 + { 30 + backgroundColor: vars.color.warningSubtle, 31 + color: vars.color.warning, 32 + }, 33 + ], 34 + error: [ 35 + base, 36 + { 37 + backgroundColor: vars.color.errorSubtle, 38 + color: vars.color.error, 39 + }, 40 + ], 41 + neutral: [ 42 + base, 43 + { 44 + backgroundColor: vars.color.surfaceHover, 45 + color: vars.color.textSecondary, 46 + }, 47 + ], 48 + accent: [ 49 + base, 50 + { 51 + backgroundColor: vars.color.accentSubtle, 52 + color: vars.color.accent, 53 + }, 54 + ], 55 + });
+37
app/components/Button/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + type ButtonProps = { 5 + variant?: keyof typeof s.variant; 6 + size?: keyof typeof s.size; 7 + type?: "button" | "submit" | "reset"; 8 + disabled?: boolean; 9 + href?: string; 10 + children: Child; 11 + [key: string]: unknown; 12 + }; 13 + 14 + export function Button({ 15 + variant: v = "primary", 16 + size: sz = "md", 17 + type = "button", 18 + href, 19 + children, 20 + ...rest 21 + }: ButtonProps) { 22 + const className = `${s.variant[v]} ${s.size[sz]}`; 23 + 24 + if (href) { 25 + return ( 26 + <a href={href} class={className} {...rest}> 27 + {children} 28 + </a> 29 + ); 30 + } 31 + 32 + return ( 33 + <button type={type} class={className} {...rest}> 34 + {children} 35 + </button> 36 + ); 37 + }
+105
app/components/Button/styles.css.ts
··· 1 + import { style, styleVariants } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../../styles/tokens/typography.ts"; 5 + import { radii } from "../../styles/tokens/radii.ts"; 6 + 7 + const base = style({ 8 + display: "inline-flex", 9 + alignItems: "center", 10 + justifyContent: "center", 11 + gap: space[2], 12 + borderRadius: radii.md, 13 + fontWeight: fontWeight.medium, 14 + cursor: "pointer", 15 + border: "1px solid transparent", 16 + textDecoration: "none", 17 + whiteSpace: "nowrap", 18 + lineHeight: 1, 19 + selectors: { 20 + "&:disabled, &[aria-disabled='true']": { 21 + opacity: 0.5, 22 + cursor: "not-allowed", 23 + }, 24 + }, 25 + }); 26 + 27 + export const variant = styleVariants({ 28 + primary: [ 29 + base, 30 + { 31 + backgroundColor: vars.color.accent, 32 + color: vars.color.accentText, 33 + boxShadow: `${vars.shadow.highlight}, ${vars.shadow.sm}`, 34 + ":hover": { 35 + backgroundColor: vars.color.accentHover, 36 + }, 37 + ":active": { 38 + backgroundColor: vars.color.accentActive, 39 + boxShadow: "none", 40 + }, 41 + }, 42 + ], 43 + secondary: [ 44 + base, 45 + { 46 + backgroundColor: vars.color.surface, 47 + color: vars.color.text, 48 + borderColor: vars.color.border, 49 + boxShadow: `${vars.shadow.highlight}, ${vars.shadow.sm}`, 50 + ":hover": { 51 + backgroundColor: vars.color.surfaceHover, 52 + borderColor: vars.color.text, 53 + }, 54 + ":active": { 55 + boxShadow: "none", 56 + }, 57 + }, 58 + ], 59 + ghost: [ 60 + base, 61 + { 62 + backgroundColor: "transparent", 63 + color: vars.color.textSecondary, 64 + ":hover": { 65 + backgroundColor: vars.color.surfaceHover, 66 + color: vars.color.text, 67 + }, 68 + }, 69 + ], 70 + danger: [ 71 + base, 72 + { 73 + backgroundColor: vars.color.error, 74 + color: "white", 75 + boxShadow: `${vars.shadow.highlight}, ${vars.shadow.sm}`, 76 + ":hover": { 77 + opacity: 0.9, 78 + }, 79 + ":active": { 80 + boxShadow: "none", 81 + }, 82 + }, 83 + ], 84 + }); 85 + 86 + export const size = styleVariants({ 87 + sm: { 88 + fontSize: fontSize.sm, 89 + paddingBlock: space[1], 90 + paddingInline: space[4], 91 + minBlockSize: "32px", 92 + }, 93 + md: { 94 + fontSize: fontSize.base, 95 + paddingBlock: space[2], 96 + paddingInline: space[5], 97 + minBlockSize: "40px", 98 + }, 99 + lg: { 100 + fontSize: fontSize.md, 101 + paddingBlock: space[3], 102 + paddingInline: space[6], 103 + minBlockSize: "48px", 104 + }, 105 + });
+12
app/components/Card/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + export function Card({ 5 + variant: v = "flat", 6 + children, 7 + }: { 8 + variant?: keyof typeof s.variant; 9 + children: Child; 10 + }) { 11 + return <div class={s.variant[v]}>{children}</div>; 12 + }
+34
app/components/Card/styles.css.ts
··· 1 + import { styleVariants } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { radii } from "../../styles/tokens/radii.ts"; 5 + 6 + const baseStyles = { 7 + backgroundColor: vars.color.surface, 8 + borderRadius: radii.lg, 9 + paddingBlock: space[5], 10 + paddingInline: space[5], 11 + } as const; 12 + 13 + export const variant = styleVariants({ 14 + flat: { 15 + ...baseStyles, 16 + border: `1px solid ${vars.color.border}`, 17 + boxShadow: vars.shadow.highlight, 18 + }, 19 + raised: { 20 + ...baseStyles, 21 + border: `1px solid ${vars.color.borderSubtle}`, 22 + boxShadow: `${vars.shadow.highlight}, ${vars.shadow.md}`, 23 + }, 24 + interactive: { 25 + ...baseStyles, 26 + border: `1px solid ${vars.color.border}`, 27 + boxShadow: vars.shadow.highlight, 28 + cursor: "pointer", 29 + ":hover": { 30 + borderColor: vars.color.accent, 31 + boxShadow: `${vars.shadow.highlight}, ${vars.shadow.md}`, 32 + }, 33 + }, 34 + });
+14
app/components/CodeBlock/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + export function CodeBlock({ children }: { children: Child }) { 5 + return ( 6 + <pre class={s.codeBlock}> 7 + <code>{children}</code> 8 + </pre> 9 + ); 10 + } 11 + 12 + export function InlineCode({ children }: { children: Child }) { 13 + return <code class={s.inlineCode}>{children}</code>; 14 + }
+27
app/components/CodeBlock/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontFamily, fontSize } from "../../styles/tokens/typography.ts"; 5 + import { radii } from "../../styles/tokens/radii.ts"; 6 + 7 + export const codeBlock = style({ 8 + fontFamily: fontFamily.mono, 9 + fontSize: fontSize.sm, 10 + backgroundColor: vars.color.code, 11 + color: vars.color.text, 12 + paddingBlock: space[3], 13 + paddingInline: space[4], 14 + borderRadius: radii.md, 15 + overflowX: "auto", 16 + whiteSpace: "pre", 17 + border: `1px solid ${vars.color.borderSubtle}`, 18 + }); 19 + 20 + export const inlineCode = style({ 21 + fontFamily: fontFamily.mono, 22 + fontSize: "0.875em", 23 + backgroundColor: vars.color.code, 24 + paddingInline: "6px", 25 + paddingBlock: "2px", 26 + borderRadius: radii.sm, 27 + });
+6
app/components/DescriptionList/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import { dl } from "./styles.css.ts"; 3 + 4 + export function DescriptionList({ children }: { children: Child }) { 5 + return <dl class={dl}>{children}</dl>; 6 + }
+39
app/components/DescriptionList/styles.css.ts
··· 1 + import { style, globalStyle } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../../styles/tokens/typography.ts"; 5 + import { mq } from "../../styles/utils.ts"; 6 + 7 + export const dl = style({ 8 + display: "grid", 9 + gap: space[4], 10 + gridTemplateColumns: "1fr", 11 + "@media": { 12 + [mq.md]: { 13 + gridTemplateColumns: "minmax(140px, auto) 1fr", 14 + gap: `${space[3]} ${space[5]}`, 15 + }, 16 + }, 17 + }); 18 + 19 + globalStyle(`${dl} dt`, { 20 + fontSize: fontSize.sm, 21 + fontWeight: fontWeight.medium, 22 + color: vars.color.textSecondary, 23 + }); 24 + 25 + globalStyle(`${dl} dd`, { 26 + color: vars.color.text, 27 + wordBreak: "break-word", 28 + }); 29 + 30 + // On mobile, group dt+dd pairs with spacing between groups 31 + globalStyle(`${dl} dt:not(:first-child)`, { 32 + "@media": { 33 + [`screen and (max-width: 767px)`]: { 34 + marginBlockStart: space[2], 35 + paddingBlockStart: space[4], 36 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 37 + }, 38 + }, 39 + });
+37
app/components/Input/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + type InputProps = { 5 + label?: string; 6 + hint?: string; 7 + error?: string; 8 + id?: string; 9 + [key: string]: unknown; 10 + }; 11 + 12 + export function Input({ 13 + label: labelText, 14 + hint, 15 + error, 16 + id, 17 + ...rest 18 + }: InputProps) { 19 + const inputId = id || (labelText ? labelText.toLowerCase().replace(/\s+/g, "-") : undefined); 20 + 21 + return ( 22 + <div class={s.wrapper}> 23 + {labelText && ( 24 + <label class={s.label} for={inputId}> 25 + {labelText} 26 + </label> 27 + )} 28 + <input 29 + id={inputId} 30 + class={`${s.input}${error ? ` ${s.inputError}` : ""}`} 31 + {...rest} 32 + /> 33 + {hint && !error && <span class={s.hint}>{hint}</span>} 34 + {error && <span class={s.errorText}>{error}</span>} 35 + </div> 36 + ); 37 + }
+60
app/components/Input/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontSize } from "../../styles/tokens/typography.ts"; 5 + import { radii } from "../../styles/tokens/radii.ts"; 6 + 7 + export const wrapper = style({ 8 + display: "flex", 9 + flexDirection: "column", 10 + gap: space[1], 11 + }); 12 + 13 + export const label = style({ 14 + fontSize: fontSize.sm, 15 + fontWeight: "500", 16 + color: vars.color.text, 17 + }); 18 + 19 + export const input = style({ 20 + width: "100%", 21 + paddingBlock: space[2], 22 + paddingInline: space[3], 23 + fontSize: fontSize.base, 24 + color: vars.color.text, 25 + backgroundColor: vars.color.surface, 26 + border: `1px solid ${vars.color.border}`, 27 + borderRadius: radii.md, 28 + boxShadow: vars.shadow.highlight, 29 + "::placeholder": { 30 + color: vars.color.textMuted, 31 + }, 32 + ":focus": { 33 + borderColor: vars.color.accent, 34 + outline: "none", 35 + boxShadow: `${vars.shadow.highlight}, 0 0 0 3px ${vars.focus.ring}`, 36 + }, 37 + selectors: { 38 + "&:disabled": { 39 + opacity: 0.5, 40 + cursor: "not-allowed", 41 + }, 42 + }, 43 + }); 44 + 45 + export const inputError = style({ 46 + borderColor: vars.color.error, 47 + ":focus": { 48 + boxShadow: `0 0 0 3px oklch(0.55 0.22 25 / 0.2)`, 49 + }, 50 + }); 51 + 52 + export const hint = style({ 53 + fontSize: fontSize.xs, 54 + color: vars.color.textMuted, 55 + }); 56 + 57 + export const errorText = style({ 58 + fontSize: fontSize.xs, 59 + color: vars.color.error, 60 + });
+17
app/components/Layout/AppShell/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + export function AppShell({ 5 + header, 6 + children, 7 + }: { 8 + header: Child; 9 + children: Child; 10 + }) { 11 + return ( 12 + <div class={s.shell}> 13 + {header} 14 + <main class={s.main}>{children}</main> 15 + </div> 16 + ); 17 + }
+12
app/components/Layout/AppShell/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { space } from "../../../styles/tokens/spacing.ts"; 3 + 4 + export const shell = style({ 5 + display: "grid", 6 + gridTemplateRows: "auto 1fr", 7 + minBlockSize: "100dvh", 8 + }); 9 + 10 + export const main = style({ 11 + paddingBlock: space[6], 12 + });
+15
app/components/Layout/Cluster/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import type { space } from "../../../styles/tokens/spacing.ts"; 3 + import { cluster } from "./styles.css.ts"; 4 + 5 + type SpaceKey = keyof typeof space; 6 + 7 + export function Cluster({ 8 + gap = 3, 9 + children, 10 + }: { 11 + gap?: SpaceKey; 12 + children: Child; 13 + }) { 14 + return <div class={cluster[gap]}>{children}</div>; 15 + }
+9
app/components/Layout/Cluster/styles.css.ts
··· 1 + import { styleVariants } from "@vanilla-extract/css"; 2 + import { space } from "../../../styles/tokens/spacing.ts"; 3 + 4 + export const cluster = styleVariants(space, (gap) => ({ 5 + display: "flex", 6 + flexWrap: "wrap", 7 + alignItems: "center", 8 + gap, 9 + }));
+6
app/components/Layout/Container/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import { container } from "./styles.css.ts"; 3 + 4 + export function Container({ children }: { children: Child }) { 5 + return <div class={container}>{children}</div>; 6 + }
+15
app/components/Layout/Container/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { space } from "../../../styles/tokens/spacing.ts"; 3 + import { mq } from "../../../styles/utils.ts"; 4 + 5 + export const container = style({ 6 + maxInlineSize: "72rem", 7 + marginInline: "auto", 8 + paddingInline: space[4], 9 + inlineSize: "100%", 10 + "@media": { 11 + [mq.md]: { 12 + paddingInline: space[6], 13 + }, 14 + }, 15 + });
+31
app/components/Layout/Header/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + export function Header({ 5 + user, 6 + actions, 7 + }: { 8 + user?: { handle: string } | null; 9 + actions?: Child; 10 + }) { 11 + return ( 12 + <header class={s.header}> 13 + <div class={s.inner}> 14 + <a href="/" class={s.brand}> 15 + Airglow 16 + </a> 17 + <div class={s.nav}> 18 + {user && ( 19 + <> 20 + <a href="/dashboard" class={s.navLink}> 21 + Dashboard 22 + </a> 23 + <span class={s.userInfo}>@{user.handle}</span> 24 + </> 25 + )} 26 + {actions} 27 + </div> 28 + </div> 29 + </header> 30 + ); 31 + }
+54
app/components/Layout/Header/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../../../styles/theme.css.ts"; 3 + import { space } from "../../../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../../../styles/tokens/typography.ts"; 5 + 6 + export const header = style({ 7 + borderBlockEnd: `1px solid ${vars.color.border}`, 8 + backgroundColor: vars.color.surface, 9 + boxShadow: vars.shadow.sm, 10 + }); 11 + 12 + export const inner = style({ 13 + display: "flex", 14 + alignItems: "center", 15 + justifyContent: "space-between", 16 + maxInlineSize: "72rem", 17 + marginInline: "auto", 18 + paddingInline: space[4], 19 + paddingBlock: space[3], 20 + }); 21 + 22 + export const brand = style({ 23 + fontSize: fontSize.lg, 24 + fontWeight: fontWeight.semibold, 25 + color: vars.color.heading, 26 + textDecoration: "none", 27 + ":hover": { 28 + color: vars.color.heading, 29 + }, 30 + }); 31 + 32 + export const nav = style({ 33 + display: "flex", 34 + alignItems: "center", 35 + gap: space[4], 36 + }); 37 + 38 + export const navLink = style({ 39 + color: vars.color.textSecondary, 40 + textDecoration: "none", 41 + fontSize: fontSize.sm, 42 + fontWeight: fontWeight.medium, 43 + ":hover": { 44 + color: vars.color.text, 45 + }, 46 + }); 47 + 48 + export const userInfo = style({ 49 + display: "flex", 50 + alignItems: "center", 51 + gap: space[3], 52 + fontSize: fontSize.sm, 53 + color: vars.color.textSecondary, 54 + });
+22
app/components/Layout/PageHeader/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + export function PageHeader({ 5 + title, 6 + description: desc, 7 + actions, 8 + }: { 9 + title: string; 10 + description?: string; 11 + actions?: Child; 12 + }) { 13 + return ( 14 + <div class={s.wrapper}> 15 + <div class={s.topRow}> 16 + <h1>{title}</h1> 17 + {actions} 18 + </div> 19 + {desc && <p class={s.description}>{desc}</p>} 20 + </div> 21 + ); 22 + }
+25
app/components/Layout/PageHeader/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../../../styles/theme.css.ts"; 3 + import { space } from "../../../styles/tokens/spacing.ts"; 4 + 5 + export const wrapper = style({ 6 + display: "flex", 7 + flexDirection: "column", 8 + gap: space[2], 9 + paddingBlockEnd: space[6], 10 + borderBlockEnd: `1px solid ${vars.color.borderSubtle}`, 11 + marginBlockEnd: space[6], 12 + }); 13 + 14 + export const topRow = style({ 15 + display: "flex", 16 + alignItems: "center", 17 + justifyContent: "space-between", 18 + flexWrap: "wrap", 19 + gap: space[3], 20 + }); 21 + 22 + export const description = style({ 23 + color: vars.color.textSecondary, 24 + marginBlockEnd: 0, 25 + });
+15
app/components/Layout/Stack/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import type { space } from "../../../styles/tokens/spacing.ts"; 3 + import { stack } from "./styles.css.ts"; 4 + 5 + type SpaceKey = keyof typeof space; 6 + 7 + export function Stack({ 8 + gap = 4, 9 + children, 10 + }: { 11 + gap?: SpaceKey; 12 + children: Child; 13 + }) { 14 + return <div class={stack[gap]}>{children}</div>; 15 + }
+8
app/components/Layout/Stack/styles.css.ts
··· 1 + import { styleVariants } from "@vanilla-extract/css"; 2 + import { space } from "../../../styles/tokens/spacing.ts"; 3 + 4 + export const stack = styleVariants(space, (gap) => ({ 5 + display: "flex", 6 + flexDirection: "column", 7 + gap, 8 + }));
+26
app/components/Select/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + type SelectProps = { 5 + label?: string; 6 + id?: string; 7 + children: Child; 8 + [key: string]: unknown; 9 + }; 10 + 11 + export function Select({ label: labelText, id, children, ...rest }: SelectProps) { 12 + const selectId = id || (labelText ? labelText.toLowerCase().replace(/\s+/g, "-") : undefined); 13 + 14 + return ( 15 + <div class={s.wrapper}> 16 + {labelText && ( 17 + <label class={s.label} for={selectId}> 18 + {labelText} 19 + </label> 20 + )} 21 + <select id={selectId} class={s.select} {...rest}> 22 + {children} 23 + </select> 24 + </div> 25 + ); 26 + }
+40
app/components/Select/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontSize } from "../../styles/tokens/typography.ts"; 5 + import { radii } from "../../styles/tokens/radii.ts"; 6 + 7 + export const wrapper = style({ 8 + display: "flex", 9 + flexDirection: "column", 10 + gap: space[1], 11 + }); 12 + 13 + export const label = style({ 14 + fontSize: fontSize.sm, 15 + fontWeight: "500", 16 + color: vars.color.text, 17 + }); 18 + 19 + export const select = style({ 20 + width: "100%", 21 + paddingBlock: space[2], 22 + paddingInline: space[3], 23 + paddingInlineEnd: space[7], 24 + fontSize: fontSize.base, 25 + color: vars.color.text, 26 + backgroundColor: vars.color.surface, 27 + border: `1px solid ${vars.color.border}`, 28 + borderRadius: radii.md, 29 + appearance: "none", 30 + backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`, 31 + backgroundRepeat: "no-repeat", 32 + backgroundPosition: "right 12px center", 33 + cursor: "pointer", 34 + boxShadow: vars.shadow.highlight, 35 + ":focus": { 36 + borderColor: vars.color.accent, 37 + outline: "none", 38 + boxShadow: `${vars.shadow.highlight}, 0 0 0 3px ${vars.focus.ring}`, 39 + }, 40 + });
+10
app/components/Table/index.tsx
··· 1 + import type { Child } from "hono/jsx"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + export function Table({ children }: { children: Child }) { 5 + return ( 6 + <div class={s.wrapper}> 7 + <table class={s.table}>{children}</table> 8 + </div> 9 + ); 10 + }
+41
app/components/Table/styles.css.ts
··· 1 + import { style, globalStyle } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontSize } from "../../styles/tokens/typography.ts"; 5 + 6 + export const wrapper = style({ 7 + overflowX: "auto", 8 + borderRadius: "8px", 9 + border: `1px solid ${vars.color.border}`, 10 + boxShadow: `${vars.shadow.highlight}, ${vars.shadow.sm}`, 11 + }); 12 + 13 + export const table = style({ 14 + width: "100%", 15 + fontSize: fontSize.sm, 16 + textAlign: "start", 17 + }); 18 + 19 + globalStyle(`${table} thead`, { 20 + backgroundColor: vars.color.surfaceHover, 21 + }); 22 + 23 + globalStyle(`${table} th`, { 24 + paddingBlock: space[3], 25 + paddingInline: space[4], 26 + fontWeight: 600, 27 + color: vars.color.heading, 28 + textAlign: "start", 29 + whiteSpace: "nowrap", 30 + }); 31 + 32 + globalStyle(`${table} td`, { 33 + paddingBlock: space[3], 34 + paddingInline: space[4], 35 + color: vars.color.text, 36 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 37 + }); 38 + 39 + globalStyle(`${table} tbody tr:hover`, { 40 + backgroundColor: vars.color.surfaceHover, 41 + });
+121
app/islands/DeliveryLog.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../styles/theme.css.ts"; 3 + import { space } from "../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../styles/tokens/typography.ts"; 5 + import { radii } from "../styles/tokens/radii.ts"; 6 + 7 + export const wrapper = style({ 8 + display: "flex", 9 + flexDirection: "column", 10 + gap: space[4], 11 + }); 12 + 13 + export const actions = style({ 14 + display: "flex", 15 + gap: space[2], 16 + alignItems: "center", 17 + flexWrap: "wrap", 18 + }); 19 + 20 + const btnBase = { 21 + display: "inline-flex", 22 + alignItems: "center", 23 + justifyContent: "center", 24 + paddingBlock: space[1], 25 + paddingInline: space[3], 26 + fontSize: fontSize.sm, 27 + fontWeight: fontWeight.medium, 28 + borderRadius: radii.md, 29 + cursor: "pointer", 30 + border: `1px solid ${vars.color.border}`, 31 + minBlockSize: "32px", 32 + lineHeight: 1, 33 + selectors: { 34 + "&:disabled": { 35 + opacity: 0.5, 36 + cursor: "not-allowed", 37 + }, 38 + }, 39 + } as const; 40 + 41 + export const toggleBtn = style({ 42 + ...btnBase, 43 + color: vars.color.text, 44 + backgroundColor: "transparent", 45 + ":hover": { 46 + backgroundColor: vars.color.surfaceHover, 47 + borderColor: vars.color.text, 48 + }, 49 + }); 50 + 51 + export const deleteBtn = style({ 52 + ...btnBase, 53 + color: vars.color.error, 54 + backgroundColor: "transparent", 55 + borderColor: vars.color.border, 56 + ":hover": { 57 + backgroundColor: vars.color.errorSubtle, 58 + borderColor: vars.color.error, 59 + }, 60 + }); 61 + 62 + export const refreshBtn = style({ 63 + ...btnBase, 64 + color: vars.color.textSecondary, 65 + backgroundColor: "transparent", 66 + ":hover": { 67 + backgroundColor: vars.color.surfaceHover, 68 + color: vars.color.text, 69 + }, 70 + }); 71 + 72 + export const alertError = style({ 73 + paddingBlock: space[3], 74 + paddingInline: space[4], 75 + borderRadius: radii.md, 76 + fontSize: fontSize.sm, 77 + borderInlineStart: "3px solid", 78 + backgroundColor: vars.color.errorSubtle, 79 + color: vars.color.error, 80 + borderColor: vars.color.error, 81 + }); 82 + 83 + export const logsHeader = style({ 84 + display: "flex", 85 + alignItems: "center", 86 + justifyContent: "space-between", 87 + }); 88 + 89 + export const emptyState = style({ 90 + color: vars.color.textMuted, 91 + fontSize: fontSize.sm, 92 + }); 93 + 94 + export const tableWrapper = style({ 95 + overflowX: "auto", 96 + borderRadius: radii.md, 97 + border: `1px solid ${vars.color.border}`, 98 + }); 99 + 100 + export const table = style({ 101 + width: "100%", 102 + fontSize: fontSize.sm, 103 + textAlign: "start", 104 + }); 105 + 106 + export const th = style({ 107 + paddingBlock: space[3], 108 + paddingInline: space[4], 109 + fontWeight: fontWeight.semibold, 110 + color: vars.color.heading, 111 + textAlign: "start", 112 + whiteSpace: "nowrap", 113 + backgroundColor: vars.color.surfaceHover, 114 + }); 115 + 116 + export const td = style({ 117 + paddingBlock: space[3], 118 + paddingInline: space[4], 119 + color: vars.color.text, 120 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 121 + });
+46 -31
app/islands/DeliveryLog.tsx
··· 1 1 import { useState, useCallback } from "hono/jsx"; 2 + import * as s from "./DeliveryLog.css.ts"; 2 3 3 4 type LogEntry = { 4 5 id: number; ··· 77 78 }, [rkey]); 78 79 79 80 return ( 80 - <div> 81 - <p> 82 - <button type="button" onClick={toggleActive} disabled={loading}> 81 + <div class={s.wrapper}> 82 + <div class={s.actions}> 83 + <button 84 + type="button" 85 + class={s.toggleBtn} 86 + onClick={toggleActive} 87 + disabled={loading} 88 + > 83 89 {isActive ? "Deactivate" : "Activate"} 84 - </button>{" "} 85 - <button type="button" onClick={handleDelete} disabled={loading}> 90 + </button> 91 + <button 92 + type="button" 93 + class={s.deleteBtn} 94 + onClick={handleDelete} 95 + disabled={loading} 96 + > 86 97 Delete 87 98 </button> 88 - </p> 99 + </div> 89 100 90 - {error && <p>{error}</p>} 101 + {error && <div class={s.alertError}>{error}</div>} 91 102 92 - <h3> 93 - Delivery Logs{" "} 94 - <button type="button" onClick={refreshLogs}> 103 + <div class={s.logsHeader}> 104 + <h3>Delivery Logs</h3> 105 + <button type="button" class={s.refreshBtn} onClick={refreshLogs}> 95 106 Refresh 96 107 </button> 97 - </h3> 108 + </div> 98 109 99 110 {logs.length === 0 ? ( 100 - <p>No deliveries yet.</p> 111 + <p class={s.emptyState}>No deliveries yet.</p> 101 112 ) : ( 102 - <table> 103 - <thead> 104 - <tr> 105 - <th>Time</th> 106 - <th>Status</th> 107 - <th>Attempt</th> 108 - <th>Error</th> 109 - </tr> 110 - </thead> 111 - <tbody> 112 - {logs.map((log) => ( 113 - <tr key={log.id}> 114 - <td>{new Date(log.createdAt).toLocaleString()}</td> 115 - <td>{log.statusCode ?? "—"}</td> 116 - <td>{log.attempt}</td> 117 - <td>{log.error || "—"}</td> 113 + <div class={s.tableWrapper}> 114 + <table class={s.table}> 115 + <thead> 116 + <tr> 117 + <th class={s.th}>Time</th> 118 + <th class={s.th}>Status</th> 119 + <th class={s.th}>Attempt</th> 120 + <th class={s.th}>Error</th> 118 121 </tr> 119 - ))} 120 - </tbody> 121 - </table> 122 + </thead> 123 + <tbody> 124 + {logs.map((log) => ( 125 + <tr key={log.id}> 126 + <td class={s.td}> 127 + {new Date(log.createdAt).toLocaleString()} 128 + </td> 129 + <td class={s.td}>{log.statusCode ?? "\u2014"}</td> 130 + <td class={s.td}>{log.attempt}</td> 131 + <td class={s.td}>{log.error || "\u2014"}</td> 132 + </tr> 133 + ))} 134 + </tbody> 135 + </table> 136 + </div> 122 137 )} 123 138 </div> 124 139 );
+178
app/islands/SubscriptionForm.css.ts
··· 1 + import { style, globalStyle } from "@vanilla-extract/css"; 2 + import { vars } from "../styles/theme.css.ts"; 3 + import { space } from "../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../styles/tokens/typography.ts"; 5 + import { radii } from "../styles/tokens/radii.ts"; 6 + 7 + export const form = style({ 8 + display: "flex", 9 + flexDirection: "column", 10 + gap: space[5], 11 + }); 12 + 13 + export const fieldGroup = style({ 14 + display: "flex", 15 + flexDirection: "column", 16 + gap: space[1], 17 + }); 18 + 19 + export const label = style({ 20 + fontSize: fontSize.sm, 21 + fontWeight: fontWeight.medium, 22 + color: vars.color.text, 23 + }); 24 + 25 + export const input = style({ 26 + width: "100%", 27 + paddingBlock: space[2], 28 + paddingInline: space[3], 29 + fontSize: fontSize.base, 30 + color: vars.color.text, 31 + backgroundColor: vars.color.bg, 32 + border: `1px solid ${vars.color.border}`, 33 + borderRadius: radii.md, 34 + "::placeholder": { 35 + color: vars.color.textMuted, 36 + }, 37 + ":focus": { 38 + borderColor: vars.color.accent, 39 + outline: "none", 40 + boxShadow: `0 0 0 3px ${vars.focus.ring}`, 41 + }, 42 + }); 43 + 44 + export const select = style({ 45 + width: "100%", 46 + paddingBlock: space[2], 47 + paddingInline: space[3], 48 + paddingInlineEnd: space[7], 49 + fontSize: fontSize.base, 50 + color: vars.color.text, 51 + backgroundColor: vars.color.bg, 52 + border: `1px solid ${vars.color.border}`, 53 + borderRadius: radii.md, 54 + appearance: "none", 55 + backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`, 56 + backgroundRepeat: "no-repeat", 57 + backgroundPosition: "right 12px center", 58 + cursor: "pointer", 59 + ":focus": { 60 + borderColor: vars.color.accent, 61 + outline: "none", 62 + boxShadow: `0 0 0 3px ${vars.focus.ring}`, 63 + }, 64 + }); 65 + 66 + export const hint = style({ 67 + fontSize: fontSize.xs, 68 + color: vars.color.textMuted, 69 + }); 70 + 71 + export const errorText = style({ 72 + fontSize: fontSize.sm, 73 + color: vars.color.error, 74 + }); 75 + 76 + export const conditionsSection = style({ 77 + display: "flex", 78 + flexDirection: "column", 79 + gap: space[3], 80 + paddingBlock: space[4], 81 + paddingInline: space[4], 82 + borderRadius: radii.md, 83 + border: `1px solid ${vars.color.borderSubtle}`, 84 + backgroundColor: vars.color.bg, 85 + }); 86 + 87 + export const conditionRow = style({ 88 + display: "flex", 89 + gap: space[2], 90 + alignItems: "flex-start", 91 + }); 92 + 93 + export const conditionField = style({ 94 + flex: 1, 95 + }); 96 + 97 + export const conditionValue = style({ 98 + flex: 1, 99 + }); 100 + 101 + export const removeBtn = style({ 102 + paddingBlock: space[2], 103 + paddingInline: space[3], 104 + fontSize: fontSize.sm, 105 + color: vars.color.error, 106 + backgroundColor: "transparent", 107 + border: `1px solid ${vars.color.border}`, 108 + borderRadius: radii.md, 109 + cursor: "pointer", 110 + whiteSpace: "nowrap", 111 + ":hover": { 112 + backgroundColor: vars.color.errorSubtle, 113 + borderColor: vars.color.error, 114 + }, 115 + }); 116 + 117 + export const addBtn = style({ 118 + alignSelf: "flex-start", 119 + paddingBlock: space[1], 120 + paddingInline: space[3], 121 + fontSize: fontSize.sm, 122 + fontWeight: fontWeight.medium, 123 + color: vars.color.accent, 124 + backgroundColor: "transparent", 125 + border: `1px solid ${vars.color.border}`, 126 + borderRadius: radii.md, 127 + cursor: "pointer", 128 + ":hover": { 129 + backgroundColor: vars.color.accentSubtle, 130 + borderColor: vars.color.accent, 131 + }, 132 + }); 133 + 134 + export const submitBtn = style({ 135 + width: "100%", 136 + display: "inline-flex", 137 + alignItems: "center", 138 + justifyContent: "center", 139 + paddingBlock: space[2], 140 + paddingInline: space[4], 141 + fontSize: fontSize.base, 142 + fontWeight: fontWeight.medium, 143 + color: vars.color.accentText, 144 + backgroundColor: vars.color.accent, 145 + border: "1px solid transparent", 146 + borderRadius: radii.md, 147 + cursor: "pointer", 148 + minBlockSize: "40px", 149 + lineHeight: 1, 150 + ":hover": { 151 + backgroundColor: vars.color.accentHover, 152 + }, 153 + ":active": { 154 + backgroundColor: vars.color.accentActive, 155 + }, 156 + selectors: { 157 + "&:disabled": { 158 + opacity: 0.5, 159 + cursor: "not-allowed", 160 + }, 161 + }, 162 + }); 163 + 164 + export const alertError = style({ 165 + paddingBlock: space[3], 166 + paddingInline: space[4], 167 + borderRadius: radii.md, 168 + fontSize: fontSize.sm, 169 + borderInlineStart: "3px solid", 170 + backgroundColor: vars.color.errorSubtle, 171 + color: vars.color.error, 172 + borderColor: vars.color.error, 173 + }); 174 + 175 + export const resetFieldset = style({ 176 + border: "none", 177 + padding: 0, 178 + });
+137 -83
app/islands/SubscriptionForm.tsx
··· 1 1 import { useState, useCallback, useRef } from "hono/jsx"; 2 + import * as s from "./SubscriptionForm.css.ts"; 2 3 3 4 type Field = { 4 5 path: string; ··· 11 12 value: string; 12 13 }; 13 14 15 + const NSID_RE = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){2,}$/; 16 + 17 + function getInitialLexicon(): string { 18 + if (typeof window === "undefined") return ""; 19 + return new URLSearchParams(window.location.search).get("lexicon") ?? ""; 20 + } 21 + 14 22 export default function SubscriptionForm() { 15 - const [lexicon, setLexicon] = useState(""); 23 + const initial = getInitialLexicon(); 24 + const [lexicon, setLexicon] = useState(initial); 16 25 const [fields, setFields] = useState<Field[]>([]); 17 26 const [fieldsLoading, setFieldsLoading] = useState(false); 18 27 const [fieldsError, setFieldsError] = useState(""); ··· 21 30 const [submitting, setSubmitting] = useState(false); 22 31 const [error, setError] = useState(""); 23 32 const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 33 + const initialFetched = useRef(false); 24 34 25 - const fetchFields = useCallback((nsid: string) => { 35 + const fetchFields = useCallback((nsid: string, updateUrl = true) => { 26 36 if (debounceRef.current) clearTimeout(debounceRef.current); 27 37 // Clear conditions immediately — old field paths are stale 28 38 setConditions([]); 29 39 if (!nsid) { 30 40 setFields([]); 31 41 setFieldsError(""); 42 + if (updateUrl) { 43 + const url = new URL(window.location.href); 44 + url.searchParams.delete("lexicon"); 45 + history.replaceState(null, "", url); 46 + } 47 + return; 48 + } 49 + if (updateUrl) { 50 + const url = new URL(window.location.href); 51 + url.searchParams.set("lexicon", nsid); 52 + history.replaceState(null, "", url); 53 + } 54 + if (!NSID_RE.test(nsid)) { 55 + setFields([]); 56 + setFieldsError(""); 32 57 return; 33 58 } 34 59 debounceRef.current = setTimeout(async () => { ··· 51 76 } 52 77 }, 400); 53 78 }, []); 79 + 80 + // Fetch fields for initial lexicon from URL query param 81 + if (!initialFetched.current && initial) { 82 + initialFetched.current = true; 83 + fetchFields(initial, false); 84 + } 54 85 55 86 const addCondition = useCallback(() => { 56 87 setConditions((prev) => [...prev, { field: "", value: "" }]); ··· 102 133 ); 103 134 104 135 return ( 105 - <form onSubmit={handleSubmit}> 106 - <fieldset disabled={submitting}> 107 - <div> 108 - <label for="lexicon">Lexicon NSID</label> 109 - <input 110 - id="lexicon" 111 - type="text" 112 - placeholder="e.g. sh.tangled.feed.star" 113 - value={lexicon} 114 - onInput={(e: Event) => { 115 - const val = (e.target as HTMLInputElement).value; 116 - setLexicon(val); 117 - fetchFields(val); 118 - }} 119 - required 120 - /> 121 - {fieldsLoading && <span>Loading fields...</span>} 122 - {fieldsError && <span>{fieldsError}</span>} 123 - </div> 136 + <form onSubmit={handleSubmit} class={s.form}> 137 + <fieldset disabled={submitting} class={s.resetFieldset}> 138 + <div class={s.form}> 139 + <div class={s.fieldGroup}> 140 + <label class={s.label} for="lexicon"> 141 + Lexicon NSID 142 + </label> 143 + <input 144 + id="lexicon" 145 + class={s.input} 146 + type="text" 147 + placeholder="e.g. sh.tangled.feed.star" 148 + value={lexicon} 149 + onInput={(e: Event) => { 150 + const val = (e.target as HTMLInputElement).value; 151 + setLexicon(val); 152 + fetchFields(val); 153 + }} 154 + required 155 + /> 156 + {fieldsLoading && <span class={s.hint}>Loading fields...</span>} 157 + {fieldsError && <span class={s.errorText}>{fieldsError}</span>} 158 + </div> 124 159 125 - <div> 126 - <label for="callbackUrl">Callback URL</label> 127 - <input 128 - id="callbackUrl" 129 - type="url" 130 - placeholder="https://example.com/hooks/events" 131 - value={callbackUrl} 132 - onInput={(e: Event) => 133 - setCallbackUrl((e.target as HTMLInputElement).value) 134 - } 135 - required 136 - /> 137 - </div> 160 + <div class={s.fieldGroup}> 161 + <label class={s.label} for="callbackUrl"> 162 + Callback URL 163 + </label> 164 + <input 165 + id="callbackUrl" 166 + class={s.input} 167 + type="url" 168 + placeholder="https://example.com/hooks/events" 169 + value={callbackUrl} 170 + onInput={(e: Event) => 171 + setCallbackUrl((e.target as HTMLInputElement).value) 172 + } 173 + required 174 + /> 175 + </div> 138 176 139 - {fields.length > 0 && ( 140 - <div> 141 - <h3>Conditions</h3> 142 - <p>Filter events by field values. All conditions must match (AND).</p> 143 - {conditions.map((cond, i) => ( 144 - <div key={i}> 145 - <select 146 - value={cond.field} 147 - onChange={(e: Event) => 148 - updateCondition( 149 - i, 150 - "field", 151 - (e.target as HTMLSelectElement).value, 152 - ) 153 - } 154 - > 155 - <option value="">Select field...</option> 156 - {fields.map((f) => ( 157 - <option key={f.path} value={f.path}> 158 - {f.path} 159 - {f.description ? ` — ${f.description}` : ""} 160 - </option> 161 - ))} 162 - </select> 163 - <input 164 - type="text" 165 - placeholder="Value" 166 - value={cond.value} 167 - onInput={(e: Event) => 168 - updateCondition( 169 - i, 170 - "value", 171 - (e.target as HTMLInputElement).value, 172 - ) 173 - } 174 - /> 175 - <button type="button" onClick={() => removeCondition(i)}> 176 - Remove 177 - </button> 177 + {fields.length > 0 && ( 178 + <div class={s.conditionsSection}> 179 + <div> 180 + <h3>Conditions</h3> 181 + <p class={s.hint}> 182 + Filter events by field values. All conditions must match 183 + (AND). 184 + </p> 178 185 </div> 179 - ))} 180 - <button type="button" onClick={addCondition}> 181 - Add condition 182 - </button> 183 - </div> 184 - )} 186 + {conditions.map((cond, i) => ( 187 + <div key={i} class={s.conditionRow}> 188 + <div class={s.conditionField}> 189 + <select 190 + class={s.select} 191 + value={cond.field} 192 + onChange={(e: Event) => 193 + updateCondition( 194 + i, 195 + "field", 196 + (e.target as HTMLSelectElement).value, 197 + ) 198 + } 199 + > 200 + <option value="">Select field...</option> 201 + {fields.map((f) => ( 202 + <option key={f.path} value={f.path}> 203 + {f.path} 204 + {f.description ? ` — ${f.description}` : ""} 205 + </option> 206 + ))} 207 + </select> 208 + </div> 209 + <div class={s.conditionValue}> 210 + <input 211 + class={s.input} 212 + type="text" 213 + placeholder="Value" 214 + value={cond.value} 215 + onInput={(e: Event) => 216 + updateCondition( 217 + i, 218 + "value", 219 + (e.target as HTMLInputElement).value, 220 + ) 221 + } 222 + /> 223 + </div> 224 + <button 225 + type="button" 226 + class={s.removeBtn} 227 + onClick={() => removeCondition(i)} 228 + > 229 + Remove 230 + </button> 231 + </div> 232 + ))} 233 + <button type="button" class={s.addBtn} onClick={addCondition}> 234 + + Add condition 235 + </button> 236 + </div> 237 + )} 185 238 186 - {error && <p>{error}</p>} 239 + {error && <div class={s.alertError}>{error}</div>} 187 240 188 - <button type="submit"> 189 - {submitting ? "Creating..." : "Create subscription"} 190 - </button> 241 + <button type="submit" class={s.submitBtn}> 242 + {submitting ? "Creating..." : "Create subscription"} 243 + </button> 244 + </div> 191 245 </fieldset> 192 246 </form> 193 247 );
+24
app/islands/ThemeToggle.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../styles/theme.css.ts"; 3 + import { space } from "../styles/tokens/spacing.ts"; 4 + 5 + export const button = style({ 6 + display: "inline-flex", 7 + alignItems: "center", 8 + justifyContent: "center", 9 + padding: space[2], 10 + background: "none", 11 + border: "none", 12 + borderRadius: "4px", 13 + color: vars.color.textSecondary, 14 + cursor: "pointer", 15 + ":hover": { 16 + color: vars.color.text, 17 + backgroundColor: vars.color.surfaceHover, 18 + }, 19 + }); 20 + 21 + export const icon = style({ 22 + inlineSize: "20px", 23 + blockSize: "20px", 24 + });
+72
app/islands/ThemeToggle.tsx
··· 1 + import { useState, useCallback } from "hono/jsx"; 2 + import * as s from "./ThemeToggle.css.ts"; 3 + 4 + function getInitialTheme(): "light" | "dark" { 5 + if (typeof document === "undefined") return "light"; 6 + return (document.documentElement.dataset.theme as "light" | "dark") || "light"; 7 + } 8 + 9 + // Sun icon for dark mode (click to switch to light) 10 + function SunIcon() { 11 + return ( 12 + <svg 13 + class={s.icon} 14 + viewBox="0 0 24 24" 15 + fill="none" 16 + stroke="currentColor" 17 + stroke-width="2" 18 + stroke-linecap="round" 19 + stroke-linejoin="round" 20 + > 21 + <circle cx="12" cy="12" r="5" /> 22 + <line x1="12" y1="1" x2="12" y2="3" /> 23 + <line x1="12" y1="21" x2="12" y2="23" /> 24 + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /> 25 + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /> 26 + <line x1="1" y1="12" x2="3" y2="12" /> 27 + <line x1="21" y1="12" x2="23" y2="12" /> 28 + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /> 29 + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /> 30 + </svg> 31 + ); 32 + } 33 + 34 + // Moon icon for light mode (click to switch to dark) 35 + function MoonIcon() { 36 + return ( 37 + <svg 38 + class={s.icon} 39 + viewBox="0 0 24 24" 40 + fill="none" 41 + stroke="currentColor" 42 + stroke-width="2" 43 + stroke-linecap="round" 44 + stroke-linejoin="round" 45 + > 46 + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> 47 + </svg> 48 + ); 49 + } 50 + 51 + export default function ThemeToggle() { 52 + const [theme, setTheme] = useState(getInitialTheme); 53 + 54 + const toggle = useCallback(() => { 55 + const next = theme === "dark" ? "light" : "dark"; 56 + document.documentElement.dataset.theme = next; 57 + localStorage.setItem("theme", next); 58 + setTheme(next); 59 + }, [theme]); 60 + 61 + return ( 62 + <button 63 + type="button" 64 + class={s.button} 65 + onClick={toggle} 66 + aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`} 67 + title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`} 68 + > 69 + {theme === "dark" ? <SunIcon /> : <MoonIcon />} 70 + </button> 71 + ); 72 + }
+20 -4
app/routes/_404.tsx
··· 1 1 import type { NotFoundHandler } from "hono"; 2 + import { AppShell } from "../components/Layout/AppShell/index.js"; 3 + import { Header } from "../components/Layout/Header/index.js"; 4 + import { Container } from "../components/Layout/Container/index.js"; 5 + import { Button } from "../components/Button/index.js"; 6 + import { centerText } from "../styles/utilities.css.js"; 2 7 3 8 const handler: NotFoundHandler = (c) => { 9 + if (c.req.path.startsWith("/api/")) { 10 + return c.json({ error: "Not found" }, 404); 11 + } 12 + 4 13 return c.render( 5 - <div> 6 - <h1>404</h1> 7 - <p>Page not found.</p> 8 - </div>, 14 + <AppShell header={<Header />}> 15 + <Container> 16 + <div class={centerText}> 17 + <h1>404</h1> 18 + <p>Page not found.</p> 19 + <Button href="/" variant="secondary"> 20 + Back to home 21 + </Button> 22 + </div> 23 + </Container> 24 + </AppShell>, 9 25 { title: "Not Found — Airglow" }, 10 26 ); 11 27 };
+49 -1
app/routes/_renderer.tsx
··· 1 1 import { jsxRenderer } from "hono/jsx-renderer"; 2 + import { raw } from "hono/html"; 2 3 import { Script } from "honox/server"; 3 - import "../styles/base.css.js"; 4 + import "../styles/reset.css.js"; 5 + import "../styles/theme.css.js"; 6 + import "../styles/global.css.js"; 7 + import "../styles/sprinkles.css.js"; 8 + 9 + // 1. Blocking script: set data-theme before paint 10 + // 2. Critical inline CSS: base colors, font, line-height — no external request needed 11 + // Uses the same OKLCH values as the theme tokens. When full CSS loads, it takes over seamlessly. 12 + const headInline = raw(`<script> 13 + (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'}})() 14 + </script> 15 + <style> 16 + *,*::before,*::after{margin:0;padding:0} 17 + html{font-family:Inter,Roboto,"Helvetica Neue","Arial Nova","Nimbus Sans",Arial,sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased;background:oklch(.975 .005 90);color:oklch(.23 0 0)} 18 + [data-theme="dark"]{background:oklch(.13 0 0);color:oklch(.87 0 0)} 19 + @media(prefers-color-scheme:dark){html:not([data-theme]){background:oklch(.13 0 0);color:oklch(.87 0 0)}} 20 + </style>`); 21 + 22 + // In production, read the Vite manifest to emit blocking <link> tags for CSS 23 + function CssLinks() { 24 + if (!import.meta.env.PROD) return <></>; 25 + 26 + const manifests = import.meta.glob<{ default: Record<string, { css?: string[] }> }>( 27 + "/dist/.vite/manifest.json", 28 + { eager: true }, 29 + ); 30 + const manifest = Object.values(manifests)[0]?.default; 31 + if (!manifest) return <></>; 32 + 33 + const base = import.meta.env.BASE_URL ?? "/"; 34 + const seen = new Set<string>(); 35 + for (const entry of Object.values(manifest)) { 36 + for (const css of entry.css ?? []) { 37 + seen.add(css); 38 + } 39 + } 40 + 41 + return ( 42 + <> 43 + {[...seen].map((css) => ( 44 + <link key={css} rel="stylesheet" href={`${base}${css}`} /> 45 + ))} 46 + </> 47 + ); 48 + } 4 49 5 50 export default jsxRenderer(({ children, title }) => { 6 51 return ( ··· 8 53 <head> 9 54 <meta charset="utf-8" /> 10 55 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 56 + {headInline} 57 + {!import.meta.env.PROD && <link rel="stylesheet" href="/__dev.css" />} 58 + <CssLinks /> 11 59 <title>{title ?? "Airglow"}</title> 12 60 <Script src="/app/client.ts" async /> 13 61 </head>
+32 -11
app/routes/auth/callback.tsx
··· 5 5 import { config } from "@/config.js"; 6 6 import { db } from "@/db/index.js"; 7 7 import { users } from "@/db/schema.js"; 8 + import { AppShell } from "../../components/Layout/AppShell/index.js"; 9 + import { Header } from "../../components/Layout/Header/index.js"; 10 + import { Container } from "../../components/Layout/Container/index.js"; 11 + import { Card } from "../../components/Card/index.js"; 12 + import { Alert } from "../../components/Alert/index.js"; 13 + import { Button } from "../../components/Button/index.js"; 14 + import { Stack } from "../../components/Layout/Stack/index.js"; 15 + import ThemeToggle from "../../islands/ThemeToggle.js"; 16 + import { centerBlock } from "../../styles/utilities.css.js"; 17 + 18 + function ErrorPage({ message }: { message: string }) { 19 + return ( 20 + <AppShell header={<Header actions={<ThemeToggle />} />}> 21 + <Container> 22 + <div class={centerBlock}> 23 + <Card variant="raised"> 24 + <Stack gap={4}> 25 + <h1>Authentication Failed</h1> 26 + <Alert variant="error">{message}</Alert> 27 + <Button href="/auth/login" variant="secondary"> 28 + Try Again 29 + </Button> 30 + </Stack> 31 + </Card> 32 + </div> 33 + </Container> 34 + </AppShell> 35 + ); 36 + } 8 37 9 38 export default createRoute(async (c) => { 10 39 const params = new URL(c.req.url).searchParams; 11 40 12 41 // Check for OAuth error response 13 42 if (params.has("error")) { 14 - const description = params.get("error_description") || params.get("error"); 43 + const description = params.get("error_description") || params.get("error") || "Unknown error"; 15 44 return c.render( 16 - <div> 17 - <h1>Authentication failed</h1> 18 - <p>{description}</p> 19 - <a href="/auth/login">Try again</a> 20 - </div>, 45 + <ErrorPage message={description} />, 21 46 { title: "Error — Airglow" }, 22 47 ); 23 48 } ··· 48 73 } catch (err) { 49 74 console.error("OAuth callback error:", err); 50 75 return c.render( 51 - <div> 52 - <h1>Authentication failed</h1> 53 - <p>Something went wrong during authentication. Please try again.</p> 54 - <a href="/auth/login">Try again</a> 55 - </div>, 76 + <ErrorPage message="Something went wrong during authentication. Please try again." />, 56 77 { title: "Error — Airglow" }, 57 78 ); 58 79 }
+46 -11
app/routes/auth/login.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { getOAuthClient, resolveHandle, rewritePdsUrl } from "@/auth/client.js"; 3 3 import { getSessionUser } from "@/auth/middleware.js"; 4 + import { AppShell } from "../../components/Layout/AppShell/index.js"; 5 + import { Header } from "../../components/Layout/Header/index.js"; 6 + import { Container } from "../../components/Layout/Container/index.js"; 7 + import { Card } from "../../components/Card/index.js"; 8 + import { Alert } from "../../components/Alert/index.js"; 9 + import { Stack } from "../../components/Layout/Stack/index.js"; 10 + import ThemeToggle from "../../islands/ThemeToggle.js"; 11 + import { loginWrapper, loginCard, formGroup, loginInput, loginLabel, loginButton } from "../../styles/pages/login.css.js"; 4 12 5 13 export default createRoute(async (c) => { 6 14 const user = await getSessionUser(c); ··· 9 17 const error = c.req.query("error"); 10 18 11 19 return c.render( 12 - <div> 13 - <h1>Sign in to Airglow</h1> 14 - {error && <p style="color: red">{error}</p>} 15 - <form method="post" action="/auth/login"> 16 - <label> 17 - Handle or DID 18 - <input type="text" name="handle" placeholder="you.bsky.social" required autofocus /> 19 - </label> 20 - <button type="submit">Sign in with AT Protocol</button> 21 - </form> 22 - </div>, 20 + <AppShell header={<Header actions={<ThemeToggle />} />}> 21 + <Container> 22 + <div class={loginWrapper}> 23 + <Card variant="raised"> 24 + <div class={loginCard}> 25 + <Stack gap={5}> 26 + <div> 27 + <h1>Sign in to Airglow</h1> 28 + <p>Use your AT Protocol handle or DID to authenticate.</p> 29 + </div> 30 + {error && <Alert variant="error">{error}</Alert>} 31 + <form method="post" action="/auth/login"> 32 + <Stack gap={4}> 33 + <div class={formGroup}> 34 + <label class={loginLabel} for="handle"> 35 + Handle or DID 36 + </label> 37 + <input 38 + id="handle" 39 + class={loginInput} 40 + type="text" 41 + name="handle" 42 + placeholder="you.bsky.social" 43 + required 44 + autofocus 45 + /> 46 + </div> 47 + <button type="submit" class={loginButton}> 48 + Sign in with AT Protocol 49 + </button> 50 + </Stack> 51 + </form> 52 + </Stack> 53 + </div> 54 + </Card> 55 + </div> 56 + </Container> 57 + </AppShell>, 23 58 { title: "Sign in — Airglow" }, 24 59 ); 25 60 });
+53 -24
app/routes/dashboard/index.tsx
··· 2 2 import { eq } from "drizzle-orm"; 3 3 import { db } from "@/db/index.js"; 4 4 import { subscriptions } from "@/db/schema.js"; 5 + import { AppShell } from "../../components/Layout/AppShell/index.js"; 6 + import { Header } from "../../components/Layout/Header/index.js"; 7 + import { Container } from "../../components/Layout/Container/index.js"; 8 + import { PageHeader } from "../../components/Layout/PageHeader/index.js"; 9 + import { Button } from "../../components/Button/index.js"; 10 + import { Table } from "../../components/Table/index.js"; 11 + import { Badge } from "../../components/Badge/index.js"; 12 + import { Card } from "../../components/Card/index.js"; 13 + import { InlineCode } from "../../components/CodeBlock/index.js"; 14 + import ThemeToggle from "../../islands/ThemeToggle.js"; 15 + import { centerTextSm } from "../../styles/utilities.css.js"; 5 16 6 17 export default createRoute(async (c) => { 7 18 const user = c.get("user"); ··· 10 21 }); 11 22 12 23 return c.render( 13 - <div> 14 - <header> 15 - <h1>Dashboard</h1> 16 - <p> 17 - Signed in as <strong>{user.handle}</strong> 18 - </p> 19 - <form method="post" action="/auth/signout"> 20 - <button type="submit">Sign out</button> 21 - </form> 22 - </header> 23 - <section> 24 - <h2>Subscriptions</h2> 25 - <p> 26 - <a href="/dashboard/subscriptions/new">New subscription</a> 27 - </p> 24 + <AppShell 25 + header={<Header user={user} actions={<ThemeToggle />} />} 26 + > 27 + <Container> 28 + <PageHeader 29 + title="Subscriptions" 30 + description={`${subs.length} subscription${subs.length !== 1 ? "s" : ""}`} 31 + actions={ 32 + <Button href="/dashboard/subscriptions/new" size="sm"> 33 + New Subscription 34 + </Button> 35 + } 36 + /> 37 + 28 38 {subs.length === 0 ? ( 29 - <p>No subscriptions yet.</p> 39 + <Card variant="flat"> 40 + <div class={centerTextSm}> 41 + <p>No subscriptions yet.</p> 42 + <p> 43 + <Button href="/dashboard/subscriptions/new" variant="secondary" size="sm"> 44 + Create your first subscription 45 + </Button> 46 + </p> 47 + </div> 48 + </Card> 30 49 ) : ( 31 - <table> 50 + <Table> 32 51 <thead> 33 52 <tr> 34 53 <th>Lexicon</th> ··· 43 62 <tr key={sub.uri}> 44 63 <td> 45 64 <a href={`/dashboard/subscriptions/${sub.rkey}`}> 46 - <code>{sub.lexicon}</code> 65 + <InlineCode>{sub.lexicon}</InlineCode> 47 66 </a> 48 67 </td> 49 68 <td> 50 - <code>{sub.callbackUrl}</code> 69 + <InlineCode>{sub.callbackUrl}</InlineCode> 51 70 </td> 52 71 <td>{sub.conditions.length}</td> 53 - <td>{sub.active ? "Active" : "Inactive"}</td> 54 72 <td> 55 - <a href={`/dashboard/subscriptions/${sub.rkey}`}>View</a> 73 + <Badge variant={sub.active ? "success" : "neutral"}> 74 + {sub.active ? "Active" : "Inactive"} 75 + </Badge> 76 + </td> 77 + <td> 78 + <Button 79 + href={`/dashboard/subscriptions/${sub.rkey}`} 80 + variant="ghost" 81 + size="sm" 82 + > 83 + View 84 + </Button> 56 85 </td> 57 86 </tr> 58 87 ))} 59 88 </tbody> 60 - </table> 89 + </Table> 61 90 )} 62 - </section> 63 - </div>, 91 + </Container> 92 + </AppShell>, 64 93 { title: "Dashboard — Airglow" }, 65 94 ); 66 95 });
+102 -60
app/routes/dashboard/subscriptions/[rkey].tsx
··· 2 2 import { eq, and, desc } from "drizzle-orm"; 3 3 import { db } from "@/db/index.js"; 4 4 import { subscriptions, deliveryLogs } from "@/db/schema.js"; 5 + import { AppShell } from "../../../components/Layout/AppShell/index.js"; 6 + import { Header } from "../../../components/Layout/Header/index.js"; 7 + import { Container } from "../../../components/Layout/Container/index.js"; 8 + import { PageHeader } from "../../../components/Layout/PageHeader/index.js"; 9 + import { Card } from "../../../components/Card/index.js"; 10 + import { Badge } from "../../../components/Badge/index.js"; 11 + import { Button } from "../../../components/Button/index.js"; 12 + import { DescriptionList } from "../../../components/DescriptionList/index.js"; 13 + import { InlineCode } from "../../../components/CodeBlock/index.js"; 14 + import { Stack } from "../../../components/Layout/Stack/index.js"; 15 + import ThemeToggle from "../../../islands/ThemeToggle.js"; 5 16 import DeliveryLog from "../../../islands/DeliveryLog.js"; 17 + import { inlineCluster, plainList } from "../../../styles/utilities.css.js"; 6 18 7 19 export default createRoute(async (c) => { 8 20 const user = c.get("user"); ··· 14 26 if (!sub) { 15 27 c.status(404); 16 28 return c.render( 17 - <div> 18 - <h1>Not found</h1> 19 - <p> 20 - <a href="/dashboard">&larr; Back to dashboard</a> 21 - </p> 22 - </div>, 29 + <AppShell 30 + header={<Header user={user} actions={<ThemeToggle />} />} 31 + > 32 + <Container> 33 + <PageHeader 34 + title="Not Found" 35 + actions={ 36 + <Button href="/dashboard" variant="ghost" size="sm"> 37 + &larr; Back 38 + </Button> 39 + } 40 + /> 41 + <p>This subscription does not exist.</p> 42 + </Container> 43 + </AppShell>, 23 44 { title: "Not Found — Airglow" }, 24 45 ); 25 46 } ··· 31 52 }); 32 53 33 54 return c.render( 34 - <div> 35 - <h1>Subscription</h1> 36 - <p> 37 - <a href="/dashboard">&larr; Back to dashboard</a> 38 - </p> 55 + <AppShell 56 + header={<Header user={user} actions={<ThemeToggle />} />} 57 + > 58 + <Container> 59 + <PageHeader 60 + title={sub.lexicon} 61 + actions={ 62 + <div class={inlineCluster}> 63 + <Badge variant={sub.active ? "success" : "neutral"}> 64 + {sub.active ? "Active" : "Inactive"} 65 + </Badge> 66 + <Button href="/dashboard" variant="ghost" size="sm"> 67 + &larr; Back 68 + </Button> 69 + </div> 70 + } 71 + /> 39 72 40 - <dl> 41 - <dt>Lexicon</dt> 42 - <dd> 43 - <code>{sub.lexicon}</code> 44 - </dd> 45 - <dt>Callback URL</dt> 46 - <dd> 47 - <code>{sub.callbackUrl}</code> 48 - </dd> 49 - <dt>Status</dt> 50 - <dd>{sub.active ? "Active" : "Inactive"}</dd> 51 - <dt>HMAC Secret</dt> 52 - <dd> 53 - <code>{sub.secret}</code> 54 - </dd> 55 - <dt>AT URI</dt> 56 - <dd> 57 - <code>{sub.uri}</code> 58 - </dd> 59 - </dl> 73 + <Stack gap={6}> 74 + <Card variant="flat"> 75 + <DescriptionList> 76 + <dt>Lexicon</dt> 77 + <dd> 78 + <InlineCode>{sub.lexicon}</InlineCode> 79 + </dd> 80 + <dt>Callback URL</dt> 81 + <dd> 82 + <InlineCode>{sub.callbackUrl}</InlineCode> 83 + </dd> 84 + <dt>Status</dt> 85 + <dd> 86 + <Badge variant={sub.active ? "success" : "neutral"}> 87 + {sub.active ? "Active" : "Inactive"} 88 + </Badge> 89 + </dd> 90 + <dt>HMAC Secret</dt> 91 + <dd> 92 + <InlineCode>{sub.secret}</InlineCode> 93 + </dd> 94 + <dt>AT URI</dt> 95 + <dd> 96 + <InlineCode>{sub.uri}</InlineCode> 97 + </dd> 98 + </DescriptionList> 99 + </Card> 60 100 61 - {sub.conditions.length > 0 && ( 62 - <div> 63 - <h2>Conditions</h2> 64 - <ul> 65 - {sub.conditions.map((cond, i) => ( 66 - <li key={i}> 67 - <code>{cond.field}</code> {cond.operator}{" "} 68 - <code>{cond.value}</code> 69 - </li> 70 - ))} 71 - </ul> 72 - </div> 73 - )} 101 + {sub.conditions.length > 0 && ( 102 + <Card variant="flat"> 103 + <Stack gap={3}> 104 + <h3>Conditions</h3> 105 + <ul class={plainList}> 106 + {sub.conditions.map((cond, i) => ( 107 + <li key={i}> 108 + <InlineCode>{cond.field}</InlineCode>{" "} 109 + {cond.operator}{" "} 110 + <InlineCode>{cond.value}</InlineCode> 111 + </li> 112 + ))} 113 + </ul> 114 + </Stack> 115 + </Card> 116 + )} 74 117 75 - <div> 76 - <h2>Actions</h2> 77 - <DeliveryLog 78 - rkey={sub.rkey} 79 - active={sub.active} 80 - initialLogs={logs.map((l) => ({ 81 - id: l.id, 82 - eventTimeUs: l.eventTimeUs, 83 - statusCode: l.statusCode, 84 - error: l.error, 85 - attempt: l.attempt, 86 - createdAt: l.createdAt.getTime(), 87 - }))} 88 - /> 89 - </div> 90 - </div>, 118 + <DeliveryLog 119 + rkey={sub.rkey} 120 + active={sub.active} 121 + initialLogs={logs.map((l) => ({ 122 + id: l.id, 123 + eventTimeUs: l.eventTimeUs, 124 + statusCode: l.statusCode, 125 + error: l.error, 126 + attempt: l.attempt, 127 + createdAt: l.createdAt.getTime(), 128 + }))} 129 + /> 130 + </Stack> 131 + </Container> 132 + </AppShell>, 91 133 { title: `${sub.lexicon} — Airglow` }, 92 134 ); 93 135 });
+26 -7
app/routes/dashboard/subscriptions/new.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 + import { AppShell } from "../../../components/Layout/AppShell/index.js"; 3 + import { Header } from "../../../components/Layout/Header/index.js"; 4 + import { Container } from "../../../components/Layout/Container/index.js"; 5 + import { PageHeader } from "../../../components/Layout/PageHeader/index.js"; 6 + import { Card } from "../../../components/Card/index.js"; 7 + import { Button } from "../../../components/Button/index.js"; 8 + import ThemeToggle from "../../../islands/ThemeToggle.js"; 2 9 import SubscriptionForm from "../../../islands/SubscriptionForm.js"; 3 10 4 11 export default createRoute((c) => { 12 + const user = c.get("user"); 13 + 5 14 return c.render( 6 - <div> 7 - <h1>New Subscription</h1> 8 - <p> 9 - <a href="/dashboard">&larr; Back to dashboard</a> 10 - </p> 11 - <SubscriptionForm /> 12 - </div>, 15 + <AppShell 16 + header={<Header user={user} actions={<ThemeToggle />} />} 17 + > 18 + <Container> 19 + <PageHeader 20 + title="New Subscription" 21 + actions={ 22 + <Button href="/dashboard" variant="ghost" size="sm"> 23 + &larr; Back 24 + </Button> 25 + } 26 + /> 27 + <Card variant="flat"> 28 + <SubscriptionForm /> 29 + </Card> 30 + </Container> 31 + </AppShell>, 13 32 { title: "New Subscription — Airglow" }, 14 33 ); 15 34 });
+87 -6
app/routes/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { getSessionUser } from "@/auth/middleware.js"; 3 + import { AppShell } from "../components/Layout/AppShell/index.js"; 4 + import { Header } from "../components/Layout/Header/index.js"; 5 + import { Container } from "../components/Layout/Container/index.js"; 6 + import { Button } from "../components/Button/index.js"; 7 + import ThemeToggle from "../islands/ThemeToggle.js"; 8 + import * as s from "../styles/pages/landing.css.js"; 3 9 4 10 export default createRoute(async (c) => { 5 11 const user = await getSessionUser(c); 6 12 7 13 return c.render( 8 - <div> 9 - <h1>Airglow</h1> 10 - <p>Webhooks for the AT Protocol</p> 11 - {user ? <a href="/dashboard">Go to dashboard</a> : <a href="/auth/login">Sign in</a>} 12 - </div>, 13 - { title: "Airglow" }, 14 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 15 + <Container> 16 + <section class={s.hero}> 17 + <h1 class={s.heroTitle}>Webhooks for the AT Protocol</h1> 18 + <p class={s.heroSubtitle}> 19 + Subscribe to events across the AT Protocol network and receive 20 + real-time webhook deliveries. Filter by lexicon, match conditions, 21 + and track every delivery. 22 + </p> 23 + {user ? ( 24 + <Button href="/dashboard" size="lg"> 25 + Go to Dashboard 26 + </Button> 27 + ) : ( 28 + <Button href="/auth/login" size="lg"> 29 + Get Started 30 + </Button> 31 + )} 32 + </section> 33 + 34 + <section class={s.features}> 35 + <div class={s.featureCard}> 36 + <h3 class={s.featureTitle}>Real-time Webhooks</h3> 37 + <p class={s.featureDesc}> 38 + Receive HTTP POST callbacks instantly when matching events occur on 39 + the AT Protocol network via Jetstream. 40 + </p> 41 + </div> 42 + <div class={s.featureCard}> 43 + <h3 class={s.featureTitle}>Lexicon Filtering</h3> 44 + <p class={s.featureDesc}> 45 + Subscribe to specific record types by NSID. Add field-level 46 + conditions to match exactly the events you need. 47 + </p> 48 + </div> 49 + <div class={s.featureCard}> 50 + <h3 class={s.featureTitle}>Delivery Tracking</h3> 51 + <p class={s.featureDesc}> 52 + Full delivery log with status codes, retry attempts, and error 53 + details. Know exactly what happened with every event. 54 + </p> 55 + </div> 56 + <div class={s.featureCard}> 57 + <h3 class={s.featureTitle}>HMAC Signing</h3> 58 + <p class={s.featureDesc}> 59 + Every webhook is signed with a per-subscription HMAC secret so 60 + your callback can verify authenticity. 61 + </p> 62 + </div> 63 + </section> 64 + 65 + <section class={s.steps}> 66 + <h2 class={s.stepsTitle}>How It Works</h2> 67 + <ol class={s.stepsList}> 68 + <li class={s.step}> 69 + <div class={s.stepNumber}>1</div> 70 + <h3 class={s.stepTitle}>Sign in</h3> 71 + <p class={s.stepDesc}> 72 + Authenticate with your AT Protocol identity via OAuth. 73 + </p> 74 + </li> 75 + <li class={s.step}> 76 + <div class={s.stepNumber}>2</div> 77 + <h3 class={s.stepTitle}>Subscribe</h3> 78 + <p class={s.stepDesc}> 79 + Choose a lexicon, set conditions, and provide your callback URL. 80 + </p> 81 + </li> 82 + <li class={s.step}> 83 + <div class={s.stepNumber}>3</div> 84 + <h3 class={s.stepTitle}>Receive</h3> 85 + <p class={s.stepDesc}> 86 + Get signed webhook deliveries in real time with automatic 87 + retries. 88 + </p> 89 + </li> 90 + </ol> 91 + </section> 92 + </Container> 93 + </AppShell>, 94 + { title: "Airglow — Webhooks for the AT Protocol" }, 14 95 ); 15 96 });
-13
app/styles/base.css.ts
··· 1 - import { globalStyle } from "@vanilla-extract/css"; 2 - 3 - globalStyle("*, *::before, *::after", { 4 - boxSizing: "border-box", 5 - margin: 0, 6 - }); 7 - 8 - globalStyle("body", { 9 - fontFamily: "system-ui, -apple-system, sans-serif", 10 - lineHeight: 1.5, 11 - color: "#1a1a1a", 12 - backgroundColor: "#fafafa", 13 - });
+19
app/styles/components.css.ts
··· 1 + // Import all component styles to ensure they're in the client module graph. 2 + // SSR-only components aren't loaded client-side, so their CSS 3 + // won't reach the browser unless explicitly imported here. 4 + 5 + import "../components/Layout/AppShell/styles.css.ts"; 6 + import "../components/Layout/Header/styles.css.ts"; 7 + import "../components/Layout/Container/styles.css.ts"; 8 + import "../components/Layout/Stack/styles.css.ts"; 9 + import "../components/Layout/Cluster/styles.css.ts"; 10 + import "../components/Layout/PageHeader/styles.css.ts"; 11 + import "../components/Button/styles.css.ts"; 12 + import "../components/Input/styles.css.ts"; 13 + import "../components/Select/styles.css.ts"; 14 + import "../components/Card/styles.css.ts"; 15 + import "../components/Badge/styles.css.ts"; 16 + import "../components/Alert/styles.css.ts"; 17 + import "../components/Table/styles.css.ts"; 18 + import "../components/DescriptionList/styles.css.ts"; 19 + import "../components/CodeBlock/styles.css.ts";
+94
app/styles/global.css.ts
··· 1 + import { globalStyle } from "@vanilla-extract/css"; 2 + import { vars } from "./theme.css.ts"; 3 + import { fontFamily, fontSize, letterSpacing, lineHeight } from "./tokens/typography.ts"; 4 + 5 + globalStyle("html", { 6 + fontFamily: fontFamily.sans, 7 + fontSize: "100%", 8 + WebkitFontSmoothing: "antialiased", 9 + MozOsxFontSmoothing: "grayscale", 10 + textSizeAdjust: "100%", 11 + }); 12 + 13 + globalStyle("body", { 14 + color: vars.color.text, 15 + backgroundColor: vars.color.bg, 16 + lineHeight: lineHeight.normal, 17 + }); 18 + 19 + // Headings 20 + globalStyle("h1, h2, h3, h4, h5, h6", { 21 + fontWeight: 600, 22 + lineHeight: lineHeight.tight, 23 + color: vars.color.heading, 24 + textWrap: "balance", 25 + }); 26 + 27 + globalStyle("h1", { 28 + fontSize: fontSize["2xl"], 29 + letterSpacing: letterSpacing.tight, 30 + }); 31 + 32 + globalStyle("h2", { 33 + fontSize: fontSize.xl, 34 + letterSpacing: "-0.01em", 35 + }); 36 + 37 + globalStyle("h3", { 38 + fontSize: fontSize.lg, 39 + }); 40 + 41 + globalStyle("h4", { 42 + fontSize: fontSize.md, 43 + }); 44 + 45 + // Body text 46 + globalStyle("p", { 47 + marginBlockEnd: "1rem", 48 + }); 49 + 50 + globalStyle("p:last-child", { 51 + marginBlockEnd: 0, 52 + }); 53 + 54 + // Code 55 + globalStyle("code, pre", { 56 + fontFamily: fontFamily.mono, 57 + fontSize: "0.875em", 58 + }); 59 + 60 + globalStyle("code", { 61 + backgroundColor: vars.color.code, 62 + paddingInline: "6px", 63 + paddingBlock: "2px", 64 + borderRadius: "4px", 65 + }); 66 + 67 + globalStyle("pre code", { 68 + backgroundColor: "transparent", 69 + paddingInline: 0, 70 + paddingBlock: 0, 71 + }); 72 + 73 + // Links 74 + globalStyle("a", { 75 + color: vars.color.link, 76 + textDecorationSkipInk: "auto", 77 + }); 78 + 79 + globalStyle("a:hover", { 80 + color: vars.color.linkHover, 81 + }); 82 + 83 + // Focus visible for keyboard nav 84 + globalStyle(":focus-visible", { 85 + outline: `2px solid ${vars.focus.ring}`, 86 + outlineOffset: "2px", 87 + }); 88 + 89 + // Smooth color transitions for theme switching 90 + globalStyle("*, *::before, *::after", { 91 + transitionProperty: "color, background-color, border-color, box-shadow", 92 + transitionDuration: "150ms", 93 + transitionTimingFunction: "ease", 94 + });
+132
app/styles/pages/landing.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../theme.css.ts"; 3 + import { space } from "../tokens/spacing.ts"; 4 + import { fontSize, fontWeight, letterSpacing, lineHeight } from "../tokens/typography.ts"; 5 + import { radii } from "../tokens/radii.ts"; 6 + import { mq } from "../utils.ts"; 7 + 8 + export const hero = style({ 9 + textAlign: "center", 10 + paddingBlock: space[8], 11 + "@media": { 12 + [mq.md]: { 13 + paddingBlock: space[9], 14 + }, 15 + }, 16 + }); 17 + 18 + export const heroTitle = style({ 19 + fontSize: fontSize["2xl"], 20 + fontWeight: fontWeight.bold, 21 + letterSpacing: letterSpacing.tight, 22 + lineHeight: lineHeight.tight, 23 + marginBlockEnd: space[4], 24 + "@media": { 25 + [mq.md]: { 26 + fontSize: fontSize["3xl"], 27 + }, 28 + }, 29 + }); 30 + 31 + export const heroSubtitle = style({ 32 + fontSize: fontSize.md, 33 + color: vars.color.textSecondary, 34 + maxInlineSize: "40rem", 35 + marginInline: "auto", 36 + lineHeight: lineHeight.relaxed, 37 + marginBlockEnd: space[6], 38 + "@media": { 39 + [mq.md]: { 40 + fontSize: fontSize.lg, 41 + }, 42 + }, 43 + }); 44 + 45 + export const features = style({ 46 + display: "grid", 47 + gridTemplateColumns: "1fr", 48 + gap: space[5], 49 + paddingBlock: space[7], 50 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 51 + "@media": { 52 + [mq.md]: { 53 + gridTemplateColumns: "repeat(2, 1fr)", 54 + }, 55 + [mq.lg]: { 56 + gridTemplateColumns: "repeat(4, 1fr)", 57 + }, 58 + }, 59 + }); 60 + 61 + export const featureCard = style({ 62 + paddingBlock: space[5], 63 + paddingInline: space[5], 64 + borderRadius: radii.lg, 65 + border: `1px solid ${vars.color.borderSubtle}`, 66 + backgroundColor: vars.color.surface, 67 + }); 68 + 69 + export const featureTitle = style({ 70 + fontSize: fontSize.base, 71 + fontWeight: fontWeight.semibold, 72 + marginBlockEnd: space[2], 73 + }); 74 + 75 + export const featureDesc = style({ 76 + fontSize: fontSize.sm, 77 + color: vars.color.textSecondary, 78 + lineHeight: lineHeight.relaxed, 79 + }); 80 + 81 + export const steps = style({ 82 + paddingBlock: space[7], 83 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 84 + }); 85 + 86 + export const stepsTitle = style({ 87 + textAlign: "center", 88 + marginBlockEnd: space[6], 89 + }); 90 + 91 + export const stepsList = style({ 92 + display: "grid", 93 + gridTemplateColumns: "1fr", 94 + gap: space[5], 95 + maxInlineSize: "48rem", 96 + marginInline: "auto", 97 + listStyle: "none", 98 + "@media": { 99 + [mq.md]: { 100 + gridTemplateColumns: "repeat(3, 1fr)", 101 + }, 102 + }, 103 + }); 104 + 105 + export const step = style({ 106 + textAlign: "center", 107 + }); 108 + 109 + export const stepNumber = style({ 110 + display: "inline-flex", 111 + alignItems: "center", 112 + justifyContent: "center", 113 + inlineSize: "2rem", 114 + blockSize: "2rem", 115 + borderRadius: radii.full, 116 + backgroundColor: vars.color.accentSubtle, 117 + color: vars.color.accent, 118 + fontWeight: fontWeight.bold, 119 + fontSize: fontSize.sm, 120 + marginBlockEnd: space[3], 121 + }); 122 + 123 + export const stepTitle = style({ 124 + fontSize: fontSize.base, 125 + fontWeight: fontWeight.semibold, 126 + marginBlockEnd: space[1], 127 + }); 128 + 129 + export const stepDesc = style({ 130 + fontSize: fontSize.sm, 131 + color: vars.color.textSecondary, 132 + });
+71
app/styles/pages/login.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../theme.css.ts"; 3 + import { space } from "../tokens/spacing.ts"; 4 + import { fontSize } from "../tokens/typography.ts"; 5 + import { radii } from "../tokens/radii.ts"; 6 + 7 + export const loginWrapper = style({ 8 + display: "flex", 9 + justifyContent: "center", 10 + paddingBlock: space[8], 11 + }); 12 + 13 + export const loginCard = style({ 14 + maxInlineSize: "24rem", 15 + inlineSize: "100%", 16 + }); 17 + 18 + export const formGroup = style({ 19 + display: "flex", 20 + flexDirection: "column", 21 + gap: space[1], 22 + }); 23 + 24 + export const loginLabel = style({ 25 + fontSize: fontSize.sm, 26 + fontWeight: "500", 27 + color: vars.color.text, 28 + }); 29 + 30 + export const loginInput = style({ 31 + width: "100%", 32 + paddingBlock: space[2], 33 + paddingInline: space[3], 34 + fontSize: fontSize.base, 35 + color: vars.color.text, 36 + backgroundColor: vars.color.bg, 37 + border: `1px solid ${vars.color.border}`, 38 + borderRadius: radii.md, 39 + "::placeholder": { 40 + color: vars.color.textMuted, 41 + }, 42 + ":focus": { 43 + borderColor: vars.color.accent, 44 + outline: "none", 45 + boxShadow: `0 0 0 3px ${vars.focus.ring}`, 46 + }, 47 + }); 48 + 49 + export const loginButton = style({ 50 + width: "100%", 51 + display: "inline-flex", 52 + alignItems: "center", 53 + justifyContent: "center", 54 + paddingBlock: space[2], 55 + paddingInline: space[4], 56 + fontSize: fontSize.base, 57 + fontWeight: "500", 58 + color: vars.color.accentText, 59 + backgroundColor: vars.color.accent, 60 + border: "1px solid transparent", 61 + borderRadius: radii.md, 62 + cursor: "pointer", 63 + minBlockSize: "40px", 64 + lineHeight: 1, 65 + ":hover": { 66 + backgroundColor: vars.color.accentHover, 67 + }, 68 + ":active": { 69 + backgroundColor: vars.color.accentActive, 70 + }, 71 + });
+23
app/styles/reset.css.ts
··· 1 + import { globalStyle } from "@vanilla-extract/css"; 2 + 3 + // Minimal reset — no universal border-box 4 + 5 + globalStyle("*, *::before, *::after", { 6 + margin: 0, 7 + padding: 0, 8 + }); 9 + 10 + globalStyle("img, picture, video, svg", { 11 + display: "block", 12 + maxInlineSize: "100%", 13 + }); 14 + 15 + // border-box ONLY on form controls that need it for width: 100% 16 + globalStyle("input, button, textarea, select", { 17 + font: "inherit", 18 + boxSizing: "border-box", 19 + }); 20 + 21 + globalStyle("table", { 22 + borderCollapse: "collapse", 23 + });
+105
app/styles/sprinkles.css.ts
··· 1 + import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"; 2 + import { vars } from "./theme.css.ts"; 3 + import { space } from "./tokens/spacing.ts"; 4 + import { fontSize, fontWeight, lineHeight } from "./tokens/typography.ts"; 5 + import { radii } from "./tokens/radii.ts"; 6 + import { breakpoints } from "./tokens/breakpoints.ts"; 7 + 8 + const conditions = { 9 + default: {}, 10 + sm: { "@media": `screen and (min-width: ${breakpoints.sm}px)` }, 11 + md: { "@media": `screen and (min-width: ${breakpoints.md}px)` }, 12 + lg: { "@media": `screen and (min-width: ${breakpoints.lg}px)` }, 13 + } as const; 14 + 15 + const layoutProperties = defineProperties({ 16 + conditions, 17 + defaultCondition: "default", 18 + properties: { 19 + display: ["none", "flex", "grid", "block", "inline", "inline-flex"], 20 + flexDirection: ["row", "column", "row-reverse", "column-reverse"], 21 + flexWrap: ["wrap", "nowrap"], 22 + alignItems: ["stretch", "flex-start", "center", "flex-end", "baseline"], 23 + justifyContent: [ 24 + "stretch", 25 + "flex-start", 26 + "center", 27 + "flex-end", 28 + "space-between", 29 + "space-around", 30 + ], 31 + gap: space, 32 + paddingBlockStart: space, 33 + paddingBlockEnd: space, 34 + paddingInlineStart: space, 35 + paddingInlineEnd: space, 36 + marginBlockStart: space, 37 + marginBlockEnd: space, 38 + marginInlineStart: space, 39 + marginInlineEnd: space, 40 + textAlign: ["start", "center", "end"], 41 + }, 42 + shorthands: { 43 + paddingBlock: ["paddingBlockStart", "paddingBlockEnd"], 44 + paddingInline: ["paddingInlineStart", "paddingInlineEnd"], 45 + padding: [ 46 + "paddingBlockStart", 47 + "paddingBlockEnd", 48 + "paddingInlineStart", 49 + "paddingInlineEnd", 50 + ], 51 + marginBlock: ["marginBlockStart", "marginBlockEnd"], 52 + marginInline: ["marginInlineStart", "marginInlineEnd"], 53 + margin: [ 54 + "marginBlockStart", 55 + "marginBlockEnd", 56 + "marginInlineStart", 57 + "marginInlineEnd", 58 + ], 59 + }, 60 + }); 61 + 62 + const colorProperties = defineProperties({ 63 + properties: { 64 + color: { 65 + text: vars.color.text, 66 + textSecondary: vars.color.textSecondary, 67 + textMuted: vars.color.textMuted, 68 + heading: vars.color.heading, 69 + accent: vars.color.accent, 70 + success: vars.color.success, 71 + warning: vars.color.warning, 72 + error: vars.color.error, 73 + link: vars.color.link, 74 + inherit: "inherit", 75 + }, 76 + backgroundColor: { 77 + bg: vars.color.bg, 78 + surface: vars.color.surface, 79 + elevated: vars.color.elevated, 80 + accent: vars.color.accent, 81 + accentSubtle: vars.color.accentSubtle, 82 + successSubtle: vars.color.successSubtle, 83 + warningSubtle: vars.color.warningSubtle, 84 + errorSubtle: vars.color.errorSubtle, 85 + transparent: "transparent", 86 + }, 87 + }, 88 + }); 89 + 90 + const typographyProperties = defineProperties({ 91 + properties: { 92 + fontSize, 93 + fontWeight, 94 + lineHeight, 95 + borderRadius: radii, 96 + }, 97 + }); 98 + 99 + export const sprinkles = createSprinkles( 100 + layoutProperties, 101 + colorProperties, 102 + typographyProperties, 103 + ); 104 + 105 + export type Sprinkles = Parameters<typeof sprinkles>[0];
+105
app/styles/theme.css.ts
··· 1 + import { 2 + createGlobalThemeContract, 3 + createGlobalTheme, 4 + globalStyle, 5 + } from "@vanilla-extract/css"; 6 + import { darkColors, lightColors } from "./tokens/colors.ts"; 7 + import { darkShadows, lightShadows } from "./tokens/shadows.ts"; 8 + 9 + // Typed contract — CSS custom property names for all theme-varying values 10 + export const vars = createGlobalThemeContract({ 11 + color: { 12 + bg: "color-bg", 13 + surface: "color-surface", 14 + surfaceHover: "color-surface-hover", 15 + elevated: "color-elevated", 16 + border: "color-border", 17 + borderSubtle: "color-border-subtle", 18 + text: "color-text", 19 + textSecondary: "color-text-secondary", 20 + textMuted: "color-text-muted", 21 + heading: "color-heading", 22 + link: "color-link", 23 + linkHover: "color-link-hover", 24 + accent: "color-accent", 25 + accentHover: "color-accent-hover", 26 + accentActive: "color-accent-active", 27 + accentText: "color-accent-text", 28 + accentSubtle: "color-accent-subtle", 29 + success: "color-success", 30 + successSubtle: "color-success-subtle", 31 + warning: "color-warning", 32 + warningSubtle: "color-warning-subtle", 33 + error: "color-error", 34 + errorSubtle: "color-error-subtle", 35 + code: "color-code", 36 + }, 37 + shadow: { 38 + highlight: "shadow-highlight", 39 + sm: "shadow-sm", 40 + md: "shadow-md", 41 + lg: "shadow-lg", 42 + xl: "shadow-xl", 43 + }, 44 + focus: { 45 + ring: "focus-ring", 46 + }, 47 + }); 48 + 49 + // Light theme — default 50 + createGlobalTheme(":root, [data-theme='light']", vars, { 51 + color: lightColors, 52 + shadow: lightShadows, 53 + focus: { 54 + ring: "oklch(0.62 0.20 65 / 0.4)", 55 + }, 56 + }); 57 + 58 + // Dark theme — activated by data-theme attribute 59 + createGlobalTheme("[data-theme='dark']", vars, { 60 + color: darkColors, 61 + shadow: darkShadows, 62 + focus: { 63 + ring: "oklch(0.75 0.18 65 / 0.4)", 64 + }, 65 + }); 66 + 67 + // Handle first-visit users with dark OS preference and no stored theme 68 + globalStyle(":root:not([data-theme])", { 69 + "@media": { 70 + "(prefers-color-scheme: dark)": { 71 + vars: { 72 + [vars.color.bg]: darkColors.bg, 73 + [vars.color.surface]: darkColors.surface, 74 + [vars.color.surfaceHover]: darkColors.surfaceHover, 75 + [vars.color.elevated]: darkColors.elevated, 76 + [vars.color.border]: darkColors.border, 77 + [vars.color.borderSubtle]: darkColors.borderSubtle, 78 + [vars.color.text]: darkColors.text, 79 + [vars.color.textSecondary]: darkColors.textSecondary, 80 + [vars.color.textMuted]: darkColors.textMuted, 81 + [vars.color.heading]: darkColors.heading, 82 + [vars.color.link]: darkColors.link, 83 + [vars.color.linkHover]: darkColors.linkHover, 84 + [vars.color.accent]: darkColors.accent, 85 + [vars.color.accentHover]: darkColors.accentHover, 86 + [vars.color.accentActive]: darkColors.accentActive, 87 + [vars.color.accentText]: darkColors.accentText, 88 + [vars.color.accentSubtle]: darkColors.accentSubtle, 89 + [vars.color.success]: darkColors.success, 90 + [vars.color.successSubtle]: darkColors.successSubtle, 91 + [vars.color.warning]: darkColors.warning, 92 + [vars.color.warningSubtle]: darkColors.warningSubtle, 93 + [vars.color.error]: darkColors.error, 94 + [vars.color.errorSubtle]: darkColors.errorSubtle, 95 + [vars.color.code]: darkColors.code, 96 + [vars.shadow.highlight]: darkShadows.highlight, 97 + [vars.shadow.sm]: darkShadows.sm, 98 + [vars.shadow.md]: darkShadows.md, 99 + [vars.shadow.lg]: darkShadows.lg, 100 + [vars.shadow.xl]: darkShadows.xl, 101 + [vars.focus.ring]: "oklch(0.75 0.18 65 / 0.4)", 102 + }, 103 + }, 104 + }, 105 + });
+6
app/styles/tokens/breakpoints.ts
··· 1 + export const breakpoints = { 2 + sm: 640, 3 + md: 768, 4 + lg: 1024, 5 + xl: 1280, 6 + } as const;
+63
app/styles/tokens/colors.ts
··· 1 + // OKLCH color values — all neutrals use chroma 0, hue 0 (pure achromatic grey, no blue) 2 + 3 + export const darkColors = { 4 + bg: "oklch(0.13 0 0)", 5 + surface: "oklch(0.17 0 0)", 6 + surfaceHover: "oklch(0.20 0 0)", 7 + elevated: "oklch(0.21 0 0)", 8 + border: "oklch(0.25 0 0)", 9 + borderSubtle: "oklch(0.20 0 0)", 10 + text: "oklch(0.87 0 0)", 11 + textSecondary: "oklch(0.65 0 0)", 12 + textMuted: "oklch(0.45 0 0)", 13 + heading: "oklch(0.93 0 0)", 14 + 15 + link: "oklch(0.78 0.14 65)", 16 + linkHover: "oklch(0.85 0.16 65)", 17 + 18 + accent: "oklch(0.75 0.18 65)", 19 + accentHover: "oklch(0.70 0.20 65)", 20 + accentActive: "oklch(0.65 0.20 65)", 21 + accentText: "oklch(0.15 0 0)", 22 + accentSubtle: "oklch(0.22 0.04 65)", 23 + 24 + success: "oklch(0.72 0.17 150)", 25 + successSubtle: "oklch(0.22 0.04 150)", 26 + warning: "oklch(0.80 0.15 85)", 27 + warningSubtle: "oklch(0.22 0.04 85)", 28 + error: "oklch(0.70 0.19 25)", 29 + errorSubtle: "oklch(0.22 0.04 25)", 30 + 31 + code: "oklch(0.20 0 0)", 32 + } as const; 33 + 34 + export const lightColors = { 35 + bg: "oklch(0.975 0.005 90)", 36 + surface: "oklch(1.0 0 0)", 37 + surfaceHover: "oklch(0.97 0 0)", 38 + elevated: "oklch(1.0 0 0)", 39 + border: "oklch(0.87 0 0)", 40 + borderSubtle: "oklch(0.92 0 0)", 41 + text: "oklch(0.23 0 0)", 42 + textSecondary: "oklch(0.45 0 0)", 43 + textMuted: "oklch(0.62 0 0)", 44 + heading: "oklch(0.15 0 0)", 45 + 46 + link: "oklch(0.55 0.18 65)", 47 + linkHover: "oklch(0.48 0.20 65)", 48 + 49 + accent: "oklch(0.62 0.20 65)", 50 + accentHover: "oklch(0.57 0.22 65)", 51 + accentActive: "oklch(0.52 0.22 65)", 52 + accentText: "oklch(1.0 0 0)", 53 + accentSubtle: "oklch(0.95 0.04 65)", 54 + 55 + success: "oklch(0.55 0.19 150)", 56 + successSubtle: "oklch(0.95 0.04 150)", 57 + warning: "oklch(0.62 0.17 85)", 58 + warningSubtle: "oklch(0.96 0.04 85)", 59 + error: "oklch(0.55 0.22 25)", 60 + errorSubtle: "oklch(0.96 0.04 25)", 61 + 62 + code: "oklch(0.95 0.005 90)", 63 + } as const;
+12
app/styles/tokens/index.ts
··· 1 + export { darkColors, lightColors } from "./colors.ts"; 2 + export { space } from "./spacing.ts"; 3 + export { 4 + fontFamily, 5 + fontSize, 6 + fontWeight, 7 + lineHeight, 8 + letterSpacing, 9 + } from "./typography.ts"; 10 + export { lightShadows, darkShadows } from "./shadows.ts"; 11 + export { radii } from "./radii.ts"; 12 + export { breakpoints } from "./breakpoints.ts";
+7
app/styles/tokens/radii.ts
··· 1 + export const radii = { 2 + sm: "4px", 3 + md: "8px", 4 + lg: "12px", 5 + xl: "16px", 6 + full: "9999px", 7 + } as const;
+18
app/styles/tokens/shadows.ts
··· 1 + // Layered shadows — theme-dependent for visibility 2 + // `highlight` = inset top edge glow, simulates light from above 3 + 4 + export const lightShadows = { 5 + highlight: "inset 0 1px 0 0 oklch(1 0 0 / 0.5)", 6 + sm: "0 1px 2px oklch(0 0 0 / 0.05)", 7 + md: "0 2px 4px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04)", 8 + lg: "0 4px 8px oklch(0 0 0 / 0.08), 0 2px 4px oklch(0 0 0 / 0.04)", 9 + xl: "0 8px 16px oklch(0 0 0 / 0.1), 0 4px 8px oklch(0 0 0 / 0.05)", 10 + } as const; 11 + 12 + export const darkShadows = { 13 + highlight: "inset 0 1px 0 0 oklch(1 0 0 / 0.06)", 14 + sm: "0 1px 2px oklch(0 0 0 / 0.20)", 15 + md: "0 2px 4px oklch(0 0 0 / 0.25), 0 1px 2px oklch(0 0 0 / 0.15)", 16 + lg: "0 4px 8px oklch(0 0 0 / 0.30), 0 2px 4px oklch(0 0 0 / 0.15)", 17 + xl: "0 8px 16px oklch(0 0 0 / 0.40), 0 4px 8px oklch(0 0 0 / 0.20)", 18 + } as const;
+14
app/styles/tokens/spacing.ts
··· 1 + // 4px base unit, geometric scale 2 + 3 + export const space = { 4 + 0: "0", 5 + 1: "4px", 6 + 2: "8px", 7 + 3: "12px", 8 + 4: "16px", 9 + 5: "24px", 10 + 6: "32px", 11 + 7: "48px", 12 + 8: "64px", 13 + 9: "96px", 14 + } as const;
+39
app/styles/tokens/typography.ts
··· 1 + // System font stacks — no external fonts loaded 2 + 3 + export const fontFamily = { 4 + sans: 'Inter, Roboto, "Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif', 5 + mono: 'ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace', 6 + ui: "system-ui, sans-serif", 7 + } as const; 8 + 9 + // 1.25 ratio modular scale 10 + export const fontSize = { 11 + xs: "0.8rem", 12 + sm: "0.875rem", 13 + base: "1rem", 14 + md: "1.125rem", 15 + lg: "1.25rem", 16 + xl: "1.563rem", 17 + "2xl": "1.953rem", 18 + "3xl": "2.441rem", 19 + } as const; 20 + 21 + export const fontWeight = { 22 + normal: "400", 23 + medium: "500", 24 + semibold: "600", 25 + bold: "700", 26 + } as const; 27 + 28 + export const lineHeight = { 29 + tight: "1.15", 30 + snug: "1.3", 31 + normal: "1.5", 32 + relaxed: "1.625", 33 + } as const; 34 + 35 + export const letterSpacing = { 36 + tight: "-0.02em", 37 + normal: "0", 38 + wide: "0.02em", 39 + } as const;
+31
app/styles/utilities.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { space } from "./tokens/spacing.ts"; 3 + 4 + export const centerText = style({ 5 + textAlign: "center", 6 + paddingBlock: space[8], 7 + }); 8 + 9 + export const centerTextSm = style({ 10 + textAlign: "center", 11 + paddingBlock: space[6], 12 + }); 13 + 14 + export const centerBlock = style({ 15 + display: "flex", 16 + justifyContent: "center", 17 + paddingBlock: space[8], 18 + }); 19 + 20 + export const inlineCluster = style({ 21 + display: "flex", 22 + alignItems: "center", 23 + gap: space[2], 24 + }); 25 + 26 + export const plainList = style({ 27 + listStyle: "none", 28 + display: "flex", 29 + flexDirection: "column", 30 + gap: space[1], 31 + });
+8
app/styles/utils.ts
··· 1 + import { breakpoints } from "./tokens/breakpoints.ts"; 2 + 3 + export const mq = { 4 + sm: `screen and (min-width: ${breakpoints.sm}px)`, 5 + md: `screen and (min-width: ${breakpoints.md}px)`, 6 + lg: `screen and (min-width: ${breakpoints.lg}px)`, 7 + xl: `screen and (min-width: ${breakpoints.xl}px)`, 8 + } as const;
+3
bun.lock
··· 7 7 "dependencies": { 8 8 "@atproto/oauth-client-node": "^0.3.17", 9 9 "@vanilla-extract/css": "^1.20.1", 10 + "@vanilla-extract/sprinkles": "^1.6.5", 10 11 "@vanilla-extract/vite-plugin": "^5.2.2", 11 12 "drizzle-orm": "^0.45.2", 12 13 "hono": "^4.12.10", ··· 313 314 "@vanilla-extract/integration": ["@vanilla-extract/integration@8.0.9", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/plugin-syntax-typescript": "^7.23.3", "@vanilla-extract/babel-plugin-debug-ids": "^1.2.2", "@vanilla-extract/css": "^1.19.1", "dedent": "^1.5.3", "esbuild": "npm:esbuild@>=0.17.6 <0.28.0", "eval": "0.1.8", "find-up": "^5.0.0", "javascript-stringify": "^2.0.1", "mlly": "^1.4.2" } }, "sha512-NP+CSo5IYHDmkMMy5vAxY4R9i2+CAg4sxgvVaxuHiuY9q30i6dNUTujNNKZGW2urEkd4HVVI6NggeIyYjbGPwA=="], 314 315 315 316 "@vanilla-extract/private": ["@vanilla-extract/private@1.0.9", "", {}, "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA=="], 317 + 318 + "@vanilla-extract/sprinkles": ["@vanilla-extract/sprinkles@1.6.5", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA=="], 316 319 317 320 "@vanilla-extract/vite-plugin": ["@vanilla-extract/vite-plugin@5.2.2", "", { "dependencies": { "@vanilla-extract/compiler": "^0.7.0", "@vanilla-extract/integration": "^8.0.9" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-AUyB4fDR2b/Mo0lcXhhlf6RxnDPYwFMyKKopalJ4BwQNKYzZSoTwHJ1PLPO9SKhpz7lzXc0Z18GHQZOewzl3YA=="], 318 321
+90 -4
lib/lexicons/resolver.ts
··· 1 1 import { readFileSync, existsSync } from "node:fs"; 2 2 import { resolve as resolvePath } from "node:path"; 3 + import { resolveTxt } from "node:dns/promises"; 3 4 4 5 export type LexiconField = { 5 6 path: string; ··· 26 27 */ 27 28 export function nsidToAuthority(nsid: string): string { 28 29 const parts = nsid.split("."); 29 - return `${parts[1]}.${parts[0]}`; 30 + return parts.slice(0, -1).reverse().join("."); 30 31 } 31 32 32 33 /** ··· 127 128 } 128 129 } 129 130 130 - /** Try to fetch a lexicon from the authority domain. */ 131 + /** Try to fetch a lexicon from the authority domain via HTTP. */ 131 132 export async function resolveRemote( 132 133 nsid: string, 133 134 ): Promise<LexiconSchema | null> { ··· 159 160 return null; 160 161 } 161 162 162 - /** Resolve a lexicon by NSID. Tries local first, then remote. */ 163 + /** 164 + * Resolve a lexicon via the official AT Protocol mechanism: 165 + * 1. DNS TXT lookup at _lexicon.{authority} to find the DID 166 + * 2. DID resolution to find the PDS 167 + * 3. Fetch com.atproto.lexicon.schema record from the PDS 168 + * 169 + * See https://atproto.com/specs/lexicon#lexicon-publication-and-resolution 170 + */ 171 + export async function resolveViaAtproto( 172 + nsid: string, 173 + ): Promise<LexiconSchema | null> { 174 + const parts = nsid.split("."); 175 + // Authority = all segments except the last, reversed for DNS 176 + const authorityParts = parts.slice(0, -1).reverse(); 177 + const dnsName = `_lexicon.${authorityParts.join(".")}`; 178 + 179 + // Step 1: DNS TXT lookup 180 + let did: string | null = null; 181 + try { 182 + const records = await resolveTxt(dnsName); 183 + for (const record of records) { 184 + const txt = record.join(""); 185 + if (txt.startsWith("did=")) { 186 + did = txt.slice(4); 187 + break; 188 + } 189 + } 190 + } catch { 191 + return null; 192 + } 193 + if (!did) return null; 194 + 195 + // Step 2: DID resolution → PDS endpoint 196 + let pdsEndpoint: string | null = null; 197 + try { 198 + const res = await fetch( 199 + `https://plc.directory/${encodeURIComponent(did)}`, 200 + { signal: AbortSignal.timeout(10_000) }, 201 + ); 202 + if (!res.ok) return null; 203 + const doc = (await res.json()) as { 204 + service?: Array<{ id: string; serviceEndpoint: string }>; 205 + }; 206 + pdsEndpoint = 207 + doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? 208 + null; 209 + } catch { 210 + return null; 211 + } 212 + if (!pdsEndpoint) return null; 213 + 214 + // Step 3: Fetch the lexicon schema record 215 + try { 216 + const url = new URL(`${pdsEndpoint}/xrpc/com.atproto.repo.getRecord`); 217 + url.searchParams.set("repo", did); 218 + url.searchParams.set("collection", "com.atproto.lexicon.schema"); 219 + url.searchParams.set("rkey", nsid); 220 + 221 + const res = await fetch(url, { 222 + headers: { Accept: "application/json" }, 223 + signal: AbortSignal.timeout(10_000), 224 + }); 225 + if (!res.ok) return null; 226 + 227 + const data = (await res.json()) as { 228 + value?: Record<string, unknown>; 229 + }; 230 + const record = data.value; 231 + if (!record || record.id !== nsid) return null; 232 + 233 + return parseLexicon(nsid, record); 234 + } catch { 235 + return null; 236 + } 237 + } 238 + 239 + /** 240 + * Resolve a lexicon by NSID. 241 + * Tries: local files → AT Protocol DNS resolution → HTTP authority domain. 242 + */ 163 243 export async function resolve( 164 244 nsid: string, 165 245 ): Promise<LexiconSchema | null> { 166 - return resolveLocal(nsid) ?? resolveRemote(nsid); 246 + const local = resolveLocal(nsid); 247 + if (local) return local; 248 + 249 + const atproto = await resolveViaAtproto(nsid); 250 + if (atproto) return atproto; 251 + 252 + return resolveRemote(nsid); 167 253 }
+1
package.json
··· 12 12 "dependencies": { 13 13 "@atproto/oauth-client-node": "^0.3.17", 14 14 "@vanilla-extract/css": "^1.20.1", 15 + "@vanilla-extract/sprinkles": "^1.6.5", 15 16 "@vanilla-extract/vite-plugin": "^5.2.2", 16 17 "drizzle-orm": "^0.45.2", 17 18 "hono": "^4.12.10",
+45 -2
vite.config.ts
··· 2 2 import { request as httpRequest } from "node:http"; 3 3 import honox from "honox/vite"; 4 4 import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; 5 - import { defineConfig, type Plugin } from "vite"; 5 + import { defineConfig, type Plugin, type ViteDevServer } from "vite"; 6 + 7 + // Collect all CSS from the Vite module graph and serve at /__dev.css 8 + // This allows a blocking <link> tag in dev mode to prevent FOUC 9 + function devCss(): Plugin { 10 + return { 11 + name: "dev-css", 12 + apply: "serve", 13 + configureServer(server: ViteDevServer) { 14 + server.middlewares.use(async (req, res, next) => { 15 + if (req.url !== "/__dev.css") return next(); 16 + 17 + const chunks: string[] = []; 18 + for (const mod of server.moduleGraph.idToModuleMap.values()) { 19 + if (!mod.id?.includes(".vanilla.css")) continue; 20 + try { 21 + // Get the client-side transform — for CSS it contains the raw CSS 22 + // wrapped in a JS module. Extract the CSS string from it. 23 + const result = await server.transformRequest(mod.url); 24 + if (!result?.code) continue; 25 + // Vite wraps CSS as: const __vite__css = "...css..." 26 + // or similar patterns. Extract everything between the first ` = "` and the closing `"` 27 + const match = result.code.match( 28 + /(?:__vite__css|css)\s*=\s*"((?:[^"\\]|\\.)*)"/s, 29 + ); 30 + if (match?.[1]) { 31 + // Unescape the JS string 32 + const css = match[1] 33 + .replace(/\\n/g, "\n") 34 + .replace(/\\t/g, "\t") 35 + .replace(/\\"/g, '"') 36 + .replace(/\\\\/g, "\\"); 37 + chunks.push(css); 38 + } 39 + } catch {} 40 + } 41 + 42 + res.setHeader("Content-Type", "text/css"); 43 + res.setHeader("Cache-Control", "no-store"); 44 + res.end(chunks.join("\n")); 45 + }); 46 + }, 47 + }; 48 + } 6 49 7 50 const PDS_TARGET = "http://localhost:3000"; 8 51 const APP_ORIGIN = "http://127.0.0.1:5175"; ··· 139 182 port: 5175, 140 183 host: true, 141 184 }, 142 - plugins: [pdsProxy(), honox(), vanillaExtractPlugin()], 185 + plugins: [devCss(), pdsProxy(), honox(), vanillaExtractPlugin()], 143 186 resolve: { 144 187 alias: { 145 188 "@": "/lib",