diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/apps/admin/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts
new file mode 100644
index 0000000..bf2f92e
--- /dev/null
+++ b/apps/admin/next.config.ts
@@ -0,0 +1,10 @@
+import type { NextConfig } from "next";
+
+const config: NextConfig = {
+ output: "standalone",
+ reactStrictMode: true,
+ // The admin app is server-rendered (DB-backed, auth-gated); never statically exported.
+ transpilePackages: ["@stargue/schema", "@stargue/sanitize", "@stargue/observability"],
+};
+
+export default config;
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 511a9c0..2f0c1be 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -15,17 +15,22 @@
"@stargue/schema": "workspace:*",
"@stargue/sanitize": "workspace:*",
"@stargue/observability": "workspace:*",
+ "drizzle-orm": "^0.36.0",
"next": "^16.0.0",
+ "postgres": "^3.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "server-only": "^0.0.1",
"zod": "^3.23.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@playwright/test": "^1.48.0",
+ "@tailwindcss/postcss": "^4.2.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"axe-core": "^4.10.0",
+ "tailwindcss": "^4.2.2",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
}
diff --git a/apps/admin/postcss.config.mjs b/apps/admin/postcss.config.mjs
new file mode 100644
index 0000000..c2ddf74
--- /dev/null
+++ b/apps/admin/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
diff --git a/apps/admin/src/app/api/approvals/route.ts b/apps/admin/src/app/api/approvals/route.ts
new file mode 100644
index 0000000..fbbe887
--- /dev/null
+++ b/apps/admin/src/app/api/approvals/route.ts
@@ -0,0 +1,12 @@
+import { handleMutation } from "@/lib/api";
+import { getDb } from "@/lib/db";
+import { createApproval } from "@/lib/handlers";
+import { CreateApprovalRequestSchema } from "@stargue/schema";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(req: Request) {
+ return handleMutation(req, CreateApprovalRequestSchema, (input, principal, corr) =>
+ createApproval(getDb(), principal, input, corr),
+ );
+}
diff --git a/apps/admin/src/app/api/content/route.ts b/apps/admin/src/app/api/content/route.ts
new file mode 100644
index 0000000..929f2a5
--- /dev/null
+++ b/apps/admin/src/app/api/content/route.ts
@@ -0,0 +1,12 @@
+import { handleMutation } from "@/lib/api";
+import { getDb } from "@/lib/db";
+import { createContent } from "@/lib/handlers";
+import { CreateContentRequestSchema } from "@stargue/schema";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(req: Request) {
+ return handleMutation(req, CreateContentRequestSchema, (input, principal, corr) =>
+ createContent(getDb(), principal, input, corr),
+ );
+}
diff --git a/apps/admin/src/app/api/feature-flags/route.ts b/apps/admin/src/app/api/feature-flags/route.ts
new file mode 100644
index 0000000..1af1e73
--- /dev/null
+++ b/apps/admin/src/app/api/feature-flags/route.ts
@@ -0,0 +1,12 @@
+import { handleMutation } from "@/lib/api";
+import { getDb } from "@/lib/db";
+import { toggleFeatureFlag } from "@/lib/handlers";
+import { ToggleFeatureFlagRequestSchema } from "@stargue/schema";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(req: Request) {
+ return handleMutation(req, ToggleFeatureFlagRequestSchema, (input, principal, corr) =>
+ toggleFeatureFlag(getDb(), principal, input, corr),
+ );
+}
diff --git a/apps/admin/src/app/api/publications/cancel/route.ts b/apps/admin/src/app/api/publications/cancel/route.ts
new file mode 100644
index 0000000..b8c817e
--- /dev/null
+++ b/apps/admin/src/app/api/publications/cancel/route.ts
@@ -0,0 +1,12 @@
+import { handleMutation } from "@/lib/api";
+import { getDb } from "@/lib/db";
+import { cancelPublication } from "@/lib/handlers";
+import { CancelPublicationRequestSchema } from "@stargue/schema";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(req: Request) {
+ return handleMutation(req, CancelPublicationRequestSchema, (input, principal, corr) =>
+ cancelPublication(getDb(), principal, input, corr),
+ );
+}
diff --git a/apps/admin/src/app/auth/linkedin/page.tsx b/apps/admin/src/app/auth/linkedin/page.tsx
new file mode 100644
index 0000000..2307b65
--- /dev/null
+++ b/apps/admin/src/app/auth/linkedin/page.tsx
@@ -0,0 +1,90 @@
+import { getDb } from "@/lib/db";
+import { listFeatureFlags } from "@/lib/handlers";
+import { getPrincipal } from "@/lib/auth";
+import { FlagToggle } from "@/components/FlagToggle";
+import { linkedin_tokens } from "@stargue/schema/db";
+import { fmtAst } from "@/lib/format";
+
+export const dynamic = "force-dynamic";
+
+export default async function LinkedInAuthPage() {
+ const principal = await getPrincipal();
+ if (!principal) {
+ return (
+
Not authenticated. Sign in via Authentik.
+ );
+ }
+ const db = getDb();
+ const flags = await listFeatureFlags(db);
+ const tokens = await db
+ .select({
+ subject_type: linkedin_tokens.subject_type,
+ subject_urn: linkedin_tokens.subject_urn,
+ access_expires_at: linkedin_tokens.access_expires_at,
+ refresh_expires_at: linkedin_tokens.refresh_expires_at,
+ scopes: linkedin_tokens.scopes,
+ })
+ .from(linkedin_tokens);
+ const canToggle = principal.roles.includes("operator");
+
+ return (
+
+
+
LinkedIn connection
+
+ OAuth status and per-outlet kill-switch.
+
+
+
+
+
Authorization
+ {tokens.length === 0 ? (
+
+ Not connected. Community Management API access is in review; once granted,
+ authorize the app here to begin publishing.
+
+ ) : (
+
+ {tokens.map((t) => (
+
+ {t.subject_urn}
+ ({t.subject_type})
+
+ access expires {fmtAst(t.access_expires_at)}
+
+
+ refresh expires {fmtAst(t.refresh_expires_at)}
+
+
+ ))}
+
+ )}
+
+
+
+
Kill-switch
+
+ Disable an outlet to halt dispatch within ~30s without a redeploy. Toggling is
+ audit-logged.
+
+ {flags.length === 0 ? (
+
+ No outlet flags set yet (outlets default to enabled).
+
+ ) : (
+
+ {flags.map((f) => (
+
+ {f.outlet}
+
+ {f.enabled ? "enabled" : `disabled — ${f.reason ?? "no reason"}`}
+
+ {canToggle && }
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css
new file mode 100644
index 0000000..79acdd1
--- /dev/null
+++ b/apps/admin/src/app/globals.css
@@ -0,0 +1,37 @@
+@import "tailwindcss";
+
+/*
+ * Nine Laws of the Hearth — admin/operator temperature (cool / forge).
+ * Forest (#4E8211) + Citrus (#99CC00) on near-black green. Amber is reserved
+ * for Hearth content surfaces and never appears here (Law 4).
+ */
+@theme {
+ --color-bg: #0e1303;
+ --color-surface: #18200a;
+ --color-surface-2: #212b0f;
+ --color-border: #34401b;
+ --color-forest: #4e8211;
+ --color-citrus: #99cc00;
+ --color-ink: #e9ede0;
+ --color-muted: #9aa089;
+ --color-danger: #ef4444;
+ --color-slate: #64748b;
+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
+}
+
+:root {
+ color-scheme: dark;
+}
+
+body {
+ background-color: var(--color-bg);
+ color: var(--color-ink);
+ font-family: var(--font-sans);
+}
+
+a {
+ color: var(--color-citrus);
+}
+a:hover {
+ color: var(--color-forest);
+}
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
new file mode 100644
index 0000000..221f2c0
--- /dev/null
+++ b/apps/admin/src/app/layout.tsx
@@ -0,0 +1,64 @@
+import "./globals.css";
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+import Link from "next/link";
+
+export const metadata: Metadata = {
+ title: "Stargue Publishing Engine — Admin",
+ description: "Operator console for the Stargue Publishing Engine.",
+ robots: "noindex, nofollow",
+};
+
+const NAV = [
+ { href: "/queue", label: "Queue" },
+ { href: "/metrics", label: "Metrics" },
+ { href: "/auth/linkedin", label: "LinkedIn" },
+];
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ Skip to content
+
+
+
+
+
+
+ Stargue PE
+ · operator
+
+
+ {NAV.map((item) => (
+
+
+ {item.label}
+
+
+ ))}
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/metrics/page.tsx b/apps/admin/src/app/metrics/page.tsx
new file mode 100644
index 0000000..e6287ac
--- /dev/null
+++ b/apps/admin/src/app/metrics/page.tsx
@@ -0,0 +1,80 @@
+import { getDb } from "@/lib/db";
+import { listPublishedWithMetrics } from "@/lib/handlers";
+import { getPrincipal } from "@/lib/auth";
+import { buildHeatmap, DAY_LABELS } from "@/lib/heatmap";
+
+export const dynamic = "force-dynamic";
+
+const HOURS = Array.from({ length: 24 }, (_, h) => h);
+
+export default async function MetricsPage() {
+ const principal = await getPrincipal();
+ if (!principal) {
+ return (
+ Not authenticated. Sign in via Authentik.
+ );
+ }
+ const rows = await listPublishedWithMetrics(getDb());
+ const cells = buildHeatmap(rows);
+ const byKey = new Map(cells.map((c) => [`${c.day}-${c.hour}`, c]));
+ const maxEng = Math.max(1, ...cells.map((c) => c.engagement));
+
+ return (
+
+ Engagement heatmap
+
+ Published posts by AST day × hour. Each cell shows the post count; background
+ intensity is total engagement (reactions + comments + shares). {rows.length} published.
+
+
+
+
+
+ Posts and engagement by day of week and hour, Atlantic Standard Time
+
+
+
+
+ {HOURS.map((h) => (
+
+ {h}
+
+ ))}
+
+
+
+ {DAY_LABELS.map((label, day) => (
+
+
+ {label}
+
+ {HOURS.map((h) => {
+ const c = byKey.get(`${day}-${h}`);
+ const intensity = c ? c.engagement / maxEng : 0;
+ const hh = String(h).padStart(2, "0");
+ const labelText = c
+ ? `${label} ${hh}:00 — ${c.count} post${c.count === 1 ? "" : "s"}, ${c.engagement} engagement`
+ : `${label} ${hh}:00 — no posts`;
+ return (
+
+ {c ? c.count : ""}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx
new file mode 100644
index 0000000..6f2a431
--- /dev/null
+++ b/apps/admin/src/app/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function Home() {
+ redirect("/queue");
+}
diff --git a/apps/admin/src/app/publications/[id]/page.tsx b/apps/admin/src/app/publications/[id]/page.tsx
new file mode 100644
index 0000000..6744143
--- /dev/null
+++ b/apps/admin/src/app/publications/[id]/page.tsx
@@ -0,0 +1,116 @@
+import { notFound } from "next/navigation";
+import { getDb } from "@/lib/db";
+import { getPublicationDetail } from "@/lib/handlers";
+import { getPrincipal } from "@/lib/auth";
+import { StatusBadge } from "@/components/StatusBadge";
+import { fmtAst } from "@/lib/format";
+
+export const dynamic = "force-dynamic";
+
+export default async function PublicationDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const principal = await getPrincipal();
+ if (!principal) {
+ return (
+ Not authenticated. Sign in via Authentik.
+ );
+ }
+ const { id } = await params;
+ const pid = Number(id);
+ if (!Number.isInteger(pid) || pid <= 0) notFound();
+
+ const detail = await getPublicationDetail(getDb(), pid);
+ if (!detail) notFound();
+ const { pub, metrics } = detail;
+
+ return (
+
+
+
{pub.title}
+
{pub.slug}
+
+
+
+
+
Outlet
+ {pub.outlet}
+
+
+
Status
+
+
+
+
+
+
Scheduled
+ {fmtAst(pub.scheduled_at)}
+
+
+
Published
+ {fmtAst(pub.published_at)}
+
+
+
+
+ Idempotency key
+
+
+ {pub.idempotency_key}
+
+
+ {pub.error && (
+
+
Error
+ {pub.error}
+
+ )}
+
+
+
+
Metrics
+ {metrics.length === 0 ? (
+
No metrics collected yet.
+ ) : (
+
+
+
+ Collected
+ Impr.
+ React.
+ Comments
+ Shares
+ Clicks
+
+
+
+ {metrics.map((m) => (
+
+ {fmtAst(m.collected_at)}
+ {m.impressions}
+ {m.reactions}
+ {m.comments}
+ {m.shares}
+ {m.clicks}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/app/queue/page.tsx b/apps/admin/src/app/queue/page.tsx
new file mode 100644
index 0000000..7f3359b
--- /dev/null
+++ b/apps/admin/src/app/queue/page.tsx
@@ -0,0 +1,78 @@
+import Link from "next/link";
+import { getDb } from "@/lib/db";
+import { listQueue } from "@/lib/handlers";
+import { getPrincipal } from "@/lib/auth";
+import { StatusBadge } from "@/components/StatusBadge";
+import { CancelButton } from "@/components/CancelButton";
+import { fmtAst } from "@/lib/format";
+
+export const dynamic = "force-dynamic";
+
+const CANCELLABLE = new Set(["pending", "queued", "scheduled", "dispatching", "failed"]);
+
+export default async function QueuePage() {
+ const principal = await getPrincipal();
+ if (!principal) {
+ return (
+ Not authenticated. Sign in via Authentik.
+ );
+ }
+ const rows = await listQueue(getDb());
+ const canCancel = principal.roles.includes("operator");
+
+ return (
+
+ Publish queue
+
+ Signed in as {principal.sub} · {principal.roles.join(", ")}
+
+
+ {rows.length === 0 ? (
+ Nothing queued.
+ ) : (
+
+
+
+
+ Title
+ Outlet
+ Status
+ Scheduled
+ Published
+ Actions
+
+
+
+ {rows.map((r) => (
+
+
+
+ {r.title}
+
+ {r.slug}
+
+ {r.outlet}
+
+
+
+ {fmtAst(r.scheduled_at)}
+ {fmtAst(r.published_at)}
+
+ {canCancel && CANCELLABLE.has(r.status) ? (
+
+ ) : (
+ —
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/CancelButton.tsx b/apps/admin/src/components/CancelButton.tsx
new file mode 100644
index 0000000..f9f3b3c
--- /dev/null
+++ b/apps/admin/src/components/CancelButton.tsx
@@ -0,0 +1,50 @@
+"use client";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+export function CancelButton({ publicationId }: { publicationId: number }) {
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const router = useRouter();
+
+ async function onCancel() {
+ const reason = window.prompt("Reason for cancelling this publication?");
+ if (!reason) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const res = await fetch("/api/publications/cancel", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ publication_id: publicationId, reason }),
+ });
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ setError(data.error ?? `Failed (${res.status})`);
+ return;
+ }
+ router.refresh();
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+ {busy ? "Cancelling…" : "Cancel"}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/FlagToggle.tsx b/apps/admin/src/components/FlagToggle.tsx
new file mode 100644
index 0000000..37dd8f8
--- /dev/null
+++ b/apps/admin/src/components/FlagToggle.tsx
@@ -0,0 +1,65 @@
+"use client";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+export function FlagToggle({
+ outlet,
+ enabled,
+}: {
+ outlet: string;
+ enabled: boolean;
+}) {
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const router = useRouter();
+ const next = !enabled;
+
+ async function onToggle() {
+ const reason =
+ window.prompt(
+ `Reason for turning ${outlet} publishing ${next ? "ON" : "OFF"}?`,
+ ) ?? "";
+ if (!reason.trim()) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const res = await fetch("/api/feature-flags", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ outlet, enabled: next, reason }),
+ });
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ setError(data.error ?? `Failed (${res.status})`);
+ return;
+ }
+ router.refresh();
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+ {busy ? "…" : next ? `Enable ${outlet}` : `Disable ${outlet}`}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/StatusBadge.tsx b/apps/admin/src/components/StatusBadge.tsx
new file mode 100644
index 0000000..da57c75
--- /dev/null
+++ b/apps/admin/src/components/StatusBadge.tsx
@@ -0,0 +1,27 @@
+/**
+ * Status badge — color + text (never color alone, WCAG 1.4.1). Uses the cool
+ * operator palette: forest/citrus for healthy, slate for in-flight, red for
+ * failure. Amber is deliberately absent (reserved for Hearth content).
+ */
+const STYLES: Record = {
+ published: { bg: "rgba(78,130,17,0.25)", fg: "#bfe35a" },
+ dispatching: { bg: "rgba(153,204,0,0.18)", fg: "#cdec7a" },
+ queued: { bg: "rgba(100,116,139,0.25)", fg: "#cbd5e1" },
+ scheduled: { bg: "rgba(100,116,139,0.25)", fg: "#cbd5e1" },
+ pending: { bg: "rgba(100,116,139,0.20)", fg: "#aab4c4" },
+ failed: { bg: "rgba(239,68,68,0.20)", fg: "#fca5a5" },
+ dlq: { bg: "rgba(239,68,68,0.28)", fg: "#fecaca" },
+ cancelled: { bg: "rgba(120,120,120,0.20)", fg: "#cfcfcf" },
+};
+
+export function StatusBadge({ status }: { status: string }) {
+ const s = STYLES[status] ?? { bg: "rgba(120,120,120,0.2)", fg: "#cfcfcf" };
+ return (
+
+ {status}
+
+ );
+}
diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts
new file mode 100644
index 0000000..08e6478
--- /dev/null
+++ b/apps/admin/src/lib/api.ts
@@ -0,0 +1,47 @@
+import "server-only";
+import { z } from "zod";
+import { AuthzError, type Principal } from "@stargue/schema";
+import { getPrincipal } from "./auth";
+import { NotFoundError, ConflictError } from "./handlers";
+
+export function jsonError(
+ status: number,
+ error: string,
+ extra?: Record,
+): Response {
+ return Response.json({ error, ...(extra ?? {}) }, { status });
+}
+
+/**
+ * Wraps a mutating route handler with the full gate chain:
+ * 401 unauthenticated → 403 forbidden (authz) → 422 invalid body →
+ * 404 not found → 409 conflict → 500.
+ * The handler itself enforces role authz via `requireAllowed`; this maps the
+ * resulting errors to HTTP status codes and threads a correlation id.
+ */
+export async function handleMutation(
+ req: Request,
+ schema: S,
+ fn: (input: z.infer, principal: Principal, correlationId: string) => Promise,
+): Promise {
+ const correlationId = crypto.randomUUID();
+ try {
+ const principal = await getPrincipal();
+ if (!principal) return jsonError(401, "unauthenticated");
+
+ const body = await req.json().catch(() => ({}));
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ return jsonError(422, "validation_failed", { issues: parsed.error.issues });
+ }
+
+ const result = await fn(parsed.data, principal, correlationId);
+ return Response.json({ ok: true, data: result, correlationId }, { status: 200 });
+ } catch (err) {
+ if (err instanceof AuthzError) return jsonError(403, "forbidden");
+ if (err instanceof NotFoundError) return jsonError(404, err.message);
+ if (err instanceof ConflictError) return jsonError(409, err.message);
+ console.error(`[admin] route error (corr=${correlationId}):`, err);
+ return jsonError(500, "internal_error", { correlationId });
+ }
+}
diff --git a/apps/admin/src/lib/auth-core.test.ts b/apps/admin/src/lib/auth-core.test.ts
new file mode 100644
index 0000000..4db946e
--- /dev/null
+++ b/apps/admin/src/lib/auth-core.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+import { principalFromAuthentik, principalFromDevEnv } from "./auth-core";
+
+describe("principalFromAuthentik", () => {
+ it("maps Authentik groups to roles", () => {
+ const p = principalFromAuthentik(
+ "angelo",
+ "stargue-pe-operators|stargue-pe-approvers",
+ );
+ expect(p).toEqual({ sub: "angelo", roles: ["operator", "approver"] });
+ });
+
+ it("returns null when no username", () => {
+ expect(principalFromAuthentik(null, "stargue-pe-operators")).toBeNull();
+ });
+
+ it("returns null when no mappable groups", () => {
+ expect(principalFromAuthentik("angelo", "some-other-group")).toBeNull();
+ expect(principalFromAuthentik("angelo", "")).toBeNull();
+ });
+});
+
+describe("principalFromDevEnv", () => {
+ it("parses sub:roles", () => {
+ expect(principalFromDevEnv("angelo:operator,approver")).toEqual({
+ sub: "angelo",
+ roles: ["operator", "approver"],
+ });
+ });
+
+ it("defaults to operator when only a sub is given", () => {
+ expect(principalFromDevEnv("angelo")).toEqual({ sub: "angelo", roles: ["operator"] });
+ });
+
+ it("returns null for undefined / empty", () => {
+ expect(principalFromDevEnv(undefined)).toBeNull();
+ expect(principalFromDevEnv("")).toBeNull();
+ expect(principalFromDevEnv(":operator")).toBeNull();
+ });
+});
diff --git a/apps/admin/src/lib/auth-core.ts b/apps/admin/src/lib/auth-core.ts
new file mode 100644
index 0000000..0ad7589
--- /dev/null
+++ b/apps/admin/src/lib/auth-core.ts
@@ -0,0 +1,48 @@
+import type { Principal, Role } from "@stargue/schema";
+
+/**
+ * Maps Authentik group names to Publishing-Engine roles. The forward-auth proxy
+ * (Authentik outpost) injects the authenticated user's groups; we translate them
+ * to the authz roles consumed by `@stargue/schema` `requireAllowed`.
+ *
+ * Pure module (no next/* imports) so it is unit-testable without a server.
+ */
+export const GROUP_ROLE_MAP: Record = {
+ "stargue-pe-operators": "operator",
+ "stargue-pe-approvers": "approver",
+ "stargue-pe-viewers": "viewer",
+};
+
+/** Resolve a principal from Authentik forward-auth headers (groups separated by `|`). */
+export function principalFromAuthentik(
+ username: string | null,
+ groupsHeader: string | null,
+): Principal | null {
+ if (!username) return null;
+ const groups = (groupsHeader ?? "")
+ .split("|")
+ .map((g) => g.trim())
+ .filter(Boolean);
+ const roles = groups
+ .map((g) => GROUP_ROLE_MAP[g])
+ .filter((r): r is Role => Boolean(r));
+ if (roles.length === 0) return null;
+ return { sub: username, roles };
+}
+
+/**
+ * Dev-only stub principal from `AUTH_DEV_PRINCIPAL` (format `sub:role1,role2`,
+ * e.g. `angelo:operator,approver`). Lets the dashboard run without a live IdP.
+ */
+export function principalFromDevEnv(dev: string | undefined): Principal | null {
+ if (!dev) return null;
+ const idx = dev.indexOf(":");
+ const sub = (idx === -1 ? dev : dev.slice(0, idx)).trim();
+ const rolesCsv = idx === -1 ? "operator" : dev.slice(idx + 1);
+ const roles = rolesCsv
+ .split(",")
+ .map((r) => r.trim())
+ .filter(Boolean) as Role[];
+ if (!sub || roles.length === 0) return null;
+ return { sub, roles };
+}
diff --git a/apps/admin/src/lib/auth.ts b/apps/admin/src/lib/auth.ts
new file mode 100644
index 0000000..f2fe355
--- /dev/null
+++ b/apps/admin/src/lib/auth.ts
@@ -0,0 +1,19 @@
+import "server-only";
+import { headers } from "next/headers";
+import type { Principal } from "@stargue/schema";
+import { principalFromAuthentik, principalFromDevEnv } from "./auth-core";
+
+/**
+ * Resolve the current principal. Dev stub (`AUTH_DEV_PRINCIPAL`) takes precedence
+ * for local development; otherwise reads the Authentik forward-auth headers.
+ * Returns null when unauthenticated (callers map this to HTTP 401 / login).
+ */
+export async function getPrincipal(): Promise {
+ const dev = principalFromDevEnv(process.env.AUTH_DEV_PRINCIPAL);
+ if (dev) return dev;
+ const h = await headers();
+ return principalFromAuthentik(
+ h.get("x-authentik-username"),
+ h.get("x-authentik-groups"),
+ );
+}
diff --git a/apps/admin/src/lib/db.ts b/apps/admin/src/lib/db.ts
new file mode 100644
index 0000000..b3768a3
--- /dev/null
+++ b/apps/admin/src/lib/db.ts
@@ -0,0 +1,14 @@
+import "server-only";
+import { createDb, type Db } from "@stargue/schema/client";
+
+let _db: Db | null = null;
+
+/** Lazily-created singleton DB handle for server components + route handlers. */
+export function getDb(): Db {
+ if (!_db) {
+ const url = process.env.DATABASE_URL;
+ if (!url) throw new Error("DATABASE_URL is required");
+ _db = createDb(url).db;
+ }
+ return _db;
+}
diff --git a/apps/admin/src/lib/format.ts b/apps/admin/src/lib/format.ts
new file mode 100644
index 0000000..ebda5e1
--- /dev/null
+++ b/apps/admin/src/lib/format.ts
@@ -0,0 +1,10 @@
+import { AST_OFFSET_MS } from "./heatmap";
+
+/** Format a UTC timestamp as `YYYY-MM-DD HH:MM AST` (Curaçao, no DST). */
+export function fmtAst(d: Date | null | undefined): string {
+ if (!d) return "—";
+ return (
+ new Date(d.getTime() + AST_OFFSET_MS).toISOString().slice(0, 16).replace("T", " ") +
+ " AST"
+ );
+}
diff --git a/apps/admin/src/lib/handlers.integration.test.ts b/apps/admin/src/lib/handlers.integration.test.ts
new file mode 100644
index 0000000..7751c4c
--- /dev/null
+++ b/apps/admin/src/lib/handlers.integration.test.ts
@@ -0,0 +1,141 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
+import { eq } from "drizzle-orm";
+import { createDb, type DbHandle } from "@stargue/schema/client";
+import { AuthzError, type Principal, type CreateContentRequest } from "@stargue/schema";
+import { content, publications, audit } from "@stargue/schema/db";
+import {
+ createContent,
+ createApproval,
+ cancelPublication,
+ toggleFeatureFlag,
+ NotFoundError,
+ ConflictError,
+} from "./handlers";
+
+/**
+ * Admin handler tests — authz tiers + happy paths against real Postgres.
+ * Gated behind INTEGRATION=1. Covers the Stage 2.3 route-handler intent
+ * (401/403/404/409/200 logic) at the handler layer, testable without a server.
+ */
+const RUN = !!process.env.INTEGRATION;
+const URL =
+ process.env.DATABASE_URL ??
+ "postgres://stargue:stargue_dev@localhost:5433/stargue_publishing_engine";
+
+const operator: Principal = { sub: "angelo", roles: ["operator"] };
+const approver: Principal = { sub: "carlo", roles: ["approver"] };
+const viewer: Principal = { sub: "guest", roles: ["viewer"] };
+
+const baseFrontmatter = {
+ status: "ready" as const,
+ language: "en" as const,
+ outlets: [{ outlet: "linkedin.org" as const, status: "queued" as const, published_url: null }],
+ scheduled: null,
+ category: "blog" as const,
+ canonical: "stargue.com",
+ sanitize: true,
+ version: 1,
+};
+
+const contentInput = (slug = "x"): CreateContentRequest => ({
+ vault_path: `Stargue/Projects/Publishing Engine/${slug}.md`,
+ slug,
+ title: "X",
+ body_sanitized: "body",
+ frontmatter: baseFrontmatter,
+ content_hash: "c".repeat(64),
+});
+
+describe.skipIf(!RUN)("admin handlers — real Postgres (Stage 2.3 / 5)", () => {
+ let h: DbHandle;
+ beforeAll(() => {
+ h = createDb(URL);
+ });
+ afterAll(async () => {
+ await h.sql.end();
+ });
+ beforeEach(async () => {
+ await h.sql`TRUNCATE content, publications, approvals, metrics, audit, outlet_feature_flags RESTART IDENTITY CASCADE`;
+ });
+
+ it("createContent: operator allowed → row + audit", async () => {
+ const row = await createContent(h.db, operator, contentInput(), "corr-1");
+ expect(row.slug).toBe("x");
+ const auditRows = await h.db.select().from(audit).where(eq(audit.correlation_id, "corr-1"));
+ expect(auditRows).toHaveLength(1);
+ expect(auditRows[0]!.action).toBe("content.create");
+ expect(auditRows[0]!.actor).toBe("angelo");
+ });
+
+ it("createContent: viewer forbidden (AuthzError) and writes nothing", async () => {
+ await expect(
+ createContent(h.db, viewer, contentInput("y"), "corr-2"),
+ ).rejects.toBeInstanceOf(AuthzError);
+ expect(await h.db.select().from(content)).toHaveLength(0);
+ });
+
+ it("createApproval: approver allowed; missing content → NotFoundError", async () => {
+ const c = await createContent(h.db, operator, contentInput("z"), "corr-3");
+ const appr = await createApproval(
+ h.db,
+ approver,
+ { content_id: c.id, outlet: "linkedin.org", notes: "lgtm" },
+ "corr-4",
+ );
+ expect(appr.approved_by).toBe("carlo");
+ await expect(
+ createApproval(h.db, approver, { content_id: 99999, outlet: "linkedin.org" }, "corr-5"),
+ ).rejects.toBeInstanceOf(NotFoundError);
+ });
+
+ it("cancelPublication: operator cancels queued; published → ConflictError", async () => {
+ const c = await createContent(h.db, operator, contentInput("p"), "corr-6");
+ const [pub] = await h.db
+ .insert(publications)
+ .values({
+ content_id: c.id,
+ outlet: "linkedin.org",
+ status: "queued",
+ idempotency_key: `${c.id}|linkedin.org|immediate`,
+ })
+ .returning();
+ const cancelled = await cancelPublication(
+ h.db,
+ operator,
+ { publication_id: pub!.id, reason: "typo" },
+ "corr-7",
+ );
+ expect(cancelled.status).toBe("cancelled");
+
+ const [pub2] = await h.db
+ .insert(publications)
+ .values({
+ content_id: c.id,
+ outlet: "linkedin.member",
+ status: "published",
+ idempotency_key: `${c.id}|linkedin.member|immediate`,
+ })
+ .returning();
+ await expect(
+ cancelPublication(h.db, operator, { publication_id: pub2!.id, reason: "x" }, "corr-8"),
+ ).rejects.toBeInstanceOf(ConflictError);
+ });
+
+ it("toggleFeatureFlag: operator upserts the kill-switch", async () => {
+ const off = await toggleFeatureFlag(
+ h.db,
+ operator,
+ { outlet: "linkedin.org", enabled: false, reason: "auth expired" },
+ "corr-9",
+ );
+ expect(off.enabled).toBe(false);
+ const on = await toggleFeatureFlag(
+ h.db,
+ operator,
+ { outlet: "linkedin.org", enabled: true, reason: "restored" },
+ "corr-10",
+ );
+ expect(on.enabled).toBe(true);
+ expect(on.outlet).toBe("linkedin.org");
+ });
+});
diff --git a/apps/admin/src/lib/handlers.ts b/apps/admin/src/lib/handlers.ts
new file mode 100644
index 0000000..65de2f8
--- /dev/null
+++ b/apps/admin/src/lib/handlers.ts
@@ -0,0 +1,243 @@
+import { eq, desc, and, isNotNull } from "drizzle-orm";
+import type { Db } from "@stargue/schema/client";
+import {
+ content,
+ publications,
+ approvals,
+ outlet_feature_flags,
+ metrics,
+ audit,
+} from "@stargue/schema/db";
+import {
+ requireAllowed,
+ type Principal,
+ type CreateContentRequest,
+ type CreateApprovalRequest,
+ type CancelPublicationRequest,
+ type ToggleFeatureFlagRequest,
+} from "@stargue/schema";
+
+export class NotFoundError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "NotFoundError";
+ }
+}
+
+export class ConflictError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ConflictError";
+ }
+}
+
+/** Append-only audit write. Every state-changing handler calls this. */
+async function writeAudit(
+ db: Db,
+ principal: Principal,
+ action: string,
+ subjectType: string,
+ subjectId: number | string,
+ correlationId: string,
+ payload: Record | null,
+): Promise {
+ await db.insert(audit).values({
+ actor: principal.sub,
+ action,
+ subject_type: subjectType,
+ subject_id: String(subjectId),
+ correlation_id: correlationId,
+ payload_jsonb: payload,
+ });
+}
+
+// ---- Mutations (authz-gated, audited) ----
+
+export async function createContent(
+ db: Db,
+ principal: Principal,
+ input: CreateContentRequest,
+ correlationId: string,
+) {
+ requireAllowed(principal, "content.create");
+ const [row] = await db
+ .insert(content)
+ .values({
+ vault_path: input.vault_path,
+ slug: input.slug,
+ title: input.title,
+ body_sanitized: input.body_sanitized,
+ frontmatter_jsonb: input.frontmatter,
+ content_hash: input.content_hash,
+ })
+ .returning();
+ await writeAudit(db, principal, "content.create", "content", row!.id, correlationId, {
+ slug: input.slug,
+ });
+ return row!;
+}
+
+export async function createApproval(
+ db: Db,
+ principal: Principal,
+ input: CreateApprovalRequest,
+ correlationId: string,
+) {
+ requireAllowed(principal, "approvals.create");
+ const [c] = await db.select().from(content).where(eq(content.id, input.content_id));
+ if (!c) throw new NotFoundError(`content ${input.content_id} not found`);
+ const [row] = await db
+ .insert(approvals)
+ .values({
+ content_id: input.content_id,
+ outlet: input.outlet,
+ approved_by: principal.sub,
+ notes: input.notes ?? null,
+ })
+ .returning();
+ await writeAudit(db, principal, "approvals.create", "approval", row!.id, correlationId, {
+ content_id: input.content_id,
+ outlet: input.outlet,
+ });
+ return row!;
+}
+
+export async function cancelPublication(
+ db: Db,
+ principal: Principal,
+ input: CancelPublicationRequest,
+ correlationId: string,
+) {
+ requireAllowed(principal, "publications.cancel");
+ const [pub] = await db
+ .select()
+ .from(publications)
+ .where(eq(publications.id, input.publication_id));
+ if (!pub) throw new NotFoundError(`publication ${input.publication_id} not found`);
+ if (pub.status === "published" || pub.status === "cancelled") {
+ throw new ConflictError(`cannot cancel a ${pub.status} publication`);
+ }
+ const [row] = await db
+ .update(publications)
+ .set({ status: "cancelled", error: input.reason })
+ .where(eq(publications.id, input.publication_id))
+ .returning();
+ await writeAudit(
+ db,
+ principal,
+ "publications.cancel",
+ "publication",
+ input.publication_id,
+ correlationId,
+ { reason: input.reason },
+ );
+ return row!;
+}
+
+export async function toggleFeatureFlag(
+ db: Db,
+ principal: Principal,
+ input: ToggleFeatureFlagRequest,
+ correlationId: string,
+) {
+ requireAllowed(principal, "feature_flags.toggle");
+ const [row] = await db
+ .insert(outlet_feature_flags)
+ .values({
+ outlet: input.outlet,
+ enabled: input.enabled,
+ reason: input.reason,
+ updated_by: principal.sub,
+ })
+ .onConflictDoUpdate({
+ target: outlet_feature_flags.outlet,
+ set: {
+ enabled: input.enabled,
+ reason: input.reason,
+ updated_by: principal.sub,
+ updated_at: new Date(),
+ },
+ })
+ .returning();
+ await writeAudit(
+ db,
+ principal,
+ "feature_flags.toggle",
+ "outlet",
+ input.outlet,
+ correlationId,
+ { enabled: input.enabled, reason: input.reason },
+ );
+ return row!;
+}
+
+// ---- Reads (for pages) ----
+
+export async function listQueue(db: Db) {
+ return db
+ .select({
+ id: publications.id,
+ outlet: publications.outlet,
+ status: publications.status,
+ scheduled_at: publications.scheduled_at,
+ published_at: publications.published_at,
+ external_url: publications.external_url,
+ error: publications.error,
+ content_id: content.id,
+ title: content.title,
+ slug: content.slug,
+ })
+ .from(publications)
+ .innerJoin(content, eq(publications.content_id, content.id))
+ .orderBy(desc(publications.scheduled_at))
+ .limit(200);
+}
+
+export async function getPublicationDetail(db: Db, id: number) {
+ const [pub] = await db
+ .select({
+ id: publications.id,
+ outlet: publications.outlet,
+ status: publications.status,
+ scheduled_at: publications.scheduled_at,
+ published_at: publications.published_at,
+ external_id: publications.external_id,
+ external_url: publications.external_url,
+ error: publications.error,
+ idempotency_key: publications.idempotency_key,
+ title: content.title,
+ slug: content.slug,
+ })
+ .from(publications)
+ .innerJoin(content, eq(publications.content_id, content.id))
+ .where(eq(publications.id, id));
+ if (!pub) return null;
+ const metricRows = await db
+ .select()
+ .from(metrics)
+ .where(eq(metrics.publication_id, id))
+ .orderBy(desc(metrics.collected_at));
+ return { pub, metrics: metricRows };
+}
+
+export async function listFeatureFlags(db: Db) {
+ return db.select().from(outlet_feature_flags).orderBy(outlet_feature_flags.outlet);
+}
+
+export async function listPublishedWithMetrics(db: Db) {
+ return db
+ .select({
+ id: publications.id,
+ outlet: publications.outlet,
+ published_at: publications.published_at,
+ impressions: metrics.impressions,
+ reactions: metrics.reactions,
+ comments: metrics.comments,
+ shares: metrics.shares,
+ })
+ .from(publications)
+ .leftJoin(metrics, eq(metrics.publication_id, publications.id))
+ .where(and(eq(publications.status, "published"), isNotNull(publications.published_at)))
+ .orderBy(desc(publications.published_at))
+ .limit(500);
+}
diff --git a/apps/admin/src/lib/heatmap.test.ts b/apps/admin/src/lib/heatmap.test.ts
new file mode 100644
index 0000000..fddd30f
--- /dev/null
+++ b/apps/admin/src/lib/heatmap.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from "vitest";
+import { buildHeatmap, AST_OFFSET_MS } from "./heatmap";
+
+describe("buildHeatmap", () => {
+ it("buckets by AST day/hour and sums engagement", () => {
+ // 2026-06-04T12:00Z is Thursday; AST (-4) → 08:00 Thursday.
+ const t = new Date("2026-06-04T12:00:00Z");
+ const cells = buildHeatmap([
+ { published_at: t, impressions: 100, reactions: 5, comments: 2, shares: 1 },
+ { published_at: t, impressions: 50, reactions: 3, comments: 0, shares: 0 },
+ ]);
+ expect(cells).toHaveLength(1);
+ expect(cells[0]).toMatchObject({ day: 4, hour: 8, count: 2, engagement: 11 });
+ });
+
+ it("skips rows with no published_at", () => {
+ expect(
+ buildHeatmap([
+ { published_at: null, impressions: 1, reactions: 1, comments: 1, shares: 1 },
+ ]),
+ ).toEqual([]);
+ });
+
+ it("treats null metric components as zero", () => {
+ const t = new Date("2026-06-04T12:00:00Z");
+ const cells = buildHeatmap([
+ { published_at: t, impressions: null, reactions: null, comments: null, shares: null },
+ ]);
+ expect(cells[0]).toMatchObject({ count: 1, engagement: 0 });
+ });
+
+ it("AST offset is a constant UTC-4 (Curaçao, no DST)", () => {
+ expect(AST_OFFSET_MS).toBe(-4 * 60 * 60 * 1000);
+ });
+});
diff --git a/apps/admin/src/lib/heatmap.ts b/apps/admin/src/lib/heatmap.ts
new file mode 100644
index 0000000..b6eb109
--- /dev/null
+++ b/apps/admin/src/lib/heatmap.ts
@@ -0,0 +1,40 @@
+/**
+ * Heatmap aggregation for the /metrics view. Pure + timezone-explicit so it is
+ * unit-testable. Buckets published posts by AST day-of-week × hour. Curaçao
+ * observes no DST, so the AST offset is a constant UTC-4.
+ */
+export const AST_OFFSET_MS = -4 * 60 * 60 * 1000;
+
+export interface MetricRow {
+ published_at: Date | null;
+ impressions: number | null;
+ reactions: number | null;
+ comments: number | null;
+ shares: number | null;
+}
+
+export interface HeatCell {
+ day: number; // 0=Sun … 6=Sat (AST)
+ hour: number; // 0…23 (AST)
+ count: number;
+ engagement: number; // reactions + comments + shares
+}
+
+export function buildHeatmap(rows: MetricRow[]): HeatCell[] {
+ const cells = new Map();
+ for (const r of rows) {
+ if (!r.published_at) continue;
+ const ast = new Date(r.published_at.getTime() + AST_OFFSET_MS);
+ const day = ast.getUTCDay();
+ const hour = ast.getUTCHours();
+ const key = `${day}-${hour}`;
+ const engagement = (r.reactions ?? 0) + (r.comments ?? 0) + (r.shares ?? 0);
+ const cell = cells.get(key) ?? { day, hour, count: 0, engagement: 0 };
+ cell.count += 1;
+ cell.engagement += engagement;
+ cells.set(key, cell);
+ }
+ return [...cells.values()].sort((a, b) => a.day - b.day || a.hour - b.hour);
+}
+
+export const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json
new file mode 100644
index 0000000..8ab30f4
--- /dev/null
+++ b/apps/admin/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "jsx": "preserve",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "allowJs": true,
+ "noEmit": true,
+ "incremental": true,
+ "declaration": false,
+ "declarationMap": false,
+ "sourceMap": false,
+ "plugins": [{ "name": "next" }],
+ "types": ["node", "react", "react-dom"],
+ "paths": { "@/*": ["./src/*"] },
+ "exactOptionalPropertyTypes": false
+ },
+ "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules", ".next"]
+}
diff --git a/bun.lock b/bun.lock
index 393fdb4..735f1ab 100644
--- a/bun.lock
+++ b/bun.lock
@@ -19,17 +19,22 @@
"@stargue/observability": "workspace:*",
"@stargue/sanitize": "workspace:*",
"@stargue/schema": "workspace:*",
+ "drizzle-orm": "^0.36.0",
"next": "^16.0.0",
+ "postgres": "^3.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "server-only": "^0.0.1",
"zod": "^3.23.0",
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@playwright/test": "^1.48.0",
+ "@tailwindcss/postcss": "^4.2.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"axe-core": "^4.10.0",
+ "tailwindcss": "^4.2.2",
"typescript": "^5.7.0",
"vitest": "^2.1.0",
},
@@ -116,6 +121,8 @@
},
},
"packages": {
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
"@axe-core/playwright": ["@axe-core/playwright@4.11.2", "", { "dependencies": { "axe-core": "~4.11.3" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-iP6hfNl9G0j/SEUSo8M7D80RbcDo9KRAAfDP4IT5OHB+Wm6zUHIrm8Y51BKI+Oyqduvipf9u1hcRy57zCBKzWQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
@@ -234,8 +241,16 @@
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
@@ -344,6 +359,36 @@
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
+ "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
+
+ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="],
+
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
@@ -458,6 +503,8 @@
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+ "enhanced-resolve": ["enhanced-resolve@5.22.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag=="],
+
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
@@ -484,6 +531,8 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
"graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="],
"headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="],
@@ -496,6 +545,32 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
+
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
@@ -598,7 +673,7 @@
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
- "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+ "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
@@ -634,6 +709,8 @@
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+ "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
+
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@@ -670,6 +747,10 @@
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+ "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
+
+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -740,14 +821,26 @@
"@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
- "vite/postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
-
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],