@hogsend/cli 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,35 +7,47 @@ bucket is half-alive.
7
7
 
8
8
  ## 1. Export from `src/buckets/index.ts`
9
9
 
10
- The barrel exports the `buckets: DefinedBucket[]` array — this is the single
11
- list both factories consume.
10
+ The barrel exports the `buckets` array — this is the single list both factories
11
+ consume. Let the array INFER its element types; do NOT annotate it
12
+ `DefinedBucket[]`. The annotation re-widens each bucket's `Id` literal back to
13
+ `string`, which would erase the literal types on `bucket.entered` / `bucket.left`
14
+ (see bucket-id-aliases).
12
15
 
13
16
  ```ts
14
17
  // src/buckets/index.ts
15
- import type { DefinedBucket } from "@hogsend/engine";
16
18
  import { powerUsers } from "./power-users.js";
17
19
  import { wentDormant } from "./went-dormant.js";
18
20
 
19
21
  /**
20
22
  * All defined buckets for this app. Passed to createHogsendClient({ buckets })
21
23
  * and createWorker({ buckets }). Edit freely — this is your content.
24
+ * No `DefinedBucket[]` annotation — let the literal ids survive for typed refs.
22
25
  */
23
- export const buckets: DefinedBucket[] = [powerUsers, wentDormant];
26
+ export const buckets = [powerUsers, wentDormant];
24
27
 
25
- // Re-export individual buckets for direct reference (tests, custom wiring).
28
+ // Re-export individual buckets for direct reference (tests, custom wiring,
29
+ // and binding journeys to their typed `.entered` / `.left` refs).
26
30
  export { powerUsers, wentDormant };
27
31
  ```
28
32
 
29
- Also add the new id to the `BucketId` union in
30
- `src/journeys/constants/index.ts` (see bucket-id-aliases) that's part of
31
- "registering" a bucket as far as typo-safety goes.
33
+ `createHogsendClient` / `createWorker` accept the base `DefinedBucket[]`, and a
34
+ `DefinedBucket<Id>` is assignable to `DefinedBucket`, so the inferred literal
35
+ array still type-checks at both factories dropping the annotation is a pure
36
+ type-ergonomics win, never a wiring requirement.
32
37
 
33
- ## 2. Thread into `createHogsendClient` (the registry + real-time + reconcile)
38
+ That's the whole registration step. There is no separate `BucketId` union to
39
+ update anymore (the typed refs replace it — see bucket-id-aliases), and any
40
+ `.on()` reactions you attached ship automatically on the bucket (see below). You
41
+ do NOT register reactions separately.
42
+
43
+ ## 2. Thread into `createHogsendClient` (registry + real-time + reconcile + reactions)
34
44
 
35
45
  In `src/index.ts` (the HTTP entry point), the client receives `buckets`. This
36
46
  builds the `BucketRegistry`, installs it as the process singleton (so the
37
- real-time ingest path and the reconcile cron can resolve it), and validates
38
- every `meta` via `bucketMetaSchema.parse()`.
47
+ real-time ingest path and the reconcile cron can resolve it), validates every
48
+ `meta` via `bucketMetaSchema.parse()`, and registers each bucket's `.on()`
49
+ reactions into the journey registry (so the admin/Studio feed and the dwell cron
50
+ can resolve them).
39
51
 
40
52
  ```ts
41
53
  // src/index.ts
@@ -52,12 +64,13 @@ const client = createHogsendClient({ journeys, buckets, email: { templates } });
52
64
  const app = createApp(client, { webhookSources });
53
65
  ```
54
66
 
55
- ## 3. Thread into `createWorker` (fast-expiry timer + boot backfill)
67
+ ## 3. Thread into `createWorker` (reaction tasks + fast-expiry timer + boot backfill)
56
68
 
57
69
  In `src/worker.ts` (the task-execution entry point), BOTH the client AND the
58
70
  worker get `buckets`. The client call here installs the registry for the worker
59
- process; the `createWorker({ buckets })` call registers the per-user fast-expiry
60
- timer task for any bucket with `fastExpiry: true`.
71
+ process; the `createWorker({ buckets })` call registers the durable tasks every
72
+ bucket owns its `.on()` reaction tasks, plus the per-user fast-expiry timer for
73
+ any bucket with `fastExpiry: true`.
61
74
 
