Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,8 @@ apps/ios/build/

# Vitest Browser Mode failure screenshots and traces
.vitest/

# bun run seed:demo writes its anchor here for the screenshot capture
scripts/seed-demo/.last-seed.json
# isolated screenshot stack env, generated by bun run seed:demo:env
.env.screenshots
2 changes: 1 addition & 1 deletion apps/ingest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@maple/ingest",
"private": true,
"scripts": {
"dev": "set -a && source ../../.env.local && set +a && cargo build && AUTUMN_SECRET_KEY= INGEST_PORT=${PORT:-3473} exec ./target/debug/maple-ingest",
"dev": "set -a && source ../../${MAPLE_DEV_ENV_FILE:-.env.local} && set +a && cargo build && AUTUMN_SECRET_KEY= INGEST_PORT=${PORT:-3473} exec ./target/debug/maple-ingest",
"start": "cargo run --release",
"build": "cargo build --release",
"test": "cargo test",
Expand Down
3 changes: 3 additions & 0 deletions apps/landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"type": "module",
"scripts": {
"generate:og": "node scripts/generate-og.mjs",
"screenshots": "bun scripts/screenshots/capture.ts",
"dev": "bun run --silent sync:cli && exec env -u CLAUDECODE astro dev --port ${PORT:-3391} --host ${HOST:-127.0.0.1}",
"sync:i18n": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide --strategy url baseLocale && astro sync",
"sync:cli": "mkdir -p public/cli && cp ../../scripts/install.sh public/cli/install && cp ../../scripts/uninstall.sh public/cli/uninstall",
Expand Down Expand Up @@ -45,8 +46,10 @@
"tw-animate-css": "catalog:tailwind"
},
"devDependencies": {
"@effect/platform-node": "catalog:effect",
"@inlang/paraglide-js": "^2.25.4",
"@types/d3-scale": "^4.0.9",
"playwright": "catalog:",
"typescript": "catalog:tooling",
"vitest": "catalog:"
}
Expand Down
Binary file modified apps/landing/public/screenshots/surface-errors.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
244 changes: 244 additions & 0 deletions apps/landing/scripts/screenshots/capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
#!/usr/bin/env bun
/**
* `bun run screenshots`: capture the landing site's app screenshots from the
* local stack. Seed it first with `bun run seed:demo` at the repo root; the
* time ranges come from that run's anchor.
*
* Signs in with a one-shot Clerk ticket (dev instances only, like
* `bun run dev:signin`), renders at 2x, and writes webp via `cwebp`.
*/
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Console, DateTime, Effect, FileSystem, Layer, Option, Schema } from "effect"
import { Command, Flag } from "effect/cli"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"
import { ChildProcess, ChildProcessSpawner } from "effect/process"
import { chromium, type BrowserContext, type Page } from "playwright"
import { RELABEL, SHOTS, TIMEZONE, VIEWPORT, type Shot } from "./shots"

const ROOT = new URL("../../../../", import.meta.url).pathname
const SEED_STATE = `${ROOT}scripts/seed-demo/.last-seed.json`
const OUT_DIR = new URL("../../public/screenshots/", import.meta.url).pathname
const DEV_EMAIL = "david+clerk_test@gmail.com"
const MINUTE = 60_000

class CaptureError extends Schema.TaggedError<CaptureError>()("@maple/landing/CaptureError", {
message: Schema.String,
shot: Schema.optional(Schema.String),
}) {}

const SeedState = Schema.Struct({ anchor: Schema.DateTimeUtcFromString, orgId: Schema.NullOr(Schema.String) })

const pw = <A>(message: string, run: () => Promise<A>, shot?: string) =>
Effect.tryPromise({
try: run,
catch: (cause) =>
new CaptureError({
message: `${message}: ${String(cause)}`,
shot,
}),
})

// ── sign-in ─────────────────────────────────────────────────────────────────

const ClerkUsers = Schema.Array(Schema.Struct({ id: Schema.String }))
const ClerkTicket = Schema.Struct({ token: Schema.String })

