diff --git a/.env.example b/.env.example index 9d62cb4..94ea36f 100644 --- a/.env.example +++ b/.env.example @@ -34,10 +34,6 @@ NSID_BLOCKLIST= # declare a non-empty wantedDids list. Use for high-volume collections # where a firehose-wide subscription would be too noisy. NSID_REQUIRES_DIDS= -# Hand-picked automation AT URIs, comma-separated. Shown on the homepage -# and pinned at the top of the gallery tagged "Featured". -# Example: at://did:plc:abc/run.airglow.automation/xyz,at://did:plc:def/run.airglow.automation/abc -FEATURED_AUTOMATIONS= # Bootstrap admin DIDs, comma-separated. Inserted into the `admins` table on # startup if missing. Admins grant Partner App status via /dashboard/admin. ADMIN_DIDS= diff --git a/app/components/AppLogo/index.tsx b/app/components/AppLogo/index.tsx index d88dd5c..5711f77 100644 --- a/app/components/AppLogo/index.tsx +++ b/app/components/AppLogo/index.tsx @@ -2,22 +2,34 @@ import { logoUrl, type LogoTarget } from "../../../lib/apps/registry.ts"; import { Favicon } from "../Favicon/index.tsx"; import * as s from "./styles.css.ts"; +type LogoSize = "lg" | "sm"; + +// Per-size class triplet (chip, app/favicon image, action glyph) and the +// intrinsic image dimensions written to the attributes. +const sizes = { + lg: { chip: s.chipLg, img: s.imgLg, icon: s.iconLg, px: 40 }, + sm: { chip: s.chipSm, img: s.imgSm, icon: s.iconSm, px: 16 }, +} as const; + /** Renders one logo slot from a resolved {@link LogoTarget}, applying the * theme-safe fallback chain: vendored SVG on a neutral chip, else a favicon on * the same chip, else a generic typed pill. Every variant carries an * accessible name (alt / aria-label / visible text) so a screen reader can - * announce the source and each output. */ -export function AppLogo({ target }: { target: LogoTarget }) { + * announce the source and each output. `size` picks the showcase chip (`lg`, + * default) or the compact card-head chip (`sm`). */ +export function AppLogo({ target, size = "lg" }: { target: LogoTarget; size?: LogoSize }) { + const c = sizes[size]; + if (target.kind === "app") { return ( - + {target.label} ); @@ -27,8 +39,8 @@ export function AppLogo({ target }: { target: LogoTarget }) { // Favicon's has alt="" (decorative); the chip carries the name so the // logo is still announced even if the favicon fails to load and self-hides. return ( - - + + ); } @@ -38,8 +50,8 @@ export function AppLogo({ target }: { target: LogoTarget }) { // stays legible on the always-white chip in both themes. const Icon = target.icon; return ( - - + + ); } diff --git a/app/components/AppLogo/styles.css.ts b/app/components/AppLogo/styles.css.ts index 909c339..9863224 100644 --- a/app/components/AppLogo/styles.css.ts +++ b/app/components/AppLogo/styles.css.ts @@ -17,8 +17,17 @@ const chipBase = style({ export const chipLg = style([chipBase, { inlineSize: "52px", blockSize: "52px" }]); +// Small variant for the automation card head: same white chip so monochrome +// marks stay legible in dark mode, sized to sit inline with the lexicon chip. +export const chipSm = style([ + chipBase, + { inlineSize: "24px", blockSize: "24px", borderRadius: radii.md }, +]); + export const imgLg = style({ inlineSize: "32px", blockSize: "32px" }); +export const imgSm = style({ inlineSize: "16px", blockSize: "16px" }); + // Generic action glyph (webhook, notification, clear-scheduled). A monochrome // lucide icon on the same white chip as the app logos. The ink is a fixed dark // neutral rather than a theme token, because the chip is always white in both @@ -26,3 +35,5 @@ export const imgLg = style({ inlineSize: "32px", blockSize: "32px" }); const iconInk = "oklch(0.28 0.01 260)"; export const iconLg = style({ inlineSize: "26px", blockSize: "26px", color: iconInk }); + +export const iconSm = style({ inlineSize: "14px", blockSize: "14px", color: iconInk }); diff --git a/app/components/AppLogo/targets.test.ts b/app/components/AppLogo/targets.test.ts new file mode 100644 index 0000000..6563c18 --- /dev/null +++ b/app/components/AppLogo/targets.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { + makeBskyPostAction, + makeSembleSaveAction, + makeWebhookAction, + makeRecordAction, +} from "../../../lib/test/fixtures.js"; +import { outputLogoTargets, resolveFlowTargets } from "./targets.js"; + +describe("outputLogoTargets", () => { + it("resolves a 1:n flow to one app logo per distinct output (R8)", () => { + const targets = outputLogoTargets([makeBskyPostAction(), makeSembleSaveAction()]); + expect(targets.map((t) => (t.kind === "app" ? t.app.id : t.kind))).toEqual([ + "bluesky", + "semble", + ]); + }); + + it("collapses duplicate output apps to one logo", () => { + const targets = outputLogoTargets([makeBskyPostAction(), makeBskyPostAction()]); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ kind: "app", label: "Bluesky" }); + }); + + it("renders a non-app output as the action's lucide glyph, never text (R9)", () => { + const targets = outputLogoTargets([makeWebhookAction()]); + expect(targets).toHaveLength(1); + const [t] = targets; + expect(t).toMatchObject({ kind: "icon", label: "Webhook" }); + // The glyph is a renderable component, not a text label. + expect(typeof (t as { icon: unknown }).icon).toBe("function"); + }); + + it("falls back to a favicon for an app-less record collection", () => { + const targets = outputLogoTargets([ + makeRecordAction({ targetCollection: "com.example.widget.thing" }), + ]); + expect(targets).toEqual([{ kind: "favicon", domain: "example.com", label: "example.com" }]); + }); +}); + +// The card head and the Showcase row both go through this, with different caps, +// so the cap and the empty-output fallback are the two things worth pinning. +describe("resolveFlowTargets", () => { + it("pairs the trigger lexicon's app with the distinct outputs", () => { + const { source, outputs } = resolveFlowTargets("app.bsky.feed.like", [ + makeBskyPostAction(), + makeSembleSaveAction(), + ]); + expect(source).toMatchObject({ kind: "app", label: "Bluesky" }); + expect(outputs.map((t) => (t.kind === "app" ? t.app.id : t.kind))).toEqual([ + "bluesky", + "semble", + ]); + }); + + it("caps the outputs for a compact head and leaves them uncapped by default", () => { + const actions = [makeBskyPostAction(), makeSembleSaveAction(), makeWebhookAction()]; + expect(resolveFlowTargets("app.bsky.feed.like", actions, 2).outputs).toHaveLength(2); + // The card passes 3; the Showcase row passes nothing and renders them all. + expect(resolveFlowTargets("app.bsky.feed.like", actions).outputs).toHaveLength(3); + }); + + it("keeps the slot filled with the generic glyph when nothing produces an output", () => { + // A processor-only automation resolves to no output apps at all; the head + // still needs something to draw, or the arrow points at a gap. + const { outputs } = resolveFlowTargets("app.bsky.feed.post", []); + expect(outputs).toHaveLength(1); + expect(outputs[0]).toMatchObject({ kind: "icon", label: "Action" }); + }); + + it("caps after the fallback, so a zero cap cannot empty the slot", () => { + // maxOutputs slices the resolved list; the fallback is substituted first, + // so the generic glyph survives any cap the callers use. + const { outputs } = resolveFlowTargets("app.bsky.feed.post", [], 3); + expect(outputs).toHaveLength(1); + }); + + it("falls back to a favicon source for a lexicon whose authority is not a known app", () => { + const { source } = resolveFlowTargets("com.example.widget.thing", [makeBskyPostAction()]); + expect(source).toMatchObject({ kind: "favicon", domain: "example.com" }); + }); +}); diff --git a/app/components/ShowcaseFlow/derive.ts b/app/components/AppLogo/targets.ts similarity index 65% rename from app/components/ShowcaseFlow/derive.ts rename to app/components/AppLogo/targets.ts index 88b0374..58ffdcf 100644 --- a/app/components/ShowcaseFlow/derive.ts +++ b/app/components/AppLogo/targets.ts @@ -1,9 +1,14 @@ import type { DisplayAction } from "../../../lib/automations/display.ts"; -import { appForDomain, type LogoTarget } from "../../../lib/apps/registry.ts"; +import { appForDomain, type LogoTarget, sourceLogoTarget } from "../../../lib/apps/registry.ts"; import { actionTypeLabels } from "../../../lib/automations/labels.ts"; import { Zap } from "../../icons.ts"; import { ACTION_UI_REGISTRY, isProcessorAction } from "../../islands/action-editors/registry.ts"; +/** Output slot for an automation whose actions produce no app output + * (webhook-only, processor-only): a generic action glyph so the slot is + * never empty. */ +const GENERIC_ACTION_TARGET: LogoTarget = { kind: "icon", icon: Zap, label: "Action" }; + function targetKey(t: LogoTarget): string { if (t.kind === "app") return `app:${t.app.id}`; if (t.kind === "favicon") return `fav:${t.domain}`; @@ -47,3 +52,20 @@ export function outputLogoTargets(actions: DisplayAction[]): LogoTarget[] { return targets; } + +/** Both ends of a source -> outputs flow for one automation: the trigger + * lexicon's app and the distinct output apps, with the generic action glyph + * standing in when there are no outputs. `maxOutputs` caps the list for + * compact heads; the Showcase renders every output (as an overlapping stack). + */ +export function resolveFlowTargets( + lexicon: string, + actions: DisplayAction[], + maxOutputs = Infinity, +): { source: LogoTarget; outputs: LogoTarget[] } { + const outputs = outputLogoTargets(actions); + return { + source: sourceLogoTarget(lexicon), + outputs: outputs.length > 0 ? outputs.slice(0, maxOutputs) : [GENERIC_ACTION_TARGET], + }; +} diff --git a/app/components/AutomationCard/CardFlow.tsx b/app/components/AutomationCard/CardFlow.tsx new file mode 100644 index 0000000..6ecbb00 --- /dev/null +++ b/app/components/AutomationCard/CardFlow.tsx @@ -0,0 +1,32 @@ +import { ArrowRight } from "../../icons.ts"; +import type { DisplayAction } from "../../../lib/automations/display.ts"; +import { AppLogo } from "../AppLogo/index.tsx"; +import { resolveFlowTargets } from "../AppLogo/targets.ts"; +import * as s from "./styles.css.ts"; + +/** How many output chips the head shows before the rest are dropped. The + * automation detail page is the full breakdown. */ +const MAX_OUTPUTS = 3; + +/** The card head: source app icon, the trigger lexicon as a mono chip, an + * arrow, then up to three output app icons side by side. Both ends resolve + * through the app registry (vendored SVG, else favicon, else the action's + * glyph), the same chain the Showcase uses, on the small chip size. An + * automation whose actions produce no app output (webhook-only, + * processor-only) shows the generic action glyph so the slot is never empty. */ +export function CardFlow({ lexicon, actions }: { lexicon: string; actions: DisplayAction[] }) { + const { source, outputs } = resolveFlowTargets(lexicon, actions, MAX_OUTPUTS); + + return ( +
+ + {lexicon} + + + {outputs.map((target) => ( + + ))} + +
+ ); +} diff --git a/app/components/AutomationCard/index.test.tsx b/app/components/AutomationCard/index.test.tsx index 3bce831..399118e 100644 --- a/app/components/AutomationCard/index.test.tsx +++ b/app/components/AutomationCard/index.test.tsx @@ -1,9 +1,18 @@ // @vitest-environment happy-dom import { describe, it, expect } from "vitest"; import { render } from "hono/jsx/dom"; +import { + makeBskyPostAction, + makeKipclipAnnotationAction, + makeMarginBookmarkAction, + makeSembleSaveAction, + makeWebhookAction, +} from "@/test/fixtures.js"; import { AutomationCard } from "./index.js"; import * as s from "./styles.css.js"; import * as btn from "../Button/styles.css.js"; +import * as logo from "../AppLogo/styles.css.js"; +import * as label from "../Label/styles.css.js"; type CardProps = Parameters[0]; @@ -31,6 +40,14 @@ function ctaLink(container: HTMLElement) { ); } +function head(container: HTMLElement) { + return container.getElementsByClassName(s.head)[0] as HTMLElement; +} + +function outputs(container: HTMLElement) { + return container.getElementsByClassName(s.headOutputs)[0] as HTMLElement; +} + describe("AutomationCard", () => { it("renders the author as 'by @handle' linking to the profile", () => { const c = mount({}); @@ -76,4 +93,128 @@ describe("AutomationCard", () => { expect(slot).toBeTruthy(); expect(slot.textContent).toBe(""); }); + + it("renders owner-only status and blueprint badges when isOwner and ownerStatus are passed", () => { + const c = mount({ + viewerAuthenticated: true, + isOwner: true, + isBlueprint: true, + actions: [makeBskyPostAction()], + ownerStatus: { active: true, dryRun: false, disabledReason: null }, + }); + const meta = c.getElementsByClassName(s.ownerMeta)[0] as HTMLElement; + expect(meta).toBeTruthy(); + expect(meta.getElementsByClassName(label.variant.success)[0]?.textContent).toBe("Active"); + expect(meta.getElementsByClassName(label.variant.accent)[0]?.textContent).toBe("Blueprint"); + expect((meta.getElementsByClassName(s.ownerActionSummary)[0] as HTMLElement).textContent).toBe( + "Bluesky Post", + ); + + // Non-owner views never render the owner meta row, even with a status. + const pub = mount({ + viewerAuthenticated: true, + isOwner: false, + ownerStatus: { active: false, dryRun: false, disabledReason: null }, + }); + expect(pub.getElementsByClassName(s.ownerMeta).length).toBe(0); + }); +}); + +describe("AutomationCard head (CardFlow)", () => { + it("shows the Bluesky mark twice around the chip for a like -> Bluesky post flow (AE6)", () => { + const c = mount({ lexicon: "app.bsky.feed.like", actions: [makeBskyPostAction()] }); + const h = head(c); + expect(h).toBeTruthy(); + + const marks = [...h.querySelectorAll('img[src="/static/logos/bluesky.svg"]')]; + expect(marks).toHaveLength(2); + for (const img of marks) expect(img.getAttribute("alt")).toBe("Bluesky"); + + // Source sits before the chip, the output after it (DOM order = reading order). + const [source, output] = marks as [Element, Element]; + const chip = h.getElementsByClassName(s.headChip)[0] as HTMLElement; + expect(chip.textContent).toBe("app.bsky.feed.like"); + expect(source.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(outputs(c).contains(output)).toBe(true); + + // Both render on the small white chip (A9) with the small image size. + for (const img of marks) { + expect(img.className).toContain(logo.imgSm); + expect(img.parentElement?.className).toContain(logo.chipSm); + } + }); + + it("shows the Tangled mark as source and the webhook glyph as output for a webhook-only automation", () => { + const c = mount({ lexicon: "sh.tangled.feed.star", actions: [makeWebhookAction()] }); + const h = head(c); + + const source = h.querySelector('img[src="/static/logos/tangled.svg"]'); + expect(source).toBeTruthy(); + expect(source?.getAttribute("alt")).toBe("Tangled"); + + const glyph = outputs(c).querySelector('[role="img"][aria-label="Webhook"]'); + expect(glyph).toBeTruthy(); + expect(glyph?.className).toContain(logo.chipSm); + expect(glyph?.querySelector("svg")).toBeTruthy(); + // No favicon or app image in the output slot: the glyph is the whole output. + expect(outputs(c).querySelectorAll("img")).toHaveLength(0); + }); + + it("falls back to the authority domain favicon when no registered app owns the lexicon", () => { + const c = mount({ lexicon: "com.example.thing", actions: [] }); + const h = head(c); + + const source = h.querySelector('[role="img"][aria-label="example.com"]'); + expect(source).toBeTruthy(); + expect(source?.className).toContain(logo.chipSm); + expect(source?.querySelector("img")?.getAttribute("src")).toContain("example.com"); + + // With no output-producing action, the generic action glyph fills the slot. + expect(outputs(c).querySelector('[role="img"][aria-label="Action"]')).toBeTruthy(); + }); + + it("caps the output icons at three side by side", () => { + const c = mount({ + lexicon: "app.bsky.feed.like", + actions: [ + makeBskyPostAction(), + makeSembleSaveAction(), + makeMarginBookmarkAction(), + makeKipclipAnnotationAction(), + ], + }); + const out = outputs(c); + expect(out.children).toHaveLength(3); + const srcs = [...out.querySelectorAll("img")].map((img) => img.getAttribute("src")); + expect(srcs).toEqual([ + "/static/logos/bluesky.svg", + "/static/logos/semble.svg", + "/static/logos/margin.svg", + ]); + }); + + it("renders the arrow between the chip and the outputs", () => { + const c = mount({}); + const arrow = head(c).getElementsByClassName(s.headArrow)[0] as SVGElement; + expect(arrow).toBeTruthy(); + expect(arrow.tagName.toLowerCase()).toBe("svg"); + expect(arrow.getAttribute("width")).toBe("14"); + }); + + it("wraps a long title in the two-line clamp slot without dropping the full-name tooltip", () => { + const name = + "When someone stars one of my Tangled repositories, post about it on Bluesky and bookmark the repo on Margin for later"; + const c = mount({ name }); + const title = c.querySelector("h3") as HTMLHeadingElement; + expect(title.className).toContain(s.title); + expect(title.getAttribute("title")).toBe(name); + + const link = title.querySelector("a") as HTMLAnchorElement; + expect(link.className).toContain(s.titleLink); + expect(link.getAttribute("href")).toBe("/u/alice.test/abc123"); + expect(link.textContent).toBe(name); + + // The card keeps its shrinkable surface class so a long title can't widen a column. + expect(c.querySelector("article")?.className).toContain(s.card); + }); }); diff --git a/app/components/AutomationCard/index.tsx b/app/components/AutomationCard/index.tsx index 82bc8d3..d5bac18 100644 --- a/app/components/AutomationCard/index.tsx +++ b/app/components/AutomationCard/index.tsx @@ -3,7 +3,7 @@ import type { DisplayAction } from "../../../lib/automations/display.ts"; import { actionTypeLabels } from "../../../lib/automations/labels.ts"; import { Badge } from "../Badge/index.tsx"; import { Button } from "../Button/index.tsx"; -import { LexiconFlow } from "../LexiconFlow/index.tsx"; +import { CardFlow } from "./CardFlow.tsx"; import * as s from "./styles.css.ts"; type AutomationCardProps = { @@ -54,14 +54,15 @@ export function AutomationCard({ return (
- +