62
75
  ```ts
63
76
  // src/worker.ts
@@ -76,7 +89,7 @@ async function main() {
76
89
  const worker = createWorker({
77
90
  container: client,
78
91
  journeys,
79
- buckets, // ← registers fastExpiry timer task(s) for opted-in buckets
92
+ buckets, // ← registers reaction tasks + fastExpiry timer(s)
80
93
  extraWorkflows,
81
94
  });
82
95
 
@@ -85,17 +98,33 @@ async function main() {
85
98
  }
86
99
  ```
87
100
 
101
+ ## Reactions ship with the bucket (no separate registration)
102
+
103
+ Every `bucket.on("enter" | "leave" | "dwell", ...)` call pushes a generated
104
+ durable journey onto `bucket.reactions`. You do NOT add reactions to the
105
+ `journeys` array, and you do NOT register them anywhere — passing `buckets` to
106
+ both factories is enough:
107
+
108
+ - the client registers each reaction's meta into the journey registry, and
109
+ - the worker registers each reaction's task.
110
+
111
+ Crucially, reactions are gated by **`ENABLED_BUCKETS`, NOT `ENABLED_JOURNEYS`**.
112
+ Their generated ids (`bucket-<id>-on-enter`, etc.) never appear in a consumer's
113
+ `ENABLED_JOURNEYS` csv, so they are selected with their owning bucket and are
114
+ absent whenever the bucket is disabled. (See the dwell/reactions section of the
115
+ main SKILL for what the reactions DO.)
116
+
88
117
  ## What each side gives you
89
118
 
90
119
  | Wiring | What it enables |
91
120
  |--------|-----------------|
92
- | `createHogsendClient({ buckets })` | Builds + installs the `BucketRegistry` singleton; validates every `meta`; powers the real-time join/leave eval inside `ingestEvent`; lets the reconcile cron resolve enabled buckets. Required in BOTH `index.ts` and `worker.ts`. |
93
- | `createWorker({ buckets })` | Registers the single shared `bucket:arm-expiry` durable timer task but ONLY if some enabled bucket has `fastExpiry: true`. Triggers the boot-time backfill / criteria-change re-eval. |
121
+ | `createHogsendClient({ buckets })` | Builds + installs the `BucketRegistry` singleton; validates every `meta`; registers each bucket's reaction metas into the journey registry (bucket-gated); powers the real-time join/leave eval inside `ingestEvent`; lets the reconcile cron resolve enabled buckets. Required in BOTH `index.ts` and `worker.ts`. |
122
+ | `createWorker({ buckets })` | Registers each bucket's reaction tasks AND the shared fast-expiry durable timer task (the latter only if some enabled bucket has `fastExpiry: true`). Triggers the boot-time backfill / criteria-change re-eval. |
94
123
 
95
- If you ONLY wire the client: time-based and fast-expiry leaves never run (no
96
- worker tasks), and a new bucket is never backfilled. If you ONLY wire the worker
97
- (client `buckets` empty): the registry is empty, so the worker's tasks resolve
98
- nothing.
124
+ If you ONLY wire the client: reaction tasks, time-based, fast-expiry, and dwell
125
+ fires never run (no worker tasks), and a new bucket is never backfilled. If you
126
+ ONLY wire the worker (client `buckets` empty): the registry is empty, so the
127
+ worker's tasks resolve nothing.
99
128
 
100
129
  ## The reconcile cron (engine-owned — you don't register it)
101
130
 
@@ -106,12 +135,15 @@ the worker's base workflows — you do NOT add them to `extraWorkflows`. The cro
106
135
  overrunning sweep queues, never cancels);
107
136
  - sweeps every enabled time-based / `maxDwell` dynamic bucket and emits
108
137
  `bucket:left` (and absence `bucket:entered`) for members the clock moved;
138
+ - runs the `dwell` pass for any bucket with a `dwell` reaction (firing
139
+ `dwell` over the continuously-resident population — see the main SKILL);
109
140
  - is the authoritative backstop even when `fastExpiry` is on.
110
141
 
111
142
  The boot backfill (`bucketBackfillTask`, kicked off by the worker on start):
112
143
 
113
144
  - on a NEW bucket id → materializes the full member set from history WITHOUT