const mintTicket = Effect.fn("capture.mintTicket")(
function* (email: string) {
const secret = process.env.CLERK_SECRET_KEY ?? ""
if (!secret.startsWith("sk_test_")) {
return yield* new CaptureError({
message: "CLERK_SECRET_KEY must be a development key (sk_test_…)",
})
}
const client = (yield* HttpClient.HttpClient).pipe(
HttpClient.mapRequest(HttpClientRequest.bearerToken(secret)),
HttpClient.filterStatusOk,
)
const users = yield* client
.get(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}`)
.pipe(
Effect.flatMap((response) => response.json),
Effect.flatMap(Schema.decodeUnknownEffect(ClerkUsers)),
)
const user = users[0]
if (!user) return yield* new CaptureError({ message: `no Clerk user ${email}` })
const ticket = yield* client
.execute(
HttpClientRequest.post("https://api.clerk.com/v1/sign_in_tokens").pipe(
HttpClientRequest.bodyText(
JSON.stringify({ user_id: user.id, expires_in_seconds: 600 }),
"application/json",
),
),
)
.pipe(
Effect.flatMap((response) => response.json),
Effect.flatMap(Schema.decodeUnknownEffect(ClerkTicket)),
)
return ticket.token
},
Effect.mapError((cause) => new CaptureError({ message: `Clerk sign-in ticket: ${cause.message}` })),
)

// ── page prep ───────────────────────────────────────────────────────────────

const FREEZE_CSS = `
*, *::before, *::after { transition: none !important; animation: none !important; caret-color: transparent !important; }
[data-sonner-toaster], #react-scan-root, .tsqd-parent-container { display: none !important; }
`

const relabel = (page: Page) =>
pw("relabel", () =>
page.evaluate((pairs) => {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
for (const [from, to] of pairs) {
if (node.nodeValue?.includes(from)) node.nodeValue = node.nodeValue.replaceAll(from, to)
}
}
}, Object.entries(RELABEL)),
)

/** Network quiet and no skeletons left: the page is showing data, not a loading state. */
const settle = (page: Page, shot: string) =>
pw(
"wait for data",
async () => {
await page.waitForLoadState("networkidle")
await page.waitForFunction(
() => document.querySelectorAll('[data-slot="skeleton"], .animate-pulse').length === 0,
null,
{
timeout: 30_000,
},
)
await page.waitForTimeout(400)
},
shot,
)

const warehouseTime = (ms: number) => new Date(ms).toISOString().slice(0, 19).replace("T", " ")

const shotUrl = (base: string, shot: Shot, anchor: number) => {
const url = new URL(shot.route, base)
for (const [key, value] of Object.entries(shot.search ?? {})) {
// TanStack Router reads non-string search values as JSON.
url.searchParams.set(key, typeof value === "string" ? value : JSON.stringify(value))
}
// Dev-only plan-gate bypass: the demo org never went through billing onboarding.
url.searchParams.set("quota_preview", "1")
if (shot.range) {
url.searchParams.set("startTime", warehouseTime(anchor - shot.range.fromMinutes * MINUTE))
url.searchParams.set("endTime", warehouseTime(anchor - shot.range.toMinutes * MINUTE))
}
return url.toString()
}

const capture = Effect.fn("capture.shot")(function* (
context: BrowserContext,
base: string,
shot: Shot,
anchor: number,
) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const fs = yield* FileSystem.FileSystem
const page = yield* Effect.acquireRelease(
pw("open page", () => context.newPage(), shot.id),
(page) => Effect.promise(() => page.close()),
)
yield* pw("navigate", () => page.goto(shotUrl(base, shot, anchor)), shot.id)
yield* pw("freeze", () => page.addStyleTag({ content: FREEZE_CSS }), shot.id)
yield* settle(page, shot.id)
const setup = shot.setup
if (setup) {
yield* pw("setup", () => setup(page), shot.id)
yield* settle(page, shot.id)
}
yield* relabel(page)

const png = yield* fs.makeTempFileScoped({ suffix: ".png" })
yield* pw("screenshot", () => page.screenshot({ path: png }), shot.id)
const out = `${OUT_DIR}${shot.id}.webp`
yield* spawner
.string(ChildProcess.make("cwebp", ["-quiet", "-q", "90", png, "-o", out]))
.pipe(
Effect.mapError(
(cause) => new CaptureError({ message: `cwebp: ${cause.message}`, shot: shot.id }),
),
)
yield* Console.log(` ✓ ${shot.id} ${out.replace(ROOT, "")}`)
}, Effect.scoped)

// ── command ─────────────────────────────────────────────────────────────────

const command = Command.make(
"capture-screenshots",
{
only: Flag.String("only").pipe(Flag.withDescription("Comma-separated shot ids"), Flag.optional),
web: Flag.String("web").pipe(
Flag.withDescription("Local web origin"),
Flag.withDefault(process.env.MAPLE_DEV_WEB_URL ?? "https://web.localhost"),
),
email: Flag.String("email").pipe(Flag.withDescription("Dev Clerk user"), Flag.withDefault(DEV_EMAIL)),
list: Flag.Boolean("list").pipe(
Flag.withDescription("Print each shot's URL and exit"),
Flag.withDefault(false),
),
},
Effect.fn("capture")(function* (flags) {
const fs = yield* FileSystem.FileSystem
const state = yield* fs.readFileString(SEED_STATE).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(SeedState))),
Effect.mapError(
() =>
new CaptureError({
message: `no seed state at ${SEED_STATE}. Run \`bun run seed:demo\` first.`,
}),
),
)
const anchor = DateTime.toEpochMillis(state.anchor)
const wanted = Option.map(flags.only, (only) => new Set(only.split(",").map((id) => id.trim())))
const shots = SHOTS.filter((shot) =>
Option.match(wanted, { onNone: () => true, onSome: (ids) => ids.has(shot.id) }),
)
if (shots.length === 0) return yield* new CaptureError({ message: "no shots match --only" })

