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.

chore: review copy

Hugo be4de6e7 63319fe8

+646 -199
+4
app/icons.ts
··· 52 52 import ExternalLinkData from "lucide/icons/external-link"; 53 53 import GlobeData from "lucide/icons/globe"; 54 54 import ZapData from "lucide/icons/zap"; 55 + import CodeData from "lucide/icons/code"; 56 + import RepeatData from "lucide/icons/repeat"; 55 57 56 58 export const Activity = icon(ActivityData); 57 59 export const ArrowLeft = icon(ArrowLeftData); ··· 78 80 export const UserPlus = icon(UserPlusData); 79 81 export const Webhook = icon(WebhookData); 80 82 export const Zap = icon(ZapData); 83 + export const Code = icon(CodeData); 84 + export const Repeat = icon(RepeatData);
+3 -3
app/islands/AutomationForm.tsx
··· 969 969 {fieldsError && <span class={s.errorText}>{fieldsError}</span>} 970 970 {schemaUnresolved && ( 971 971 <div class={s.alertWarning}> 972 - This lexicon's schema could not be resolved. You can still set up your automation — 973 - field paths for conditions will need to be entered manually. 972 + This lexicon's schema could not be resolved. You can still set up your automation, 973 + but field paths for conditions will need to be entered manually. 974 974 </div> 975 975 )} 976 976 </div> ··· 1345 1345 <div class={s.catGroupHeader}> 1346 1346 <span class={s.catDot} data-cat={cat.id} /> 1347 1347 {cat.label} 1348 - <span class={s.catGroupHeaderDesc}>— {cat.description}</span> 1348 + <span class={s.catGroupHeaderDesc}>- {cat.description}</span> 1349 1349 </div> 1350 1350 <div class={s.catTileRow}> 1351 1351 {cat.actions.map((a) => {
+1 -1
app/islands/DeliveryLog.tsx
··· 191 191 class={s.toggleBtn} 192 192 onClick={toggleDryRun} 193 193 disabled={loading} 194 - title="Test mode — logs what would happen without actually running actions" 194 + title="Test mode - logs what would happen without actually running actions" 195 195 > 196 196 <FlaskConical size={14} /> {isDryRun ? "Disable Dry Run" : "Enable Dry Run"} 197 197 </button>
+89
app/islands/FlowAction.tsx
··· 1 + import { useState, useEffect } from "hono/jsx"; 2 + import type { Child } from "hono/jsx"; 3 + import { Webhook, FilePlus2, Pencil, MessageSquare } from "../icons.js"; 4 + import * as s from "../styles/pages/landing.css.ts"; 5 + 6 + type Action = { 7 + id: string; 8 + label: string; 9 + sub: string; 10 + Icon: typeof Webhook; 11 + line: Child; 12 + }; 13 + 14 + const ACTIONS: Action[] = [ 15 + { 16 + id: "webhook", 17 + label: "Send a webhook", 18 + sub: "POST signed payload to your URL", 19 + Icon: Webhook, 20 + line: ( 21 + <> 22 + <span class={s.m}>POST</span> https://your-app.com/hook 23 + </> 24 + ), 25 + }, 26 + { 27 + id: "record", 28 + label: "Create a record", 29 + sub: "on your PDS, in any collection", 30 + Icon: FilePlus2, 31 + line: ( 32 + <> 33 + <span class={s.m}>CREATE</span> site.standard.document 34 + </> 35 + ), 36 + }, 37 + { 38 + id: "bsky-post", 39 + label: "Post to Bluesky", 40 + sub: "from your Bluesky account", 41 + Icon: MessageSquare, 42 + line: <>"{`{{event.record.text}}`}" → bsky</>, 43 + }, 44 + { 45 + id: "patch-record", 46 + label: "Patch a record", 47 + sub: "update fields of an existing record", 48 + Icon: Pencil, 49 + line: ( 50 + <> 51 + <span class={s.m}>PATCH</span> rkey · {`{ counter: +1 }`} 52 + </> 53 + ), 54 + }, 55 + ]; 56 + 57 + export default function FlowAction() { 58 + const [i, setI] = useState(0); 59 + 60 + useEffect(() => { 61 + const id = setInterval(() => { 62 + setI((x) => (x + 1) % ACTIONS.length); 63 + }, 2400); 64 + return () => clearInterval(id); 65 + }, []); 66 + 67 + const action = ACTIONS[i] ?? ACTIONS[0]!; 68 + const ActionIcon = action.Icon; 69 + 70 + return ( 71 + <div class={`${s.flowCard} ${s.flowCardAction}`}> 72 + <div class={s.flowCardHead}> 73 + <span class={s.flowKind}>Action</span> 74 + <div class={s.flowDots}> 75 + {ACTIONS.map((a, k) => ( 76 + <span key={a.id} class={`${s.flowDot} ${k === i ? s.flowDotOn : ""}`} /> 77 + ))} 78 + </div> 79 + </div> 80 + <div class={s.flowCardBody} key={action.id}> 81 + <div class={`${s.flowCardTitle} ${s.flowActionAnim}`}> 82 + <ActionIcon size={18} /> {action.label} 83 + </div> 84 + <div class={s.flowCardSub}>{action.sub}</div> 85 + <div class={`${s.flowCardLine} ${s.flowActionAnim}`}>{action.line}</div> 86 + </div> 87 + </div> 88 + ); 89 + }
+1 -1
app/islands/RecordFormBuilder.tsx
··· 680 680 onChange={() => toggleField(key)} 681 681 /> 682 682 {key} 683 - {node.description && <span class={s.hint}> — {node.description}</span>} 683 + {node.description && <span class={s.hint}> - {node.description}</span>} 684 684 </label> 685 685 {enabled && ( 686 686 <FieldRenderer
+1 -1
app/routes/_404.tsx
··· 22 22 </div> 23 23 </Container> 24 24 </AppShell>, 25 - { title: "Not Found — Airglow" }, 25 + { title: "Not Found | Airglow" }, 26 26 ); 27 27 }; 28 28
+1 -1
app/routes/_renderer.tsx
··· 48 48 } 49 49 50 50 const defaultDescription = 51 - "Automate the AT Protocol and Bluesky — set up webhooks, create records, and filter Jetstream events by lexicon."; 51 + "Automation platform for Bluesky and the AT Protocol. Filter Jetstream events by lexicon, deliver webhooks, and create records on your PDS."; 52 52 const defaultOgImage = `${config.publicUrl}/og-image.png`; 53 53 54 54 export default jsxRenderer(({ children, title, description, ogImage }, c) => {
+2 -2
app/routes/auth/callback.tsx
··· 41 41 // Check for OAuth error response 42 42 if (params.has("error")) { 43 43 const description = params.get("error_description") || params.get("error") || "Unknown error"; 44 - return c.render(<ErrorPage message={description} />, { title: "Error — Airglow" }); 44 + return c.render(<ErrorPage message={description} />, { title: "Error | Airglow" }); 45 45 } 46 46 47 47 try { ··· 93 93 console.error("OAuth callback error:", err); 94 94 return c.render( 95 95 <ErrorPage message="Something went wrong during authentication. Please try again." />, 96 - { title: "Error — Airglow" }, 96 + { title: "Error | Airglow" }, 97 97 ); 98 98 } 99 99 });
+1 -1
app/routes/auth/login.tsx
··· 79 79 </Container> 80 80 </AppShell>, 81 81 { 82 - title: "Sign in — Airglow", 82 + title: "Sign in | Airglow", 83 83 description: 84 84 "Sign in to Airglow with your AT Protocol identity to create automations and webhooks.", 85 85 },
+5 -5
app/routes/dashboard/automations/[rkey].tsx
··· 46 46 <p>This automation does not exist.</p> 47 47 </Container> 48 48 </AppShell>, 49 - { title: "Not Found — Airglow" }, 49 + { title: "Not Found | Airglow" }, 50 50 ); 51 51 } 52 52 ··· 151 151 <InlineCode>{cond.field}</InlineCode>{" "} 152 152 {opLabels[cond.operator] ?? cond.operator}{" "} 153 153 <InlineCode>{cond.value}</InlineCode> 154 - {cond.comment && <span class={textMuted}> — {cond.comment}</span>} 154 + {cond.comment && <span class={textMuted}> - {cond.comment}</span>} 155 155 </li> 156 156 ))} 157 157 </ul> ··· 169 169 {auto.fetches.map((f, i) => ( 170 170 <li key={i}> 171 171 <InlineCode>{f.name}</InlineCode> &larr; <InlineCode>{f.uri}</InlineCode> 172 - {f.comment && <span class={textMuted}> — {f.comment}</span>} 172 + {f.comment && <span class={textMuted}> - {f.comment}</span>} 173 173 </li> 174 174 ))} 175 175 </ul> ··· 202 202 </Badge> 203 203 )} 204 204 {action.comment && ( 205 - <span class={actionHeaderSubtitle}>— {action.comment}</span> 205 + <span class={actionHeaderSubtitle}>- {action.comment}</span> 206 206 )} 207 207 </ActionHeader> 208 208 <DescriptionList> ··· 291 291 </Stack> 292 292 </Container> 293 293 </AppShell>, 294 - { title: `${auto.name} — Airglow` }, 294 + { title: `${auto.name} | Airglow` }, 295 295 ); 296 296 });
+2 -2
app/routes/dashboard/automations/[rkey]/duplicate.tsx
··· 36 36 <p>This automation does not exist.</p> 37 37 </Container> 38 38 </AppShell>, 39 - { title: "Not Found — Airglow" }, 39 + { title: "Not Found | Airglow" }, 40 40 ); 41 41 } 42 42 ··· 67 67 </Card> 68 68 </Container> 69 69 </AppShell>, 70 - { title: "Duplicate Automation — Airglow" }, 70 + { title: "Duplicate Automation | Airglow" }, 71 71 ); 72 72 });
+2 -2
app/routes/dashboard/automations/[rkey]/edit.tsx
··· 36 36 <p>This automation does not exist.</p> 37 37 </Container> 38 38 </AppShell>, 39 - { title: "Not Found — Airglow" }, 39 + { title: "Not Found | Airglow" }, 40 40 ); 41 41 } 42 42 ··· 68 68 </Card> 69 69 </Container> 70 70 </AppShell>, 71 - { title: `Edit ${auto.name} — Airglow` }, 71 + { title: `Edit ${auto.name} | Airglow` }, 72 72 ); 73 73 });
+1 -1
app/routes/dashboard/automations/new.tsx
··· 62 62 </Card> 63 63 </Container> 64 64 </AppShell>, 65 - { title: `${isDuplicate ? "Duplicate" : "New"} Automation — Airglow` }, 65 + { title: `${isDuplicate ? "Duplicate" : "New"} Automation | Airglow` }, 66 66 ); 67 67 });
+1 -1
app/routes/dashboard/index.tsx
··· 108 108 )} 109 109 </Container> 110 110 </AppShell>, 111 - { title: "Dashboard — Airglow" }, 111 + { title: "Dashboard | Airglow" }, 112 112 ); 113 113 });
+3 -3
app/routes/dashboard/secrets.tsx
··· 29 29 </Card> 30 30 </Container> 31 31 </AppShell>, 32 - { title: "Secrets — Airglow" }, 32 + { title: "Secrets | Airglow" }, 33 33 ); 34 34 } 35 35 ··· 45 45 <Container> 46 46 <PageHeader 47 47 title="Secrets" 48 - description={`${secrets.length} secret${secrets.length !== 1 ? "s" : ""} — encrypted, never shown after creation`} 48 + description={`${secrets.length} secret${secrets.length !== 1 ? "s" : ""}, encrypted, never shown after creation`} 49 49 /> 50 50 <Card variant="flat"> 51 51 <SecretsManager initial={initial} /> 52 52 </Card> 53 53 </Container> 54 54 </AppShell>, 55 - { title: "Secrets — Airglow" }, 55 + { title: "Secrets | Airglow" }, 56 56 ); 57 57 });
+167 -94
app/routes/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { desc, count } from "drizzle-orm"; 3 3 import { raw } from "hono/html"; 4 - import { Webhook, FilePlus2, Filter, Activity } from "../icons.js"; 4 + import { Code, Filter, Globe, Repeat } from "../icons.js"; 5 5 import { getSessionUser } from "@/auth/middleware.js"; 6 6 import { db } from "@/db/index.js"; 7 7 import { automations } from "@/db/schema.js"; ··· 12 12 import { Table } from "../components/Table/index.js"; 13 13 import { NsidCode } from "../components/NsidCode/index.js"; 14 14 import ThemeToggle from "../islands/ThemeToggle.js"; 15 + import FlowAction from "../islands/FlowAction.js"; 15 16 import * as s from "../styles/pages/landing.css.js"; 16 17 17 18 const jsonLd = raw( ··· 21 22 name: "Airglow", 22 23 url: "https://airglow.run", 23 24 description: 24 - "Automate the AT Protocol and Bluesky — webhooks, record creation, and Jetstream event filtering.", 25 + "Automations for the AT Protocol. Subscribe to Jetstream events, match a lexicon, and send a webhook, create a record, or post to Bluesky.", 25 26 applicationCategory: "DeveloperApplication", 26 27 operatingSystem: "Web", 27 28 })}</script>`, ··· 40 41 return c.render( 41 42 <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 42 43 {jsonLd} 43 - <Container> 44 - <section class={s.hero}> 45 - <h1 class={s.heroTitle}>Webhooks &amp; Automations for the AT Protocol</h1> 46 - <p class={s.heroSubtitle}> 47 - Automate Bluesky and the AT Protocol network. Listen to Jetstream events, filter by 48 - lexicon, deliver webhooks, create records on your PDS, and track every run. 49 - </p> 50 - {user ? ( 51 - <Button href="/dashboard">Go to Dashboard</Button> 52 - ) : ( 53 - <Button href="/auth/login">Get Started</Button> 54 - )} 55 - </section> 44 + 45 + <section class={s.hero}> 46 + <div class={s.bgGrid} aria-hidden="true" /> 47 + <Container> 48 + <div class={s.heroInner}> 49 + <h1 class={s.heroTitle}> 50 + Automations for the 51 + <br /> 52 + <span class={s.heroTitleAccent}>AT&nbsp;Protocol.</span> 53 + </h1> 54 + <p class={s.heroSubtitle}> 55 + Airglow listens to AT&nbsp;Protocol events for you. Pick a lexicon, set a few 56 + conditions, and send a webhook, create a record on your PDS, or post to Bluesky. 57 + </p> 58 + <div class={s.heroActions}> 59 + {user ? ( 60 + <Button href="/dashboard">Go to dashboard</Button> 61 + ) : ( 62 + <Button href="/auth/login">Get started</Button> 63 + )} 64 + <Button href="https://tangled.org/exosphere.site/airglow" variant="secondary"> 65 + <Code size={16} /> View source 66 + </Button> 67 + </div> 68 + </div> 69 + </Container> 70 + </section> 71 + 72 + <section class={s.flowSection}> 73 + <Container> 74 + <div class={s.sectionHead}> 75 + <div class={s.sectionEyebrow}>How it works</div> 76 + <h2 class={s.sectionTitle}>Three steps, any AT&nbsp;Protocol event.</h2> 77 + <p class={s.sectionSub}> 78 + Airglow subscribes to Jetstream, matches your filter, and runs the action you picked. 79 + </p> 80 + </div> 81 + 82 + <div class={s.flowDiagram}> 83 + <div class={s.flowCard}> 84 + <div class={s.flowCardHead}> 85 + <span class={s.flowKind}>Source</span> 86 + </div> 87 + <div class={s.flowCardBody}> 88 + <div class={s.flowCardTitle}> 89 + <Globe size={18} /> Jetstream 90 + </div> 91 + <div class={s.flowCardSub}>AT Protocol events</div> 92 + <div class={s.flowCardLine}> 93 + <span class={s.led} /> 94 + streaming events 95 + </div> 96 + </div> 97 + </div> 98 + 99 + <FlowArrow /> 56 100 57 - <section> 58 - <h2 class={s.stepsTitle}>Features</h2> 59 - <div class={s.features}> 60 - <div class={s.featureCard}> 61 - <div class={s.featureIcon}> 62 - <Webhook size={28} /> 101 + <div class={s.flowCard}> 102 + <div class={s.flowCardHead}> 103 + <span class={s.flowKind}>Filter</span> 104 + </div> 105 + <div class={s.flowCardBody}> 106 + <div class={s.flowCardTitle}> 107 + <Filter size={18} /> Match a lexicon 108 + </div> 109 + <div class={s.flowCardSub}>with field conditions</div> 110 + <div class={s.flowCode}> 111 + <div> 112 + <span class={s.codeOp}>eq</span>{" "} 113 + <span class={s.codeS}>site.standard.document</span> 114 + </div> 115 + <div> 116 + <span class={s.codeK}>author</span> <span class={s.codeOp}>eq</span>{" "} 117 + <span class={s.codeS}>airglow.run</span> 118 + </div> 119 + </div> 63 120 </div> 64 - <h3 class={s.featureTitle}>Webhook Delivery</h3> 65 - <p class={s.featureDesc}> 66 - Receive HTTP POST callbacks instantly when matching events occur on the AT Protocol 67 - network via Jetstream. 68 - </p> 69 121 </div> 70 - <div class={s.featureCard}> 122 + 123 + <FlowArrow /> 124 + 125 + <FlowAction /> 126 + </div> 127 + </Container> 128 + </section> 129 + 130 + <section class={s.features}> 131 + <Container> 132 + <div class={s.sectionHead}> 133 + <div class={s.sectionEyebrow}>Built to run</div> 134 + <h2 class={s.sectionTitle}>Three guarantees, no surprises.</h2> 135 + </div> 136 + <div class={s.featuresGrid}> 137 + <div class={s.feature}> 71 138 <div class={s.featureIcon}> 72 - <FilePlus2 size={28} /> 139 + <Filter size={18} /> 73 140 </div> 74 - <h3 class={s.featureTitle}>Record Creation</h3> 141 + <h3 class={s.featureTitle}>Precise conditions</h3> 75 142 <p class={s.featureDesc}> 76 - Automatically create records on your PDS when events match. Use templates with 77 - placeholders to build records from event data. 143 + Match on any record field with operators like <code>eq</code>, <code>contains</code> 144 + , and <code>starts</code>. 78 145 </p> 79 146 </div> 80 - <div class={s.featureCard}> 147 + <div class={s.feature}> 81 148 <div class={s.featureIcon}> 82 - <Filter size={28} /> 149 + <Repeat size={18} /> 83 150 </div> 84 - <h3 class={s.featureTitle}>Smart Filtering</h3> 151 + <h3 class={s.featureTitle}>Reliable delivery</h3> 85 152 <p class={s.featureDesc}> 86 - Listen to specific record types by NSID. Add field-level conditions with operators 87 - like equals, starts with, or contains. 153 + Every run is logged with its status and errors. Failed automations retry 154 + automatically. 88 155 </p> 89 156 </div> 90 - <div class={s.featureCard}> 157 + <div class={s.feature}> 91 158 <div class={s.featureIcon}> 92 - <Activity size={28} /> 159 + <Code size={18} /> 93 160 </div> 94 - <h3 class={s.featureTitle}>Delivery Tracking</h3> 161 + <h3 class={s.featureTitle}>Open source</h3> 95 162 <p class={s.featureDesc}> 96 - Full delivery log with status codes, retry attempts, and error details. Know exactly 97 - what happened with every event. 163 + MIT-licensed. Run it yourself or use the hosted version.{" "} 164 + <a href="https://tangled.org/exosphere.site/airglow">See the code →</a> 98 165 </p> 99 166 </div> 100 167 </div> 101 - </section> 168 + </Container> 169 + </section> 102 170 103 - {topLexicons.length > 0 && ( 104 - <section class={s.topLexicons}> 105 - <h2 class={s.stepsTitle}>Popular Lexicons</h2> 106 - <Table> 107 - <thead> 108 - <tr> 109 - <th>NSID</th> 110 - <th>Automations</th> 111 - </tr> 112 - </thead> 113 - <tbody> 114 - {topLexicons.map((row) => ( 115 - <tr key={row.lexicon}> 116 - <td> 117 - <a href={`/lexicons/${row.lexicon}`}> 118 - <NsidCode>{row.lexicon}</NsidCode> 119 - </a> 120 - </td> 121 - <td>{row.count}</td> 171 + {topLexicons.length > 0 && ( 172 + <section class={s.lexiconsWrap} id="lexicons"> 173 + <Container> 174 + <div class={s.lexiconsGrid}> 175 + <div> 176 + <div class={s.sectionEyebrow}>The ecosystem</div> 177 + <h2 class={s.lexiconsIntroTitle}>Popular lexicons</h2> 178 + <p class={s.lexiconsIntroText}> 179 + Airglow works with any lexicon: Bluesky, community, or your own. Here are a few 180 + that people automate against. 181 + </p> 182 + </div> 183 + <Table> 184 + <thead> 185 + <tr> 186 + <th>NSID</th> 187 + <th class={s.countCell}>Automations</th> 122 188 </tr> 123 - ))} 124 - </tbody> 125 - </Table> 126 - </section> 127 - )} 128 - 129 - <section class={s.steps}> 130 - <h2 class={s.stepsTitle}>How It Works</h2> 131 - <ol class={s.stepsList}> 132 - <li class={s.step}> 133 - <div class={s.stepNumber}>1</div> 134 - <h3 class={s.stepTitle}>Sign in</h3> 135 - <p class={s.stepDesc}>Authenticate with your AT Protocol identity via OAuth.</p> 136 - </li> 137 - <li class={s.step}> 138 - <div class={s.stepNumber}>2</div> 139 - <h3 class={s.stepTitle}>Automate</h3> 140 - <p class={s.stepDesc}> 141 - Choose a lexicon, set conditions, and add actions like webhooks or record creation. 142 - </p> 143 - </li> 144 - <li class={s.step}> 145 - <div class={s.stepNumber}>3</div> 146 - <h3 class={s.stepTitle}>Receive</h3> 147 - <p class={s.stepDesc}> 148 - Get signed webhook deliveries and automatic record creation in real time with 149 - retries. 150 - </p> 151 - </li> 152 - </ol> 189 + </thead> 190 + <tbody> 191 + {topLexicons.map((row) => ( 192 + <tr key={row.lexicon}> 193 + <td> 194 + <a href={`/lexicons/${row.lexicon}`}> 195 + <NsidCode>{row.lexicon}</NsidCode> 196 + </a> 197 + </td> 198 + <td class={s.countCell}>{row.count}</td> 199 + </tr> 200 + ))} 201 + </tbody> 202 + </Table> 203 + </div> 204 + </Container> 153 205 </section> 154 - </Container> 206 + )} 155 207 </AppShell>, 156 208 { 157 - title: "Airglow — Webhooks & Automations for the AT Protocol", 209 + title: "Airglow | Automations for the AT Protocol", 158 210 description: 159 - "Automate Bluesky and the AT Protocol. Set up webhooks, create records, and filter Jetstream events by lexicon.", 211 + "Automations for the AT Protocol. Subscribe to Jetstream events, match a lexicon, and send a webhook, create a record, or post to Bluesky.", 160 212 }, 161 213 ); 162 214 }); 215 + 216 + function FlowArrow() { 217 + return ( 218 + <div class={s.flowArrow} aria-hidden="true"> 219 + <svg viewBox="0 0 100 12" preserveAspectRatio="none"> 220 + <line 221 + x1="0" 222 + y1="6" 223 + x2="100" 224 + y2="6" 225 + stroke="var(--color-border)" 226 + stroke-width="1" 227 + stroke-dasharray="3 4" 228 + vector-effect="non-scaling-stroke" 229 + /> 230 + </svg> 231 + <span class={s.faDot} /> 232 + <span class={`${s.faDot} ${s.faDot2}`} /> 233 + </div> 234 + ); 235 + }
+2 -2
app/routes/lexicons/[nsid].tsx
··· 38 38 </div> 39 39 </Container> 40 40 </AppShell>, 41 - { title: "Not Found — Airglow" }, 41 + { title: "Not Found | Airglow" }, 42 42 ); 43 43 } 44 44 ··· 179 179 </Container> 180 180 </AppShell>, 181 181 { 182 - title: `${nsid} — Airglow`, 182 + title: `${nsid} | Airglow`, 183 183 description: description 184 184 ? `${description.replace(/\.?$/, ".")} Browse automations for ${nsid} on Airglow.` 185 185 : `Browse automations using the ${nsid} AT Protocol lexicon on Airglow.`,
+2 -2
app/routes/lexicons/index.tsx
··· 55 55 </Container> 56 56 </AppShell>, 57 57 { 58 - title: "AT Protocol Lexicons — Airglow", 58 + title: "AT Protocol Lexicons | Airglow", 59 59 description: 60 - "Browse AT Protocol lexicons with active automations on Airglow. Discover webhooks and automations for Bluesky and the AT Protocol network.", 60 + "Browse AT Protocol lexicons with active automations on Airglow. Discover webhooks and automations for Bluesky and the AT Protocol.", 61 61 }, 62 62 ); 63 63 });
+6 -6
app/routes/u/[handle]/[rkey].tsx
··· 61 61 </div> 62 62 </Container> 63 63 </AppShell>, 64 - { title: "Not Found — Airglow" }, 64 + { title: "Not Found | Airglow" }, 65 65 ); 66 66 } 67 67 ··· 83 83 </div> 84 84 </Container> 85 85 </AppShell>, 86 - { title: "Not Found — Airglow" }, 86 + { title: "Not Found | Airglow" }, 87 87 ); 88 88 } 89 89 ··· 165 165 <InlineCode>{cond.field}</InlineCode>{" "} 166 166 {opLabels[cond.operator] ?? cond.operator}{" "} 167 167 <InlineCode>{cond.value}</InlineCode> 168 - {cond.comment && <span class={textMuted}> — {cond.comment}</span>} 168 + {cond.comment && <span class={textMuted}> - {cond.comment}</span>} 169 169 </li> 170 170 ))} 171 171 </ul> ··· 183 183 {auto.fetches.map((f, i) => ( 184 184 <li key={i}> 185 185 <InlineCode>{f.name}</InlineCode> &larr; <InlineCode>{f.uri}</InlineCode> 186 - {f.comment && <span class={textMuted}> — {f.comment}</span>} 186 + {f.comment && <span class={textMuted}> - {f.comment}</span>} 187 187 </li> 188 188 ))} 189 189 </ul> ··· 216 216 </Badge> 217 217 )} 218 218 {action.comment && ( 219 - <span class={actionHeaderSubtitle}>— {action.comment}</span> 219 + <span class={actionHeaderSubtitle}>- {action.comment}</span> 220 220 )} 221 221 </ActionHeader> 222 222 <DescriptionList> ··· 276 276 </Stack> 277 277 </Container> 278 278 </AppShell>, 279 - { title: `${auto.name} — @${handle} — Airglow` }, 279 + { title: `${auto.name} | @${handle} | Airglow` }, 280 280 ); 281 281 });
+3 -3
app/routes/u/[handle]/index.tsx
··· 97 97 </div> 98 98 </Container> 99 99 </AppShell>, 100 - { title: "Not Found — Airglow" }, 100 + { title: "Not Found | Airglow" }, 101 101 ); 102 102 } 103 103 ··· 217 217 </Container> 218 218 </AppShell>, 219 219 { 220 - title: `@${profileUser?.handle ?? handle} — Airglow`, 221 - description: `See @${profileUser?.handle ?? handle}'s automations and lexicons on Airglow — automation platform for the AT Protocol.`, 220 + title: `@${profileUser?.handle ?? handle} | Airglow`, 221 + description: `See @${profileUser?.handle ?? handle}'s automations and lexicons on Airglow, automation platform for Bluesky and the AT Protocol.`, 222 222 }, 223 223 ); 224 224 });
+349 -68
app/styles/pages/landing.css.ts
··· 1 - import { style } from "@vanilla-extract/css"; 1 + import { style, keyframes, globalStyle } from "@vanilla-extract/css"; 2 2 import { vars } from "../theme.css.ts"; 3 3 import { space } from "../tokens/spacing.ts"; 4 4 import { fontSize, fontWeight, letterSpacing, lineHeight } from "../tokens/typography.ts"; 5 5 import { radii } from "../tokens/radii.ts"; 6 6 import { mq } from "../utils.ts"; 7 7 8 + const gridStep = "clamp(40px, 5.4vw, 56px)"; 9 + 10 + const gridFg = `color-mix(in oklch, ${vars.color.heading} 5%, transparent)`; 11 + 12 + /* ---------- hero ---------- */ 13 + 8 14 export const hero = style({ 15 + position: "relative", 9 16 textAlign: "center", 10 - paddingBlock: space[8], 17 + paddingBlock: "64px 48px", 11 18 "@media": { 12 19 [mq.md]: { 13 - paddingBlock: space[9], 20 + paddingBlock: "96px 64px", 14 21 }, 15 22 }, 16 23 }); 17 24 25 + export const bgGrid = style({ 26 + position: "absolute", 27 + inset: 0, 28 + backgroundImage: ` 29 + linear-gradient(${gridFg} 1px, transparent 1px), 30 + linear-gradient(90deg, ${gridFg} 1px, transparent 1px) 31 + `, 32 + backgroundSize: `${gridStep} ${gridStep}`, 33 + backgroundPosition: "center top", 34 + maskImage: "radial-gradient(ellipse 80% 60% at 50% 35%, #000 20%, transparent 80%)", 35 + WebkitMaskImage: "radial-gradient(ellipse 80% 60% at 50% 35%, #000 20%, transparent 80%)", 36 + pointerEvents: "none", 37 + zIndex: 0, 38 + }); 39 + 40 + export const heroInner = style({ 41 + position: "relative", 42 + zIndex: 1, 43 + maxInlineSize: "42rem", 44 + marginInline: "auto", 45 + }); 46 + 18 47 export const heroTitle = style({ 19 - fontSize: fontSize["2xl"], 48 + fontSize: "clamp(2rem, 4.2vw, 3rem)", 20 49 fontWeight: fontWeight.bold, 21 50 letterSpacing: letterSpacing.tight, 22 - lineHeight: lineHeight.tight, 23 - marginBlockEnd: space[4], 24 - "@media": { 25 - [mq.md]: { 26 - fontSize: fontSize["3xl"], 27 - }, 28 - }, 51 + lineHeight: gridStep, 52 + marginBlockEnd: gridStep, 53 + color: vars.color.heading, 54 + }); 55 + 56 + export const heroTitleAccent = style({ 57 + color: vars.color.accent, 29 58 }); 30 59 31 60 export const heroSubtitle = style({ 32 61 fontSize: fontSize.md, 33 62 color: vars.color.textSecondary, 63 + lineHeight: lineHeight.relaxed, 64 + maxInlineSize: "34rem", 65 + marginInline: "auto", 66 + textWrap: "pretty", 67 + }); 68 + 69 + export const heroActions = style({ 70 + display: "flex", 71 + gap: space[3], 72 + marginBlockStart: space[6], 73 + flexWrap: "wrap", 74 + alignItems: "center", 75 + justifyContent: "center", 76 + }); 77 + 78 + /* ---------- section head ---------- */ 79 + 80 + export const sectionHead = style({ 81 + textAlign: "center", 82 + marginBlockEnd: space[7], 34 83 maxInlineSize: "40rem", 35 84 marginInline: "auto", 85 + }); 86 + 87 + export const sectionEyebrow = style({ 88 + display: "inline-block", 89 + fontSize: fontSize.xs, 90 + textTransform: "uppercase", 91 + letterSpacing: "0.14em", 92 + fontWeight: fontWeight.semibold, 93 + color: vars.color.accent, 94 + marginBlockEnd: space[3], 95 + }); 96 + 97 + export const sectionTitle = style({ 98 + fontSize: "clamp(1.5rem, 3vw, 2rem)", 99 + fontWeight: fontWeight.bold, 100 + letterSpacing: letterSpacing.tight, 101 + marginBlockEnd: space[3], 102 + color: vars.color.heading, 103 + }); 104 + 105 + export const sectionSub = style({ 106 + color: vars.color.textSecondary, 107 + fontSize: fontSize.base, 36 108 lineHeight: lineHeight.relaxed, 37 - marginBlockEnd: space[6], 109 + textWrap: "pretty", 110 + }); 111 + 112 + /* ---------- flow section ---------- */ 113 + 114 + export const flowSection = style({ 115 + paddingBlock: "48px 96px", 116 + position: "relative", 117 + }); 118 + 119 + export const flowDiagram = style({ 120 + display: "grid", 121 + gridTemplateColumns: "1fr", 122 + gridTemplateRows: "auto", 123 + gap: space[4], 124 + alignItems: "stretch", 125 + "@media": { 126 + [mq.md]: { 127 + gridTemplateColumns: "minmax(0, 1fr) 40px minmax(0, 1fr) 40px minmax(0, 1fr)", 128 + gap: 0, 129 + }, 130 + }, 131 + }); 132 + 133 + globalStyle(`${flowDiagram} honox-island`, { 134 + display: "contents", 135 + }); 136 + 137 + export const flowCard = style({ 138 + background: vars.color.surface, 139 + border: `1px solid ${vars.color.border}`, 140 + borderRadius: "14px", 141 + boxShadow: `${vars.shadow.highlight}, ${vars.shadow.md}`, 142 + padding: "18px 20px 20px", 143 + display: "flex", 144 + flexDirection: "column", 145 + gap: space[2], 146 + }); 147 + 148 + export const flowCardAction = style({ 149 + borderColor: `color-mix(in oklch, ${vars.color.accent} 25%, ${vars.color.border})`, 150 + position: "relative", 151 + }); 152 + 153 + export const flowCardHead = style({ 154 + display: "flex", 155 + alignItems: "center", 156 + justifyContent: "space-between", 157 + }); 158 + 159 + export const flowKind = style({ 160 + fontFamily: "ui-monospace, Menlo, Consolas, monospace", 161 + fontSize: "0.7rem", 162 + color: vars.color.textMuted, 163 + textTransform: "uppercase", 164 + letterSpacing: "0.08em", 165 + }); 166 + 167 + export const flowDots = style({ 168 + display: "flex", 169 + gap: "4px", 170 + }); 171 + 172 + export const flowDot = style({ 173 + inlineSize: "6px", 174 + blockSize: "6px", 175 + borderRadius: "99px", 176 + background: vars.color.border, 177 + transition: "background-color 220ms ease, transform 220ms ease", 178 + }); 179 + 180 + export const flowDotOn = style({ 181 + background: vars.color.accent, 182 + transform: "scale(1.25)", 183 + }); 184 + 185 + export const flowCardBody = style({ 186 + display: "flex", 187 + flexDirection: "column", 188 + gap: "6px", 189 + flex: 1, 190 + }); 191 + 192 + export const flowCardTitle = style({ 193 + fontSize: "1.05rem", 194 + fontWeight: fontWeight.semibold, 195 + color: vars.color.heading, 196 + display: "inline-flex", 197 + alignItems: "center", 198 + gap: space[2], 199 + }); 200 + 201 + globalStyle(`${flowCardTitle} svg`, { 202 + color: vars.color.accent, 203 + }); 204 + 205 + export const flowCardSub = style({ 206 + fontSize: fontSize.sm, 207 + color: vars.color.textMuted, 208 + }); 209 + 210 + export const flowCardLine = style({ 211 + marginBlockStart: "auto", 212 + background: vars.color.code, 213 + borderRadius: "6px", 214 + padding: "7px 10px", 215 + fontFamily: "ui-monospace, Menlo, Consolas, monospace", 216 + fontSize: "0.78rem", 217 + color: vars.color.textSecondary, 218 + overflow: "hidden", 219 + textOverflow: "ellipsis", 220 + whiteSpace: "nowrap", 221 + }); 222 + 223 + const ledPulse = keyframes({ 224 + "0%, 100%": { opacity: 1 }, 225 + "50%": { opacity: 0.5 }, 226 + }); 227 + 228 + export const led = style({ 229 + display: "inline-block", 230 + inlineSize: "7px", 231 + blockSize: "7px", 232 + borderRadius: "99px", 233 + background: vars.color.success, 234 + marginInlineEnd: space[2], 235 + boxShadow: `0 0 8px color-mix(in oklch, ${vars.color.success} 60%, transparent)`, 236 + animation: `${ledPulse} 2s ease-in-out infinite`, 237 + "@media": { 238 + "(prefers-reduced-motion: reduce)": { 239 + animation: "none", 240 + }, 241 + }, 242 + }); 243 + 244 + export const m = style({ 245 + color: vars.color.accent, 246 + fontWeight: fontWeight.bold, 247 + }); 248 + 249 + export const flowCode = style({ 250 + marginBlockStart: "auto", 251 + background: vars.color.code, 252 + borderRadius: "6px", 253 + padding: "8px 10px", 254 + fontFamily: "ui-monospace, Menlo, Consolas, monospace", 255 + fontSize: "0.78rem", 256 + color: vars.color.textSecondary, 257 + display: "flex", 258 + flexDirection: "column", 259 + gap: "3px", 260 + }); 261 + 262 + export const codeK = style({ color: vars.color.textMuted }); 263 + export const codeOp = style({ color: vars.color.accent }); 264 + export const codeS = style({ color: vars.color.heading }); 265 + 266 + const actionFade = keyframes({ 267 + from: { opacity: 0, transform: "translateY(4px)" }, 268 + to: { opacity: 1, transform: "translateY(0)" }, 269 + }); 270 + 271 + export const flowActionAnim = style({ 272 + animation: `${actionFade} 320ms ease-out`, 273 + "@media": { 274 + "(prefers-reduced-motion: reduce)": { 275 + animation: "none", 276 + }, 277 + }, 278 + }); 279 + 280 + /* ---------- flow arrow ---------- */ 281 + 282 + export const flowArrow = style({ 283 + display: "none", 284 + alignSelf: "center", 285 + inlineSize: "100%", 286 + blockSize: "24px", 287 + position: "relative", 38 288 "@media": { 39 289 [mq.md]: { 40 - fontSize: fontSize.lg, 290 + display: "block", 41 291 }, 42 292 }, 43 293 }); 44 294 295 + globalStyle(`${flowArrow} svg`, { 296 + width: "100%", 297 + height: "100%", 298 + display: "block", 299 + }); 300 + 301 + const faTravel = keyframes({ 302 + "0%": { left: "0%", opacity: 0 }, 303 + "10%": { opacity: 1 }, 304 + "90%": { opacity: 1 }, 305 + "100%": { left: "100%", opacity: 0 }, 306 + }); 307 + 308 + export const faDot = style({ 309 + position: "absolute", 310 + top: "50%", 311 + left: 0, 312 + inlineSize: "6px", 313 + blockSize: "6px", 314 + borderRadius: "99px", 315 + background: vars.color.accent, 316 + transform: "translate(-50%, -50%)", 317 + boxShadow: `0 0 8px color-mix(in oklch, ${vars.color.accent} 50%, transparent)`, 318 + animation: `${faTravel} 2s linear infinite`, 319 + "@media": { 320 + "(prefers-reduced-motion: reduce)": { 321 + animation: "none", 322 + left: "50%", 323 + opacity: 0.6, 324 + }, 325 + }, 326 + }); 327 + 328 + export const faDot2 = style({ 329 + animationDelay: "0.7s", 330 + opacity: 0.6, 331 + }); 332 + 333 + /* ---------- features ---------- */ 334 + 45 335 export const features = style({ 336 + paddingBlock: "80px", 337 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 338 + }); 339 + 340 + export const featuresGrid = style({ 46 341 display: "grid", 47 342 gridTemplateColumns: "1fr", 48 343 gap: space[5], 49 - paddingBlock: space[7], 50 - borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 51 344 "@media": { 52 345 [mq.md]: { 53 - gridTemplateColumns: "repeat(2, 1fr)", 54 - }, 55 - [mq.lg]: { 56 - gridTemplateColumns: "repeat(4, 1fr)", 346 + gridTemplateColumns: "1fr 1fr 1fr", 57 347 }, 58 348 }, 59 349 }); 60 350 61 - export const featureCard = style({ 62 - paddingBlock: space[5], 63 - paddingInline: space[5], 351 + export const feature = style({ 352 + padding: "22px 22px 24px", 353 + background: vars.color.surface, 354 + border: `1px solid ${vars.color.borderSubtle}`, 64 355 borderRadius: radii.lg, 65 - border: `1px solid ${vars.color.borderSubtle}`, 66 - backgroundColor: vars.color.surface, 356 + display: "flex", 357 + flexDirection: "column", 358 + gap: space[3], 67 359 }); 68 360 69 361 export const featureIcon = style({ 362 + inlineSize: "34px", 363 + blockSize: "34px", 364 + borderRadius: radii.md, 365 + background: vars.color.accentSubtle, 70 366 color: vars.color.accent, 71 - marginBlockEnd: space[3], 367 + display: "grid", 368 + placeItems: "center", 369 + marginBlockEnd: space[1], 72 370 }); 73 371 74 372 export const featureTitle = style({ 75 373 fontSize: fontSize.base, 76 374 fontWeight: fontWeight.semibold, 77 - marginBlockEnd: space[2], 375 + color: vars.color.heading, 78 376 }); 79 377 80 378 export const featureDesc = style({ 81 - fontSize: fontSize.sm, 379 + fontSize: "0.9rem", 82 380 color: vars.color.textSecondary, 83 - lineHeight: lineHeight.relaxed, 381 + lineHeight: "1.55", 382 + margin: 0, 84 383 }); 85 384 86 - export const steps = style({ 87 - paddingBlock: space[7], 385 + /* ---------- lexicons ---------- */ 386 + 387 + export const lexiconsWrap = style({ 88 388 borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 89 - }); 90 - 91 - export const stepsTitle = style({ 92 - textAlign: "center", 93 - marginBlockEnd: space[6], 389 + paddingBlock: "80px", 94 390 }); 95 391 96 - export const stepsList = style({ 392 + export const lexiconsGrid = style({ 97 393 display: "grid", 98 394 gridTemplateColumns: "1fr", 99 - gap: space[5], 100 - maxInlineSize: "48rem", 101 - marginInline: "auto", 102 - listStyle: "none", 395 + gap: space[6], 396 + alignItems: "start", 103 397 "@media": { 104 - [mq.md]: { 105 - gridTemplateColumns: "repeat(3, 1fr)", 398 + [mq.lg]: { 399 + gridTemplateColumns: "1fr minmax(0, 1.3fr)", 400 + gap: "56px", 106 401 }, 107 402 }, 108 403 }); 109 404 110 - export const step = style({ 111 - textAlign: "center", 112 - }); 113 - 114 - export const stepNumber = style({ 115 - display: "inline-flex", 116 - alignItems: "center", 117 - justifyContent: "center", 118 - inlineSize: "2rem", 119 - blockSize: "2rem", 120 - borderRadius: radii.full, 121 - backgroundColor: vars.color.accentSubtle, 122 - color: vars.color.accent, 123 - fontWeight: fontWeight.bold, 124 - fontSize: fontSize.sm, 405 + export const lexiconsIntroTitle = style({ 406 + fontSize: fontSize.xl, 407 + fontWeight: fontWeight.semibold, 125 408 marginBlockEnd: space[3], 126 - }); 127 - 128 - export const stepTitle = style({ 129 - fontSize: fontSize.base, 130 - fontWeight: fontWeight.semibold, 131 - marginBlockEnd: space[1], 409 + color: vars.color.heading, 132 410 }); 133 411 134 - export const stepDesc = style({ 135 - fontSize: fontSize.sm, 412 + export const lexiconsIntroText = style({ 136 413 color: vars.color.textSecondary, 414 + fontSize: "0.95rem", 415 + lineHeight: lineHeight.relaxed, 416 + marginBlockEnd: space[4], 137 417 }); 138 418 139 - export const topLexicons = style({ 140 - paddingBlock: space[7], 141 - borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 419 + export const countCell = style({ 420 + fontVariantNumeric: "tabular-nums", 421 + color: vars.color.textSecondary, 422 + textAlign: "end", 142 423 });