114
- emitting `bucket:entered` (no historical blast into live journeys);
145
+ emitting `bucket:entered` (no historical blast into live journeys), and derives
146
+ the historical `dwellAnchorAt` so `dwell` can fire for the existing population;
115
147
  - on a CHANGED `criteria` (detected via a stored hash diff) → re-evaluates: joins
116
148
  new matchers silently, and emits `bucket:left` for members who no longer match.
117
149
 
@@ -120,9 +152,11 @@ automatically reconciles existing memberships on boot. You don't run a migration
120
152
 
121
153
  ## Enabling / disabling at load time
122
154
 
123
- - `meta.enabled: false` keeps a bucket out of the registry entirely.
155
+ - `meta.enabled: false` keeps a bucket (and its reactions) out of the registry
156
+ entirely.
124
157
  - The `ENABLED_BUCKETS` env var (comma-separated ids, or `*` for all) filters
125
- which buckets load, mirroring `ENABLED_JOURNEYS`. You can override per-call via
158
+ which buckets load, mirroring `ENABLED_JOURNEYS`. It gates the bucket AND its
159
+ reactions. You can override per-call via
126
160
  `createHogsendClient({ enabledBuckets })` / `createWorker({ enabledBuckets })`.
127
161
 
128
162
  To verify a bucket is live on a running instance (membership counts, transition
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: hogsend-extending
3
+ description: Use when extending a Hogsend app beyond journeys/emails/buckets — swapping the email or analytics provider behind its engine-owned contract (EmailProvider / PostHogService), wiring an outbound integration (Slack, a CRM, Stripe) as plain code called from a journey, or deciding when to publish a reusable @hogsend/plugin-* package. Covers the two categories of extension and where each is wired.
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Extending Hogsend
11
+
12
+ There are **two** ways to extend a Hogsend app, and they are different
13
+ mechanisms — don't reach for the wrong one.
14
+
15
+ 1. **Capability providers** (email, analytics). The engine itself drives these,
16
+ so each has an **engine-owned contract** you implement and swap behind.
17
+ `EmailProvider` and `PostHogService` are defined in `@hogsend/core` and
18
+ re-exported from `@hogsend/engine` (the canonical import). You supply an
19
+ implementation via `createHogsendClient({ email: { provider }, analytics })`
20
+ and the engine routes to it — including inbound provider webhooks.
21
+ `@hogsend/plugin-resend` and `@hogsend/plugin-posthog` are the **bundled
22
+ defaults and reference implementations**; you only swap when you want a
23
+ different vendor. → `references/swap-a-provider.md`.
24
+
25
+ 2. **Integrations** (everything you call *out* to — Slack, a CRM, Stripe, an
26
+ internal HTTP API). **No contract, no framework.** Install the SDK, write a
27
+ thin wrapper in your own `src/lib/`, import it into a journey, and call it like
28
+ a function. The engine never sees it. → `references/build-an-integration.md`.
29
+
30
+ **The deciding question: does the engine call it, or do you?** If the engine
31
+ drives the capability (sending mail, capturing analytics) it's a provider behind
32
+ a contract. If your journey reaches outward, it's a plain integration.
33
+
34
+ ## Swapping a capability provider — the short version
35
+
36
+ - **Implement the contract.** `import type { EmailProvider } from "@hogsend/engine"`
37
+ — four methods: `send`, `sendBatch`, `verifyWebhook`, `parseWebhook`. The
38
+ reference implementation to copy is `packages/plugin-resend/src/provider.ts`
39
+ (`createResendProvider`).
40
+ - **Wire it.** `createHogsendClient({ email: { templates, provider: createMyProvider(...) } })`.
41
+ Analytics is a **top-level** option (the engine itself fires captures):
42
+ `createHogsendClient({ analytics: createMyAnalytics(...) })`.
43
+ - **You get everything else for free.** Template rendering, link-click + open
44
+ tracking, preference/suppression checks, the frequency cap, and the
45
+ `email_sends` row all live in the engine's `createTrackedMailer` — never in the
46
+ provider. They come along regardless of which provider you supply.
47
+ - **Defaults.** Pass nothing and you get Resend (built from `RESEND_API_KEY`) +
48
+ PostHog (from `POSTHOG_API_KEY`). Inbound delivery webhooks land at the
49
+ engine-owned route `POST /v1/webhooks/resend`.
50
+ - ⚠️ The contract's `SendEmailOptions` imports from `@hogsend/core` (or
51
+ `@hogsend/plugin-resend`), **not** `@hogsend/engine` — the engine's own
52
+ `SendEmailOptions` is a different, higher-level send type.
53
+
54
+ ## Wiring an integration — the short version
55
+
56
+ - Install the SDK; write `src/lib/<service>.ts` exporting a
57
+ `create<Service>(config)` factory that **validates config at construction, not
58
+ at import** (so tests don't blow up on a missing env var).
59
+ - Import it into a journey and call it: `await slack.sendMessage({ ... })`. It's a
60
+ function call, **not** `ctx.sendMessage` — `ctx` is orchestration-only.
61
+ - Heavy or background work (a nightly CRM sync, a fan-out import) → author a
62
+ Hatchet task in `src/workflows/` and register it via
63
+ `createWorker({ extraWorkflows })`.
64
+
65
+ ## When to publish a `@hogsend/plugin-*` package
66
+
67
+ Almost never from a scaffolded app. A standalone module in your `src/` is the
68
+ right default. Author and publish a real `@hogsend/plugin-*` package only when an
69
+ integration is **reusable across multiple apps** or you intend to **contribute it
70
+ back to the engine** — that is engine-development work done in a clone of the
71
+ Hogsend monorepo, not in your client app.
72
+
73
+ ## What NOT to do
74
+
75
+ - **Don't put a service on `ctx`.** `ctx` is durable-orchestration primitives only
76
+ (`sleep`, `checkpoint`, `trigger`, `guard`, `history`, `posthog`, `identify`).
77
+ - **Don't reach for a provider contract for a one-directional call** — that's an
78
+ integration (just code).
79
+ - **Don't import the `EmailProvider`/`PostHogService` contract from a
80
+ `@hogsend/plugin-*` package** in new code — use `@hogsend/engine` (canonical).
81
+ The plugins still re-export the contracts for back-compat, but new code should
82
+ import from the engine.
@@ -0,0 +1,114 @@
1
+ # Building an integration
2
+
3
+ An integration is **something your journey calls out to** — Slack, a CRM, Stripe,
4
+ an internal HTTP API. It has **no contract** and the engine knows nothing about
5
+ it. The simplest integration is the best one: install the SDK, write a thin
6
+ wrapper, import it into a journey.
7
+
8
+ This is *not* a capability provider — don't implement an engine contract for it,
9
+ and don't put it on `ctx`. (For email/analytics, which the engine drives, see
10
+ `swap-a-provider.md`.)
11
+
12
+ ## 1. Install the SDK
13
+
14
+ ```bash
15
+ pnpm add @slack/web-api
16
+ ```
17
+
18
+ ## 2. Write a thin wrapper — fail at construction, not at import
19
+
20
+ ```ts
21
+ // src/lib/slack.ts — your content
22
+ import { WebClient } from "@slack/web-api";
23
+
24
+ export interface SlackServiceConfig {
25
+ token: string;
26
+ defaultChannel?: string;
27
+ }
28
+
29
+ export function createSlackService(config: SlackServiceConfig) {
30
+ if (!config.token) {
31
+ throw new Error("SlackServiceConfig.token is required");
32
+ }
33
+ const client = new WebClient(config.token);
34
+
35
+ return {
36
+ async sendMessage(opts: { channel?: string; text: string }) {
37
+ const channel = opts.channel ?? config.defaultChannel;
38
+ if (!channel) throw new Error("No channel and no defaultChannel configured");
39
+ try {
40
+ const result = await client.chat.postMessage({ channel, text: opts.text });
41
+ return { ts: result.ts, channel: result.channel };
42
+ } catch (error) {
43
+ // Don't swallow — let the journey's error handling mark the run failed.
44
+ throw new Error(
45
+ `Slack sendMessage failed for ${channel}: ${
46
+ error instanceof Error ? error.message : String(error)
47
+ }`,
48
+ );
49
+ }
50
+ },
51
+ };
52
+ }
53
+ ```
54
+
55
+ Top-level `process.env.X!` access throws on *import* (even in tests). Validate
56
+ inside the factory instead.
57
+
58
+ ## 3. Use it in a journey — a function call, not `ctx`
59
+
60
+ ```ts
61
+ // src/journeys/churn-alert.ts — your content
62
+ import { days } from "@hogsend/core";
63
+ import { defineJourney, sendEmail } from "@hogsend/engine";
64
+ import { createSlackService } from "../lib/slack.js";
65
+ import { Events, Templates } from "./constants/index.js";
66
+
67
+ const slack = createSlackService({
68
+ token: process.env.SLACK_BOT_TOKEN ?? "",
69
+ defaultChannel: "#lifecycle-alerts",
70
+ });
71
+
72
+ export const churnAlert = defineJourney({
73
+ meta: { id: "churn-alert", name: "Churn alert", enabled: true, trigger: { event: Events.PAYMENT_FAILED } },
74
+ run: async (user, ctx) => {
75
+ await slack.sendMessage({ text: `Payment failed for ${user.email}.` });
76
+ await sendEmail({ to: user.email, userId: user.id, template: Templates.CHURN_PAYMENT_FAILED, subject: "Your payment didn't go through" });
77
+ await ctx.sleep({ duration: days(2) });
78
+ // ...escalate if still failing
79
+ },
80
+ });
81
+ ```
82
+
83
+ `slack.sendMessage` and `sendEmail` are both plain imports — neither is on `ctx`.
84
+ That keeps the journey context focused on orchestration and avoids coupling
85
+ integrations to the engine.
86
+
87
+ ## Conventions
88
+
89
+ - **Optional caching.** If your service fetches slow-changing data (like
90
+ `@hogsend/plugin-posthog` does for person properties), accept an optional Redis
91
+ client + TTL in config and cache there.
92
+ - **Need the DB?** Open a connection with `createDatabase()` from `@hogsend/db`
93
+ against your `DATABASE_URL`, or query a client-track table you defined in
94
+ `src/schema/`.
95
+ - **Cleanup?** Expose a `shutdown()` and call it from your `src/worker.ts`
96
+ graceful-shutdown handler alongside `worker.stop()`.
97
+ - **Testing.** Test against a mocked SDK client — no real API calls. The bundled
98
+ `packages/plugin-resend/src/__tests__/` show the pattern.
99
+
100
+ ## Background jobs (Hatchet tasks)
101
+
102
+ Some integrations are better as durable background work than inline — a nightly
103
+ CRM sync, a heavy backfill, a fan-out import. Author them as Hatchet tasks in your
104
+ `src/workflows/` and register via `createWorker({ extraWorkflows })` (the option
105
+ is `extraWorkflows`, NOT `workflows`). They run on the same worker as your
106
+ journeys. For heavy backfills, use `runBatchedBackfill` from `@hogsend/engine`;
107
+ the scaffold ships a `src/workflows/backfill-example.ts` to copy. See the
108
+ `hogsend-webhooks-and-workflows` skill for the full pattern.
109
+
110
+ ## What integrations are not
111
+
112
+ No manifest, no auto-discovery, no lifecycle hooks, no registration. You import
113
+ what you need, where you need it. Integrations live entirely in your content, and
114
+ the engine upgrades underneath them with `pnpm up`.
@@ -0,0 +1,126 @@
1
+ # Swapping a capability provider
2
+
3
+ A capability provider is a **swappable implementation of an engine-owned
4
+ contract**. The engine drives the capability and routes to whatever you supply.
5
+ Today there are two: email (`EmailProvider`) and analytics (`PostHogService`).
6
+
7
+ ## The `EmailProvider` contract
8
+
9
+ Defined in `@hogsend/core`, re-exported canonically from `@hogsend/engine`. It is
10
+ a **dumb wire** — delivery + webhook parse/verify only. No tracking, DB,
11
+ preference, or render logic lives here.
12
+
13
+ ```ts
14
+ import type { EmailProvider } from "@hogsend/engine";
15
+
16
+ interface EmailProvider {
17
+ send(options: SendEmailOptions): Promise<SendResult>; // → { id }
18
+ sendBatch(emails: BatchEmailItem[]): Promise<{ results: SendResult[] }>;
19
+ // Verify a provider webhook signature and return the parsed event.
20
+ // Throws if the signature is missing/invalid.
21
+ verifyWebhook(opts: { payload: string; headers: Record<string, string> }): WebhookEvent;
22
+ // Parse an unsigned payload (trusted contexts/tests).
23
+ parseWebhook(payload: string): WebhookEvent;
24
+ }
25
+ ```
26
+
27
+ `SendEmailOptions`, `BatchEmailItem`, `SendResult`, and `WebhookEvent` are the
28
+ contract's supporting types. **Import the contract types from `@hogsend/engine`**,
29
+ **except `SendEmailOptions`**, which collides with the engine's higher-level send
30
+ type — import that one from `@hogsend/core`:
31
+
32
+ ```ts
33
+ import type { EmailProvider, SendResult, WebhookEvent } from "@hogsend/engine";
34
+ import type { SendEmailOptions } from "@hogsend/core";
35
+ ```
36
+
37
+ ## A provider skeleton
38
+
39
+ The reference implementation to copy is `createResendProvider`
40
+ (`packages/plugin-resend/src/provider.ts`). A custom one mirrors it:
41
+
42
+ ```ts
43
+ // src/lib/my-email-provider.ts — your content
44
+ import type { SendEmailOptions } from "@hogsend/core";
45
+ import type { EmailProvider, SendResult, WebhookEvent } from "@hogsend/engine";
46
+
47
+ export function createMyEmailProvider(config: { apiKey: string; webhookSecret?: string }): EmailProvider {
48
+ return {
49
+ async send(options: SendEmailOptions): Promise<SendResult> {
50
+ // Call your vendor's SDK. The engine hands you HTML already rewritten for
51
+ // link/open tracking (options.html) on the tracked path; render
52
+ // options.react yourself (renderToHtml/renderToPlainText from
53
+ // @hogsend/email) only if your vendor can't take React.
54
+ const id = await myVendor.send({ from: options.from, to: options.to, subject: options.subject, html: options.html });
55
+ return { id };
56
+ },
57
+ async sendBatch(emails) {
58
+ const results = await Promise.all(emails.map((e) => this.send(e as never)));
59
+ return { results };
60
+ },
61
+ verifyWebhook({ payload, headers }): WebhookEvent {
62
+ if (!config.webhookSecret) throw new Error("webhookSecret required to verify webhooks");
63
+ // Verify with your vendor's scheme, then NORMALIZE into the engine's
64
+ // WebhookEvent shape ({ type: "email.delivered" | "email.bounced" | ... }).
65
+ return normalizeMyVendorEvent(verify(payload, headers, config.webhookSecret));
66
+ },
67
+ parseWebhook(payload): WebhookEvent {
68
+ return normalizeMyVendorEvent(JSON.parse(payload));
69
+ },
70
+ };
71
+ }
72
+ ```
73
+
74
+ ## Wire it
75
+
76
+ ```ts
77
+ // src/index.ts — your content
78
+ import { createHogsendClient } from "@hogsend/engine";
79
+ import { templates } from "./emails/registry.js";
80
+ import { createMyEmailProvider } from "./lib/my-email-provider.js";
81
+
82
+ const client = createHogsendClient({
83
+ journeys,
84
+ email: {
85
+ templates, // REQUIRED, nested under email
86
+ provider: createMyEmailProvider({ apiKey: process.env.MY_API_KEY! }),
87
+ },
88
+ // analytics: createMyAnalytics(...), // top-level (engine uses it)
89
+ });
90
+ ```
91
+
92
+ Pass nothing under `email.provider` and the engine builds the **default Resend
93
+ provider** from `RESEND_API_KEY` / `RESEND_WEBHOOK_SECRET`.
94
+
95
+ ## What comes along for free
96
+
97
+ Everything except the wire. The engine's `createTrackedMailer` runs, in order:
98
+ check preferences/suppression → frequency cap → resolve + render the template →
99
+ write the `email_sends` row (status `queued`) → rewrite links + inject the open
100
+ pixel → **then** `provider.send(...)` → update status. So a swapped provider
101
+ keeps **all** of tracking, rendering, preferences, and the `email_sends`
102
+ pipeline.
103
+
104
+ ## Inbound webhooks
105
+
106
+ The engine owns one inbound email-webhook route: `POST /v1/webhooks/resend`. It
107
+ reads the raw body + headers and calls your provider's `verifyWebhook`, then maps
108
+ the normalized `WebhookEvent` to `email_sends` status updates and
109
+ bounce/complaint → suppression. Your provider only has to verify + normalize; the
110
+ DB effects are engine-owned.
111
+
112
+ ## Analytics (`PostHogService`)
113
+
114
+ Same shape: the `PostHogService` contract lives in `@hogsend/core`
115
+ (canonical `@hogsend/engine`); `createPostHogService` (`@hogsend/plugin-posthog`)
116
+ is the default + reference impl. Supply your own via the **top-level**
117
+ `createHogsendClient({ analytics })` option. PostHog is optional — with no
118
+ `POSTHOG_API_KEY` the engine resolves analytics to `undefined` and every capture
119
+ is a no-op.
120
+
121
+ ## Don't over-reach
122
+
123
+ You are implementing a contract, not building a framework. There is no provider
124
+ registry, no marketplace, and no `@hogsend/provider-*` packages — to support a
125
+ new vendor you implement `EmailProvider` (or `PostHogService`) and pass it in.
126
+ That's the whole story.
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 240 10% 3.9%;--card: 0 0% 100%;--card-foreground: 240 10% 3.9%;--primary: 240 5.9% 10%;--primary-foreground: 0 0% 98%;--secondary: 240 4.8% 95.9%;--secondary-foreground: 240 5.9% 10%;--muted: 240 4.8% 95.9%;--muted-foreground: 240 3.8% 46.1%;--accent: 240 4.8% 95.9%;--accent-foreground: 240 5.9% 10%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 0 0% 98%;--border: 240 5.9% 90%;--input: 240 5.9% 90%;--ring: 240 5.9% 10%;--radius: .5rem}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.inset-y-0{top:0;bottom:0}.bottom-4{bottom:1rem}.left-3{left:.75rem}.right-0{right:0}.right-2{right:.5rem}.right-4{right:1rem}.top-1\/2{top:50%}.z-10{z-index:10}.z-50{z-index:50}.z-\[60\]{z-index:60}.-mr-2{margin-right:-.5rem}.-mt-2{margin-top:-.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-20{height:5rem}.h-24{height:6rem}.h-28{height:7rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-40{height:10rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[600px\]{height:600px}.h-full{height:100%}.max-h-48{max-height:12rem}.min-h-\[140px\]{min-height:140px}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-60{width:15rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0px}.min-w-\[2px\]{min-width:2px}.max-w-2xl{max-width:42rem}.max-w-lg{max-width:32rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(0px * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px * var(--tw-space-y-reverse))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-destructive\/40{border-color:hsl(var(--destructive) / .4)}.border-emerald-500\/40{border-color:#10b98166}.border-input{border-color:hsl(var(--input))}.border-primary\/30{border-color:hsl(var(--primary) / .3)}.border-sky-500\/40{border-color:#0ea5e966}.border-transparent{border-color:transparent}.border-violet-500\/40{border-color:#8b5cf666}.bg-accent{background-color:hsl(var(--accent))}.bg-background{background-color:hsl(var(--background))}.bg-black\/50{background-color:#00000080}.bg-border{background-color:hsl(var(--border))}.bg-card{background-color:hsl(var(--card))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-destructive\/5{background-color:hsl(var(--destructive) / .05)}.bg-muted{background-color:hsl(var(--muted))}.bg-muted\/20{background-color:hsl(var(--muted) / .2)}.bg-muted\/30{background-color:hsl(var(--muted) / .3)}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/5{background-color:hsl(var(--primary) / .05)}.bg-primary\/70{background-color:hsl(var(--primary) / .7)}.bg-secondary{background-color:hsl(var(--secondary))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-12{padding:3rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pr-8{padding-right:2rem}.pt-0{padding-top:0}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-accent-foreground{color:hsl(var(--accent-foreground))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-500{--tw-text-opacity: 1;color:rgb(16 185 129 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-sky-600{--tw-text-opacity: 1;color:rgb(2 132 199 / var(--tw-text-opacity, 1))}.text-violet-600{--tw-text-opacity: 1;color:rgb(124 58 237 / var(--tw-text-opacity, 1))}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.file\:border-0::file-selector-button{border-width:0px}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive) / .9)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color: hsl(var(--ring))}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color: hsl(var(--ring))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:bg-primary{background-color:hsl(var(--primary))}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:hsl(var(--muted))}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is(.dark *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-violet-400:is(.dark *){--tw-text-opacity: 1;color:rgb(167 139 250 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:768px){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1024px){.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[260px_1fr\]{grid-template-columns:260px 1fr}}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\&\>li\:last-child\>div\:first-child\>span\:last-child\]\:hidden>li:last-child>div:first-child>span:last-child{display:none}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0px}.\[\&_tr\]\:border-b tr{border-bottom-width:1px}