if (flags.list) {
for (const shot of shots) yield* Console.log(`${shot.id}\n ${shotUrl(flags.web, shot, anchor)}`)
return
}

yield* Console.log(
`capturing ${shots.length} shot(s) at anchor ${DateTime.formatIso(state.anchor)} (org ${state.orgId ?? "unknown"})`,
)
const ticket = yield* mintTicket(flags.email)
const browser = yield* Effect.acquireRelease(
pw("launch Chrome", () => chromium.launch({ channel: "chrome" })),
(browser) => Effect.promise(() => browser.close()),
)
const context = yield* pw("new context", () =>
browser.newContext({
viewport: VIEWPORT,
deviceScaleFactor: 2,
timezoneId: TIMEZONE,
colorScheme: "dark",
reducedMotion: "reduce",
ignoreHTTPSErrors: true,
}),
)
const signIn = yield* pw("open sign-in", () => context.newPage())
yield* pw("sign in", async () => {
await signIn.goto(`${flags.web}/sign-in?__clerk_ticket=${encodeURIComponent(ticket)}`)
await signIn.waitForURL((url) => !url.pathname.startsWith("/sign-in"), { timeout: 30_000 })
await signIn.close()
})

yield* Effect.forEach(shots, (shot) => capture(context, flags.web, shot, anchor), { discard: true })
}, Effect.scoped),
).pipe(Command.withDescription("Capture landing screenshots from the seeded local stack"))

Command.run(command, { version: "1.0.0" }).pipe(
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer)),

Check failure on line 242 in apps/landing/scripts/screenshots/capture.ts

View workflow job for this annotation

GitHub Actions / TypeScript (effect-lint)

effecttsgo(strict-effect-provide)

apps/landing/scripts/screenshots/capture.ts:242:2: Effect.provide with a Layer should only be used at application entry points. If this is an entry point, you can safely disable this diagnostic. Otherwise, using Effect.provide may break scope lifetimes. Compose all layers at your entry point and provide them at once.
NodeRuntime.runMain,
)
46 changes: 46 additions & 0 deletions apps/landing/scripts/screenshots/shots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Every app screenshot the landing site ships, as data. Captured by
* `capture.ts` against a local stack seeded with `bun run seed:demo`, with time
* ranges pinned to the seed's anchor so a re-capture frames the same moment.
*/
import type { Page } from "playwright"

export interface Shot {
/** Output basename: `public/screenshots/<id>.webp`. */
readonly id: string
readonly route: string
readonly search?: Readonly<Record<string, string | boolean | ReadonlyArray<string>>>
/** Minutes before the seed anchor the range starts and ends. Omit for pages with a fixed window. */
readonly range?: { readonly fromMinutes: number; readonly toMinutes: number }
/** Clicks that reach the framed state once the page has data. */
readonly setup?: (page: Page) => Promise<void>
}