{name}

- {/* Always rendered (even when empty) so the reserved 2-line slot keeps - card heights uniform regardless of description length. */} + {/* Always rendered, even when empty: the title above reserves two lines + and the footer below is pushed down with `margin-block-start: auto`, + so the paragraph only has to hold its place in that column. */}

{description}

{isOwner && ownerStatus && (
@@ -87,13 +88,13 @@ export function AutomationCard({ {viewerAuthenticated ? ( - ) : ( {user ? ( - ) : ( - )} @@ -40,6 +54,18 @@ function CtaButtons({ user }: { user: { handle: string } | null }) { ); } +function StepCard({ icon, title, children }: { icon: Child; title: string; children: Child }) { + return ( +
+
+ {icon} +

{title}

+
+

{children}

+
+ ); +} + const jsonLdBody = JSON.stringify({ "@context": "https://schema.org", "@type": "WebApplication", @@ -52,41 +78,45 @@ const jsonLdBody = JSON.stringify({ }); export default createRoute(async (c) => { - const [user, featured] = await Promise.all([getSessionUser(c), getFeaturedAutomations()]); - const jsonLd = raw( - ``, - ); + const [user, showcased] = await Promise.all([ + getSessionUser(c), + getShowcaseAutomations(db, HOMEPAGE_SHOWCASE_COUNT), + ]); + const nonce = c.get("cspNonce"); + const jsonLd = raw(``); + const raysScript = raw(``); + + // Edge-cache the anonymous landing for five minutes. + setPageCache(c, user, "public, s-maxage=300, stale-while-revalidate=3600"); - // Edge-cache the anonymous landing at Fastly. Vary: Cookie keeps authenticated - // variants keyed separately; private/no-store stops the auth'd variant from - // ever being shared. - c.header("Vary", "Cookie"); - c.header( - "Cache-Control", - user ? "private, no-store" : "public, s-maxage=300, stale-while-revalidate=3600", + const hero = ( +
+ ); return c.render( - }> + } hero={hero}> {jsonLd} -
- -
-

Automations for the AT Protocol.

-

- - Airglow listens to AT Protocol events for you. Pick a lexicon, set a few - conditions, and send a webhook, create a record on your PDS, or post to Bluesky. -

-
- -
-
-
-
- - {featured.length > 0 && ( + {showcased.length > 0 && (
{ sub="Duplicate any of these to your account in one click." />
- {featured.map((a) => ( + {showcased.map((a) => ( {
)} -
+
- -
-
-
-
- -
-

Listen

+
+
+

How it works

+
+ } title="Listen"> + Airglow watches AT Protocol events from Jetstream in real time. + + } title="Match"> + Pick a lexicon and add conditions to match only the events you care about. + + } title="Run"> + Send a webhook, write a record to your PDS, or post to Bluesky. +
-

- Airglow watches AT Protocol events from Jetstream in real time. -

-
-
-
- +
+

Works with your apps

+
+

+ Airglow connects the AT Protocol apps you already use +

+ {/* Presentational: the faded second row reads as "and more", so the + tiles are not links (A5). Each tile carries the app name. */} +
+
+ {ECOSYSTEM_APPS.map((app) => ( + + + + ))} +
-

Match

-

- Pick a lexicon and add conditions to match only the events you care about. -

-
-
-
- -
-

Run

-
-

- Send a webhook, write a record to your PDS, or post to Bluesky. -

-
-
- -
- -
- - -
-
+
-

Ready to wire your apps together?

-
- +
+ +

Ready to wire your apps together?

+
+ +
diff --git a/app/routes/pricing.test.tsx b/app/routes/pricing.test.tsx new file mode 100644 index 0000000..69375a8 --- /dev/null +++ b/app/routes/pricing.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +vi.mock("@/config.js", () => ({ + config: { + databasePath: ":memory:", + publicUrl: "http://localhost:5175", + pdsUrl: "", + jetstreamUrl: "wss://test/subscribe", + cookieSecret: "test", + nsidAllowlist: [], + nsidBlocklist: [], + nsidRequireDids: [], + }, +})); + +// honox/server eagerly glob-imports every route in the app (and their +// middleware chains); the renderer only needs its