/** Dev-account labels swapped for the demo's before every capture. */
export const RELABEL = {
"test's Organization": "Acme",
"Redirect test US": "Acme",
"test test": "Ada Lovelace",
} as const

export const VIEWPORT = { width: 1440, height: 810 } as const
export const TIMEZONE = "America/New_York"

const lastMinutes = (minutes: number) => ({ fromMinutes: minutes, toMinutes: 0 })

export const SHOTS: ReadonlyArray<Shot> = [
{
id: "surface-traces-peek",
route: "/traces",
// Failed checkouts: payment-svc never owns the root span, so filter on the entry point.
search: { spanNames: ["POST /api/checkout"], hasError: true },
range: lastMinutes(90),
setup: async (page) => {
await page.locator("tr[data-index]").first().click()
await page.waitForURL(/[?&]peek=/)
},
},
{ id: "surface-service-map", route: "/service-map", range: lastMinutes(60) },
{ id: "surface-service-detail", route: "/services/payment-svc", range: lastMinutes(6 * 60) },
{ id: "surface-errors", route: "/errors" },
]
4 changes: 2 additions & 2 deletions apps/landing/src/lib/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,8 @@
constant: "ConnectionTimeout · payment-svc",
plate: {
src: "/screenshots/surface-errors.webp",
width: 2560,
height: 1320,
width: 2880,
height: 1620,

Check notice on line 316 in apps/landing/src/lib/features.ts

View check run for this annotation

Maple Review Bot / Maple / review

correctness: Recaptured plate is 16:9 but `PlatformSheet.astro` still declares 1280x660

`surface-errors.webp` is now 2880x1620 (ratio 1.778 — I read the WebP header), but `apps/landing/src/components/home/PlatformSheet.astro:169` declares the same file as 1280x660 (1.939, half of the old 2560x1320 capture). `MediaFrame` emits those numbers as the `<img width height>` with `class="w-full h-auto"`, so the homepage reserves the old aspect box and the Errors cell grows ~18px when the image loads, breaking the "no layout shift on load" contract `MediaFrame.astro:11` states.
alt: "Errors grouped by type with counts, affected services and last-seen times",
},
facts: [
Expand Down
12 changes: 0 additions & 12 deletions apps/web/src/hooks/use-mutation-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,3 @@ export function useMutationAction<A, E, W>(
): readonly [run: (value: W) => Promise<Exit.Exit<A, E>>, pending: boolean] {
return useAsyncAction(useMutationRunner(atom, options))
}

/**
* `useMutationAction` for row actions: `run(rowId, payload)` and `isPending(rowId)`, so a list
* shows the spinner on the row being mutated instead of a shared flag.
*/
export function useKeyedMutationAction<A, E, W, K extends string = string>(
atom: Atom.Writable<AsyncResult.AsyncResult<A, E>, W>,
options: MutationActionOptions<A>,
): KeyedAction<K, [value: W], Exit.Exit<A, E>> {
const runner = useMutationRunner(atom, options)
return useKeyedAsyncAction((_key: K, value: W) => runner(value))
}
6 changes: 0 additions & 6 deletions apps/web/src/lib/error-toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ export const errorMessage = (error: unknown, fallback: string): string => {
return isUnexpectedError(presentation) ? fallback : presentation.message
}

/** The user-facing message of a failed Exit, or `fallback` for a success, a non-Exit or an unexpected defect. */
export function getExitErrorMessage(exit: unknown, fallback: string): string {
if (!Exit.isExit(exit) || Exit.isSuccess(exit)) return fallback
return errorMessage(exit, fallback)
}

interface ToastExitOptions {
/** Toast title on success; omit to stay silent on success. */
readonly success?: string
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"build": "turbo build",
"dev": "bun run ./scripts/dev.ts",
"dev:signin": "bun run ./scripts/dev-signin.ts",
"seed:demo": "bun run ./scripts/seed-demo.ts",
"seed:demo:env": "bun run ./scripts/seed-demo/make-env.ts",
"prepare": "effect-tsgo unpatch --no-typescript --oxlint && effect-tsgo patch --no-typescript --oxlint",
"docker:build": "COMPOSE_PARALLEL_LIMIT=1 docker compose build",
"docker:up": "COMPOSE_PARALLEL_LIMIT=1 docker compose up --build",
Expand Down
Loading
Loading