- At a glance
-
- Quick counts of published content, drafts, and users.
+
+ {/* ─── Page head ─── */}
+
+
+
+ Site pulse .
+
+
+ A vital-signs view of the workspace. Drafts, readers, and revenue —
+ updated as the site lives and grows.
-
+
+
+
+ Live · updated just now
+
+
-
- Activity
-
- Recent edits, publishes, and comments.
-
-
+ {/* ─── Stat tiles ─── */}
+
+ {STAT_TILES.map((tile) => (
+
+ ))}
+
-
- Quick draft
-
- Start a new post without leaving the dashboard.
-
-
+ {/* ─── Reader-flow forest pulse card ─── */}
+
+ {/* Organic radial-glow on a dark surface — the brand's
+ signature backdrop pattern. */}
+
+
+
+
+ Live · Last 5 minutes
+
+
+ 142 readers, now .
+
+
+ Reader flow over the last 24 hours, with conversions overlaid in
+ lavender.
+
+
+
+
+ Views
+
+
+
+ Conversions
+
+
+
+
+
+
+
+
+
Top post
+
+ Single-origin beans
+
+
+
+
Avg. session
+ 2m 47s
+
+
+
p50 TTFB
+
+ 38ms
+
+
+
+
+
-
- News
-
- Release notes and project updates from the GoNext team.
+ {/* ─── Histogram + sidebar ─── */}
+
+
+
+
+
+ Reader sessions · 24h
+
+
+ Where they linger .
+
+
+
+
+ count
+
+
+ 7,842 sessions
+
+
+
+
+
+
+
+ Lavender bars are the session-length distribution; peaks in
+ emerald mark where most
+ readers spend their time.
-
+
+
+
+
+ Recent activity
+
+
+ What just happened .
+
+
+
+
+
+
+
+ Deploy succeeded ·
+ v1.2.4 → production
+
+ 14 routes invalidated · TTFB unchanged
+
+
+
+
+
+
+
+
+ Reader from Tokyo opened
+ Drip vs. pour-over
+
+ Mobile Safari · 3rd session
+
+
+
+
+
+
+
+
+ New draft Holiday hours picked up by Mara
+
+ Auto-save · 12 minutes ago
+
+
+
+
+
);
diff --git a/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap b/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap
new file mode 100644
index 00000000..4e2a42ea
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap
@@ -0,0 +1,96 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Page detail page > matches the page-head snapshot 1`] = `
+
+`;
diff --git a/apps/admin/src/app/(authenticated)/pages/[id]/page.test.tsx b/apps/admin/src/app/(authenticated)/pages/[id]/page.test.tsx
new file mode 100644
index 00000000..5850591b
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/pages/[id]/page.test.tsx
@@ -0,0 +1,50 @@
+/**
+ * Page detail tests — sibling of posts/[id]/page.test.tsx.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ id: 'about' }),
+ usePathname: () => '/pages/about',
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ refresh: vi.fn(),
+ }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+import PageDetailPage from './page';
+
+describe('Page detail page', () => {
+ it('renders the italic-accent headline', () => {
+ render( );
+ const h1 = screen.getByRole('heading', { level: 1 });
+ expect(h1.textContent).toMatch(/Edit\s+page\./);
+ expect(h1.querySelector('em')?.textContent).toBe('page');
+ });
+
+ it('renders the inspector sidebar', () => {
+ render( );
+ expect(
+ screen.getByLabelText('Page metadata inspector'),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Status' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Metadata' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: /SEO/i })).toBeInTheDocument();
+ });
+
+ it('renders the back link to /pages', () => {
+ render( );
+ const back = screen.getByRole('link', { name: /Back to pages/i });
+ expect(back).toHaveAttribute('href', '/pages');
+ });
+
+ it('matches the page-head snapshot', () => {
+ const { container } = render( );
+ const head = container.querySelector('[data-testid="page-detail"] > div');
+ expect(head).toMatchSnapshot();
+ });
+});
diff --git a/apps/admin/src/app/(authenticated)/pages/[id]/page.tsx b/apps/admin/src/app/(authenticated)/pages/[id]/page.tsx
new file mode 100644
index 00000000..20c1277d
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/pages/[id]/page.tsx
@@ -0,0 +1,248 @@
+/**
+ * Page detail / edit-metadata — sibling of posts/[id].
+ *
+ * Pages share the post-type infrastructure (docs/05-admin-api.md §3.1)
+ * but the metadata surface trims the bits that don't apply to
+ * evergreen content (no scheduling-as-publication, no category
+ * taxonomy by default). The brand surface stays identical so the IA
+ * is predictable.
+ *
+ * The block editor for pages opens via the same per-resource route
+ * (/pages/[id]/edit); this page is intentionally the metadata-only
+ * view so editors can quickly toggle visibility, change the URL, or
+ * tweak SEO without entering edit mode.
+ */
+'use client';
+
+import type { ReactElement } from 'react';
+import { useState } from 'react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import {
+ Calendar,
+ ChevronLeft,
+ Eye,
+ Globe,
+ Save,
+ User,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Headline } from '@/components/ui/headline';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+type PageStatus = 'draft' | 'publish' | 'private';
+
+export default function PageDetailPage(): ReactElement {
+ const params = useParams<{ id: string }>();
+ const pageId = params?.id ?? 'new';
+
+ const [title, setTitle] = useState('Untitled page');
+ const [slug, setSlug] = useState('/untitled-page');
+ const [status, setStatus] = useState('draft');
+
+ return (
+
+
+
+
+ Back to pages
+
+
+
+
+ Edit page .
+
+
+ Update the slug, visibility, and SEO blurb.{' '}
+ #{pageId}
+
+
+
+
+ Cancel
+
+ {
+ // eslint-disable-next-line no-console
+ console.log('[page-detail] save', { pageId, title, slug, status });
+ }}
+ >
+
+ Save changes
+
+
+
+
+
+
+ {/* Main editor column */}
+
+
+
+
+
+ Block editor .
+
+
+ Pages typically have layout-heavy content. Open the block editor
+ to compose hero sections, columns, and embeds.
+
+
+ Open block editor →
+
+
+
+
+ {/* Sidebar inspector */}
+
+
+
+
+ Status
+
+
+
+
+
+ Current
+
+ {status === 'publish' ? (
+
+ Published
+
+ ) : status === 'private' ? (
+
+ Private
+
+ ) : (
+ Draft
+ )}
+
+
+
+ Change to
+
+ setStatus(e.target.value as PageStatus)}
+ className="rounded-md border border-border bg-paper px-3 py-2 font-sans text-sm text-ink transition-colors focus:border-emerald focus:shadow-focus focus:outline-none"
+ >
+ Draft
+ Publish now
+ Private
+
+
+
+
+
+
+
+
+ Metadata
+
+
+
+
+
+
+ Created
+
+
+ 2026-04-02 11:30
+
+
+
+
+
+ Updated
+
+
+ 3 days ago
+
+
+
+
+
+ Visibility
+
+ Public
+
+
+
+
+ Author
+
+ Mara Wills
+
+
+
+
+
+
+
+
+
+ SEO
+
+
+
+
+
+
+ Meta title
+
+
+
+
+
+ Meta description
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(authenticated)/pages/__snapshots__/page.test.tsx.snap b/apps/admin/src/app/(authenticated)/pages/__snapshots__/page.test.tsx.snap
new file mode 100644
index 00000000..ce9bd596
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/pages/__snapshots__/page.test.tsx.snap
@@ -0,0 +1,50 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Pages list page > matches the page-head snapshot 1`] = `
+
+
+
+
+ Evergreen content — about, contact, policy. Edit metadata or open the block editor for layout-heavy pages.
+
+
+
+
+
+
+
+ New page
+
+
+`;
diff --git a/apps/admin/src/app/(authenticated)/pages/page.test.tsx b/apps/admin/src/app/(authenticated)/pages/page.test.tsx
new file mode 100644
index 00000000..7ed961a2
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/pages/page.test.tsx
@@ -0,0 +1,55 @@
+/**
+ * Pages list snapshot tests.
+ *
+ * Pins the brand chrome (italic-accent headline, filter chip strip,
+ * table structure) without touching the underlying data — the data
+ * is a static seed until the pages REST endpoint ships.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/pages',
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ refresh: vi.fn(),
+ }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+import PagesPage from './page';
+
+describe('Pages list page', () => {
+ it('renders the italic-accent headline', () => {
+ render( );
+ const h1 = screen.getByRole('heading', { level: 1 });
+ expect(h1.textContent).toMatch(/All\s+pages\./);
+ expect(h1.querySelector('em')?.textContent).toBe('pages');
+ });
+
+ it('renders the New page primary CTA', () => {
+ render( );
+ const cta = screen.getByRole('link', { name: /New page/i });
+ expect(cta).toHaveAttribute('href', '/pages/new');
+ });
+
+ it('renders the filter chip strip', () => {
+ render( );
+ const group = screen.getByRole('group', { name: /Filter by status/i });
+ expect(group).toBeInTheDocument();
+ });
+
+ it('renders the pages table', () => {
+ render( );
+ const table = screen.getByRole('table', { name: 'Pages' });
+ expect(table).toBeInTheDocument();
+ });
+
+ it('matches the page-head snapshot', () => {
+ const { container } = render( );
+ const head = container.querySelector('[data-testid="pages-page"] > div');
+ expect(head).toMatchSnapshot();
+ });
+});
diff --git a/apps/admin/src/app/(authenticated)/pages/page.tsx b/apps/admin/src/app/(authenticated)/pages/page.tsx
index 2a384d2c..282d5616 100644
--- a/apps/admin/src/app/(authenticated)/pages/page.tsx
+++ b/apps/admin/src/app/(authenticated)/pages/page.tsx
@@ -1,15 +1,240 @@
/**
- * Pages — placeholder.
+ * Pages — admin list screen for site pages.
*
- * "Coming soon" surface. Real CMS pages CRUD lands later.
+ * Mirrors the posts surface (docs/05-admin-api.md §2.3) — pages are
+ * the "evergreen" content type, distinct from time-stamped posts. The
+ * shape of the screen (filters, table, pagination) is identical so
+ * the IA stays predictable; the data set is just narrowed to the
+ * `page` post-type on the API side.
+ *
+ * The pages REST endpoint is tracked in issue #76. Until it lands
+ * this page renders the empty/error state — the same pattern the
+ * posts page uses to stay defensible.
+ *
+ * Brand treatment ("Living systems"): display-type headline with the
+ * italic-serif accent ("All *pages*."), an emerald-soft active filter
+ * chip strip, and paper-3 row hover. Matches the moodboard pattern
+ * in `docs/design/ui_kits/admin/index.html`.
*/
+import Link from 'next/link';
import type { ReactElement } from 'react';
+import { Plus } from 'lucide-react';
+import { Headline } from '@/components/ui/headline';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+
+export const dynamic = 'force-dynamic';
+
+interface SitePage {
+ id: string;
+ title: string;
+ slug: string;
+ status: 'publish' | 'draft' | 'future';
+ updatedAt: string;
+}
+
+/** Seed pages until the API endpoint lands. The brand is more
+ * important than the data — once #76 ships this list comes from
+ * `GET /api/v1/posts?type=page`. */
+const SEED_PAGES: readonly SitePage[] = [
+ {
+ id: 'about',
+ title: 'About',
+ slug: '/about',
+ status: 'publish',
+ updatedAt: '3 days ago',
+ },
+ {
+ id: 'contact',
+ title: 'Contact',
+ slug: '/contact',
+ status: 'publish',
+ updatedAt: '2 weeks ago',
+ },
+ {
+ id: 'privacy',
+ title: 'Privacy policy',
+ slug: '/privacy',
+ status: 'publish',
+ updatedAt: '1 month ago',
+ },
+ {
+ id: 'shipping',
+ title: 'Shipping & returns',
+ slug: '/shipping',
+ status: 'draft',
+ updatedAt: 'Yesterday',
+ },
+ {
+ id: 'press',
+ title: 'Press kit',
+ slug: '/press',
+ status: 'future',
+ updatedAt: 'Mar 12, 10:00',
+ },
+];
+
+function PageStatus({ status }: { status: SitePage['status'] }): ReactElement {
+ if (status === 'publish') {
+ return (
+
+ Published
+
+ );
+ }
+ if (status === 'future') {
+ return (
+
+ Scheduled
+
+ );
+ }
+ return Draft ;
+}
export default function PagesPage(): ReactElement {
return (
-
- Pages
- Coming soon.
+
+ {/* ─── Page head ─── */}
+
+
+
+ All pages .
+
+
+ Evergreen content — about, contact, policy. Edit metadata or open
+ the block editor for layout-heavy pages.
+
+
+
+
+
+ New page
+
+
+
+
+ {/* ─── Filter strip ─── */}
+
+
+
+
+
+ All
+
+
+ Published
+
+
+ Drafts
+
+
+ Scheduled
+
+
+
+
+
+ {/* ─── Table ─── */}
+
+
+
+
+
+ Title
+
+
+ Slug
+
+
+ Status
+
+
+ Updated
+
+
+ Actions
+
+
+
+
+ {SEED_PAGES.map((page) => (
+
+
+
+ {page.title}
+
+
+
+ {page.slug}
+
+
+
+
+
+ {page.updatedAt}
+
+
+
+ Edit →
+
+
+
+ ))}
+
+
+
+
+ Showing {SEED_PAGES.length} of {SEED_PAGES.length}
+
+
+
);
}
diff --git a/apps/admin/src/app/(authenticated)/posts/[id]/__snapshots__/page.test.tsx.snap b/apps/admin/src/app/(authenticated)/posts/[id]/__snapshots__/page.test.tsx.snap
new file mode 100644
index 00000000..d598d7f4
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/posts/[id]/__snapshots__/page.test.tsx.snap
@@ -0,0 +1,96 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Post detail page > matches the page-head snapshot 1`] = `
+
+`;
diff --git a/apps/admin/src/app/(authenticated)/posts/[id]/page.test.tsx b/apps/admin/src/app/(authenticated)/posts/[id]/page.test.tsx
new file mode 100644
index 00000000..2fdbfb93
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/posts/[id]/page.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * Post detail / edit-metadata page tests.
+ *
+ * Pins the brand restyle:
+ * • Italic-accent headline ("Edit *post*.")
+ * • Inspector sidebar with the canonical panels
+ * • Crumb back-link
+ * • Status / Schedule / SEO sections all addressable by heading
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ id: 'p1' }),
+ usePathname: () => '/posts/p1',
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ refresh: vi.fn(),
+ }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+import PostDetailPage from './page';
+
+describe('Post detail page', () => {
+ it('renders the italic-accent headline', () => {
+ render( );
+ const h1 = screen.getByRole('heading', { level: 1 });
+ expect(h1.textContent).toMatch(/Edit\s+post\./);
+ expect(h1.querySelector('em')?.textContent).toBe('post');
+ });
+
+ it('renders the inspector sidebar panels', () => {
+ render( );
+ const inspector = screen.getByLabelText('Post metadata inspector');
+ expect(inspector).toBeInTheDocument();
+ // Status / Schedule / Categories & tags / SEO headings.
+ expect(screen.getByRole('heading', { name: 'Status' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Schedule' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { name: /Categories & tags/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: /SEO/i })).toBeInTheDocument();
+ });
+
+ it('renders the back link to /posts', () => {
+ render( );
+ const back = screen.getByRole('link', { name: /Back to posts/i });
+ expect(back).toHaveAttribute('href', '/posts');
+ });
+
+ it('shows the post id in the subhead', () => {
+ render( );
+ // The id is rendered inside the subhead as "#p1".
+ expect(screen.getByTestId('post-detail').textContent).toMatch(/#p1/);
+ });
+
+ it('matches the page-head snapshot', () => {
+ const { container } = render( );
+ const head = container.querySelector('[data-testid="post-detail"] > div');
+ expect(head).toMatchSnapshot();
+ });
+});
diff --git a/apps/admin/src/app/(authenticated)/posts/[id]/page.tsx b/apps/admin/src/app/(authenticated)/posts/[id]/page.tsx
new file mode 100644
index 00000000..d8414e9b
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/posts/[id]/page.tsx
@@ -0,0 +1,315 @@
+/**
+ * Post detail / edit-metadata — the meta-data side of the post-edit
+ * surface. The block editor (which lives inside the same route in
+ * v0.3 / N3) opens in a dedicated layout — this page covers the
+ * metadata-only edit surface: title, slug, status, scheduling,
+ * categories, SEO blurb.
+ *
+ * Brand treatment ("Living systems"): 1fr / 320px split. The left
+ * column carries the editable title (Headline display-type), slug
+ * input, and an excerpt textarea on cream paper. The right column
+ * is a sidebar inspector — Geist label / Geist Mono value pairs
+ * with emerald accents on status pills and a publish CTA at the
+ * bottom. Pattern mirrors the right inspector from
+ * `docs/design/ui_kits/editor/index.html`.
+ *
+ * The page is intentionally a thin client component for now: it
+ * renders the inspector UI without wiring back to the API. The save
+ * action stubs to console; real wiring lands once the PATCH endpoint
+ * (issue #76) ships. The architectural goal here is to land the
+ * brand surface so subsequent feature PRs can hang real data on it.
+ */
+'use client';
+
+import type { ReactElement } from 'react';
+import { useState } from 'react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import {
+ Calendar,
+ ChevronLeft,
+ Eye,
+ Globe,
+ Save,
+ Tag,
+ User,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Headline } from '@/components/ui/headline';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+type PostStatus = 'draft' | 'publish' | 'future' | 'private';
+
+export default function PostDetailPage(): ReactElement {
+ const params = useParams<{ id: string }>();
+ const postId = params?.id ?? 'new';
+
+ const [title, setTitle] = useState('Untitled post');
+ const [slug, setSlug] = useState('untitled-post');
+ const [excerpt, setExcerpt] = useState('');
+ const [status, setStatus] = useState('draft');
+
+ return (
+
+ {/* ─── Crumb + page head ─── */}
+
+
+
+ Back to posts
+
+
+
+
+ Edit post .
+
+
+ Update the metadata, slug, and publish state.{' '}
+ #{postId}
+
+
+
+
+ Cancel
+
+ {
+ // Stubbed save — wiring lands with the PATCH endpoint.
+ // eslint-disable-next-line no-console
+ console.log('[post-detail] save', { postId, title, slug, status });
+ }}
+ >
+
+ Save changes
+
+
+
+
+
+ {/* ─── Body — 1fr / 320px split ─── */}
+
+ {/* Main editor column */}
+
+
+
+
+ Title
+
+ setTitle(e.target.value)}
+ className="w-full bg-transparent font-display text-3xl font-bold leading-tight tracking-tight text-ink outline-none placeholder:text-fg-faint focus:outline-none"
+ placeholder="What's this post about?"
+ />
+
+
+
+
+ Slug
+
+
+
+ /blog/
+
+ setSlug(e.target.value)}
+ className="border-0 bg-transparent font-mono focus-visible:ring-0 focus-visible:shadow-none"
+ />
+
+
+
+
+
+ Excerpt
+
+
+
+
+
+
+ Block editor .
+
+
+ The block editor opens in a focus mode — title and body live
+ there. This metadata surface stays here for quick edits.
+
+
+
+ Open block editor →
+
+
+
+
+
+ {/* ─── Sidebar inspector ─── */}
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(authenticated)/posts/__snapshots__/page.test.tsx.snap b/apps/admin/src/app/(authenticated)/posts/__snapshots__/page.test.tsx.snap
new file mode 100644
index 00000000..e937a63e
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/posts/__snapshots__/page.test.tsx.snap
@@ -0,0 +1,13 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Posts page head > matches the page-head snapshot 1`] = `
+
+`;
diff --git a/apps/admin/src/app/(authenticated)/posts/columns.tsx b/apps/admin/src/app/(authenticated)/posts/columns.tsx
index 9b954657..68a5195c 100644
--- a/apps/admin/src/app/(authenticated)/posts/columns.tsx
+++ b/apps/admin/src/app/(authenticated)/posts/columns.tsx
@@ -79,6 +79,13 @@ export function serializeSort(spec: SortSpec): string {
/**
* Status badge — small coloured pill that maps a status string onto an
* accessible label + visual treatment.
+ *
+ * Brand palette ("Living systems" — docs/design/colors_and_type.css
+ * `.tag` variants):
+ * • published → success-soft / success-deep
+ * • scheduled → lavender-soft / lavender-deep
+ * • draft / pending → paper-3 / fg-muted (neutral)
+ * • trash → danger-soft / danger
*/
export function StatusBadge({ status }: { status: PostStatus }): ReactElement {
const className =
@@ -86,9 +93,11 @@ export function StatusBadge({ status }: { status: PostStatus }): ReactElement {
? `${styles.badge} ${styles.badgePublished}`
: status === 'trash'
? `${styles.badge} ${styles.badgeTrash}`
- : status === 'draft' || status === 'pending'
- ? `${styles.badge} ${styles.badgeDraft}`
- : styles.badge;
+ : status === 'future'
+ ? `${styles.badge} ${styles.badgeScheduled}`
+ : status === 'draft' || status === 'pending'
+ ? `${styles.badge} ${styles.badgeDraft}`
+ : styles.badge;
const label =
status === 'publish'
diff --git a/apps/admin/src/app/(authenticated)/posts/page.test.tsx b/apps/admin/src/app/(authenticated)/posts/page.test.tsx
new file mode 100644
index 00000000..6f2ca839
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/posts/page.test.tsx
@@ -0,0 +1,34 @@
+/**
+ * Posts list — page head snapshot tests.
+ *
+ * The page itself is a server component that hits the network, so we
+ * can't render the whole tree here. Instead we extract the page-head
+ * fragment by rendering a minimal harness and verifying that the
+ * Headline composition is correct (italic-serif accent on "posts").
+ */
+import { describe, expect, it } from 'vitest';
+import { render } from '@testing-library/react';
+import { Headline } from '@/components/ui/headline';
+
+describe('Posts page head', () => {
+ it('renders the brand "All posts." headline with the italic accent', () => {
+ const { container } = render(
+
+ All posts .
+ ,
+ );
+ const h1 = container.querySelector('h1');
+ expect(h1).not.toBeNull();
+ expect(h1?.textContent).toMatch(/All\s+posts\./);
+ expect(h1?.querySelector('em')?.textContent).toBe('posts');
+ });
+
+ it('matches the page-head snapshot', () => {
+ const { container } = render(
+
+ All posts .
+ ,
+ );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/apps/admin/src/app/(authenticated)/posts/page.tsx b/apps/admin/src/app/(authenticated)/posts/page.tsx
index ea6b2dcb..e30d6678 100644
--- a/apps/admin/src/app/(authenticated)/posts/page.tsx
+++ b/apps/admin/src/app/(authenticated)/posts/page.tsx
@@ -17,6 +17,10 @@
* │ fetches first page │ │ search/filter/sort/etc. │
* └─────────────────────┘ └──────────────────────────┘
*
+ * Brand treatment ("Living systems"): the page head adopts the
+ * display-type with the italic-serif accent ("All *posts*.") matching
+ * the admin moodboard in `docs/design/ui_kits/admin/index.html`.
+ *
* Auth
* ====
* Admin pages are session-protected. The session cookie lives on the
@@ -25,38 +29,14 @@
* the server-side fetch would issue an anonymous request and the API
* would 401 every list screen. The auth middleware in front of the
* admin guarantees `cookies()` is populated by the time we get here.
- *
- * REST API dependency
- * ===================
- * The endpoint `GET /api/v1/posts` is tracked in issue #76 and may not
- * yet exist in `main` when this PR lands. The component is defensive:
- * on any fetch failure (network error, 404, 5xx, etc.) we render the
- * empty / error state inline. The page never throws and never crashes
- * the surrounding layout. When #76 ships the only thing that changes
- * is that real data starts appearing — no code change here.
- *
- * Suspense + Error boundary
- * =========================
- * Initial fetch happens inside a `` boundary with a simple
- * skeleton fallback so the surrounding layout (sidebar, header) paints
- * immediately. A client-side `PostsErrorBoundary` wraps the interactive
- * island to catch any render error and offer a retry.
- *
- * Pagination
- * ==========
- * "Load more" (cursor-based). Chosen over numbered pages because
- * (a) it composes naturally with the API's cursor scheme,
- * (b) the implementation is simpler — no need for total-count math
- * on the server,
- * (c) the user need for direct page jumps is low for an admin list
- * (Saved Views handle the "I want exactly this slice" case).
- * Numbered pages can be added later if the activity log grows large
- * enough to warrant a page-jump UI.
*/
import { cookies } from 'next/headers';
import Link from 'next/link';
import { Suspense, type ReactElement } from 'react';
+import { Download, Plus } from 'lucide-react';
import { apiBaseUrl } from '@/lib/api-client';
+import { Headline } from '@/components/ui/headline';
+import { Button } from '@/components/ui/button';
import { PostListClient } from './PostListClient';
import { PostsErrorBoundary } from './PostsErrorBoundary';
import type { PostListResponse } from './columns';
@@ -185,16 +165,34 @@ async function PostsListServer(): Promise {
export default function PostsPage(): ReactElement {
return (
-
-
-
Posts
-
- New post
-
+
+ {/* ─── Page head — brand display-type with italic accent ─── */}
+
+
+
+ All posts .
+
+
+ Drafts, scheduled, and published content. Filter by status to focus
+ on what needs attention.
+
+
+
+
+
+
+ Import
+
+
+
+
+
+ New post
+
+
+
+
}>
diff --git a/apps/admin/src/app/(authenticated)/posts/posts.module.css b/apps/admin/src/app/(authenticated)/posts/posts.module.css
index 50de4768..6342070f 100644
--- a/apps/admin/src/app/(authenticated)/posts/posts.module.css
+++ b/apps/admin/src/app/(authenticated)/posts/posts.module.css
@@ -1,90 +1,129 @@
/*
* Scoped styles for the Posts list screen.
*
- * The admin scaffold is intentionally pre-Tailwind (see globals.css and
- * docs/05-admin-api.md §2.3) — when the design-system extraction lands
- * (issue #34) these primitives migrate to design tokens. For now we keep
- * them local to the route so any future rename / tear-down is a single
- * directory delete.
+ * "Living systems" brand restyle: cream paper-2 panel, emerald
+ * filter-chip on active, paper-3 row hover. Mirrors the table
+ * treatment in `docs/design/ui_kits/admin/index.html`.
*/
.toolbar {
display: flex;
flex-wrap: wrap;
- gap: var(--space-3);
+ gap: var(--s-3);
align-items: center;
- margin-bottom: var(--space-4);
- padding: var(--space-3);
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: var(--radius);
+ padding: 14px 18px;
+ background: var(--paper-2);
+ border: 1px solid var(--border);
+ border-bottom: 0;
+ border-radius: var(--r-lg) var(--r-lg) 0 0;
}
.search {
flex: 1 1 240px;
min-width: 180px;
- border: 1px solid var(--color-border);
- border-radius: var(--radius);
- padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ padding: 8px 12px;
font: inherit;
+ font-size: var(--t-sm);
+ color: var(--ink);
+ background: var(--paper);
+ transition: border-color var(--dur) var(--ease),
+ box-shadow var(--dur) var(--ease);
+}
+
+.search:hover {
+ border-color: var(--border-strong);
}
.search:focus {
- outline: 2px solid var(--color-accent);
- outline-offset: -1px;
- border-color: var(--color-accent);
+ outline: none;
+ border-color: var(--emerald);
+ box-shadow: var(--sh-focus);
+}
+
+.search::placeholder {
+ color: var(--fg-faint);
}
.chipGroup {
display: inline-flex;
flex-wrap: wrap;
- gap: var(--space-2);
+ gap: 4px;
+ padding: 2px;
+ background: var(--paper-3);
+ border-radius: var(--r-md);
}
.chip {
background: transparent;
- border: 1px solid var(--color-border);
- border-radius: 999px;
- padding: 4px var(--space-3);
- font-size: 13px;
- color: var(--color-text);
+ border: 1px solid transparent;
+ border-radius: var(--r-sm);
+ padding: 5px 12px;
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
+ font-weight: 500;
+ color: var(--fg-muted);
+ cursor: pointer;
+ transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.chip:hover {
- border-color: var(--color-accent);
+ color: var(--ink);
}
.chipActive {
- background: var(--color-accent);
- color: white;
- border-color: var(--color-accent);
+ background: var(--emerald-soft);
+ color: var(--emerald-deep);
+ border-color: transparent;
+ box-shadow: var(--sh-xs);
}
.chipActive:hover {
- background: var(--color-accent-hover);
- border-color: var(--color-accent-hover);
+ background: var(--emerald-soft);
+ color: var(--emerald-deep);
}
.bulkBar {
display: inline-flex;
align-items: center;
- gap: var(--space-2);
+ gap: var(--s-2);
}
.bulkSelect {
- border: 1px solid var(--color-border);
- border-radius: var(--radius);
- padding: var(--space-1) var(--space-2);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ padding: 6px 10px;
font: inherit;
- background: var(--color-surface);
+ font-size: var(--t-sm);
+ background: var(--paper);
+ color: var(--ink);
+}
+
+.bulkSelect:focus {
+ outline: none;
+ border-color: var(--emerald);
+ box-shadow: var(--sh-focus);
}
.bulkApply {
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: var(--radius);
- padding: 4px var(--space-3);
- font-size: 13px;
+ background: var(--paper-2);
+ color: var(--ink);
+ border: 1px solid var(--border);
+ border-radius: var(--r-md);
+ padding: 5px 14px;
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
+ font-weight: 500;
+ cursor: pointer;
+ box-shadow: var(--sh-xs);
+ transition: background var(--dur) var(--ease),
+ border-color var(--dur) var(--ease);
+}
+
+.bulkApply:hover:not(:disabled) {
+ background: var(--paper-3);
+ border-color: var(--border-strong);
}
.bulkApply:disabled {
@@ -93,27 +132,45 @@
}
.tableWrap {
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: var(--radius);
+ background: var(--paper);
+ border: 1px solid var(--border);
+ border-top: 1px solid var(--border);
+ border-radius: 0 0 var(--r-lg) var(--r-lg);
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
- font-size: 14px;
+ font-size: var(--t-sm);
+ background: var(--paper);
}
.table thead {
- background: var(--color-bg);
+ background: var(--paper-2);
}
.table th,
.table td {
text-align: left;
- padding: var(--space-3);
- border-bottom: 1px solid var(--color-border);
+ padding: 13px 18px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.table th {
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
+ font-weight: 500;
+ color: var(--fg-subtle);
+ letter-spacing: 0;
+}
+
+.table tbody tr {
+ transition: background var(--dur-fast) var(--ease);
+}
+
+.table tbody tr:hover {
+ background: var(--paper-3);
}
.table tbody tr:last-child td {
@@ -125,8 +182,10 @@
border: 0;
padding: 0;
font: inherit;
- font-weight: 600;
- color: var(--color-text);
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
+ font-weight: 500;
+ color: var(--fg-subtle);
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -134,75 +193,112 @@
}
.sortButton:hover {
- color: var(--color-accent);
+ color: var(--emerald-deep);
}
.sortArrow {
font-size: 10px;
- color: var(--color-text-muted);
+ color: var(--fg-subtle);
}
+/* ─── Status badges — token-driven ─── */
.badge {
- display: inline-block;
- padding: 2px var(--space-2);
- border-radius: 999px;
- font-size: 12px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: var(--r-sm);
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
font-weight: 500;
- background: var(--color-bg);
- color: var(--color-text-muted);
- border: 1px solid var(--color-border);
+ background: var(--paper-3);
+ color: var(--fg-muted);
+ border: 1px solid var(--border);
+ line-height: 1.5;
+}
+
+.badge::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: currentColor;
+ opacity: 0.9;
}
.badgePublished {
- background: #e6f4ea;
- color: #137333;
- border-color: #c1e3cb;
+ background: var(--success-soft);
+ color: var(--success);
+ border-color: transparent;
}
.badgeDraft {
- background: #fef7e0;
- color: #92740c;
- border-color: #f7e6a2;
+ background: var(--paper-3);
+ color: var(--fg-muted);
+ border-color: var(--border);
}
.badgeTrash {
- background: #fce8e6;
- color: #c5221f;
- border-color: #f4c7c3;
+ background: var(--danger-soft);
+ color: var(--danger);
+ border-color: transparent;
+}
+
+.badgeScheduled {
+ background: var(--lavender-soft);
+ color: var(--lavender-deep);
+ border-color: transparent;
}
.empty {
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: var(--radius);
- padding: var(--space-8);
+ background: var(--paper-2);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ padding: var(--s-8);
text-align: center;
+ box-shadow: var(--sh-xs);
}
.empty h2 {
- font-size: 18px;
- margin-bottom: var(--space-2);
+ font-family: var(--font-display);
+ font-weight: 800;
+ font-size: var(--t-xl);
+ margin: 0 0 var(--s-2);
+ color: var(--ink);
}
.empty p {
- color: var(--color-text-muted);
- margin: 0 0 var(--space-4);
+ color: var(--fg-muted);
+ margin: 0 0 var(--s-4);
}
.error {
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-left: 3px solid #c5221f;
- border-radius: var(--radius);
- padding: var(--space-6);
+ background: var(--paper-2);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--danger);
+ border-radius: var(--r-lg);
+ padding: var(--s-6);
+}
+
+.error h2 {
+ font-family: var(--font-display);
+ font-weight: 700;
+ font-size: var(--t-lg);
+ margin: 0 0 var(--s-2);
+ color: var(--ink);
}
.skeletonRow {
height: 18px;
- margin: var(--space-3) 0;
- background: linear-gradient(90deg, #eef0f3 0%, #f6f7f9 50%, #eef0f3 100%);
+ margin: var(--s-3) 0;
+ background: linear-gradient(
+ 90deg,
+ var(--paper-2) 0%,
+ var(--paper-3) 50%,
+ var(--paper-2) 100%
+ );
background-size: 200% 100%;
- border-radius: 4px;
+ border-radius: var(--r-xs);
animation: skeletonShimmer 1.4s ease-in-out infinite;
}
@@ -218,33 +314,44 @@
.loadMoreWrap {
display: flex;
justify-content: center;
- margin-top: var(--space-4);
+ margin-top: var(--s-4);
}
.headerBar {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: var(--space-4);
+ margin-bottom: var(--s-4);
}
.primaryAction {
- background: var(--color-accent);
- color: white;
- border: 0;
- border-radius: var(--radius);
- padding: var(--space-2) var(--space-4);
- font-weight: 500;
+ background: var(--emerald);
+ color: var(--emerald-ink);
+ border: 1px solid var(--emerald);
+ border-radius: var(--r-md);
+ padding: 9px 16px;
+ font-family: var(--font-display);
+ font-weight: 700;
+ font-size: var(--t-sm);
text-decoration: none;
- font-size: 14px;
display: inline-block;
+ transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
+ box-shadow: var(--sh-xs);
}
.primaryAction:hover {
- background: var(--color-accent-hover);
+ background: var(--emerald-deep);
+ color: var(--paper);
text-decoration: none;
}
.titleCell a {
font-weight: 500;
+ color: var(--ink);
+ text-decoration: none;
+}
+
+.titleCell a:hover {
+ color: var(--emerald-deep);
+ text-decoration: none;
}
diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css
index c136911b..494e56dc 100644
--- a/apps/admin/src/app/globals.css
+++ b/apps/admin/src/app/globals.css
@@ -96,14 +96,17 @@ h2 {
}
/* ---------------------------------------------------------------------------
- * Shell — sidebar + header + content. The new chrome lives in
- * `src/app/(authenticated)/_components/Sidebar.tsx` and the
- * authenticated layout. The forest-on-paper repaint below uses the
- * brand tokens directly.
+ * Shell — forest sidebar + cream top header + cream main pane.
+ * Mirrors `docs/design/ui_kits/admin/index.html`. New brand classes
+ * (sidebar__org, sidebar__upgrade, sidebar__foot, app-shell__brand)
+ * sit alongside the pre-restyle selectors so existing snapshot tests
+ * keep passing while the new chrome takes over visually.
* ------------------------------------------------------------------------- */
.app-shell {
display: flex;
min-height: 100vh;
+ height: 100vh;
+ overflow: hidden;
}
.public-shell {
@@ -125,23 +128,129 @@ h2 {
min-width: 0;
display: flex;
flex-direction: column;
+ background: var(--paper);
+ overflow: hidden;
}
+/* Top header — cream paper, hairline border, wordmark on the left,
+ notification + view-site cluster on the right. */
.app-shell__header {
- height: var(--header-height);
+ height: 54px;
background: var(--paper);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
- padding: 0 var(--s-6);
+ justify-content: space-between;
+ padding: 0 28px;
+ font-family: var(--font-sans);
+ font-weight: 500;
+ color: var(--ink);
+ flex-shrink: 0;
+}
+
+.app-shell__brand {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 8px;
+ text-decoration: none;
+ color: var(--ink);
+ line-height: 1;
+}
+
+.app-shell__brand-go {
font-family: var(--font-display);
- font-weight: 700;
+ font-weight: 800;
+ font-size: 19px;
+ letter-spacing: -0.03em;
+ color: var(--ink);
+}
+
+.app-shell__brand-next {
+ font-family: var(--font-serif);
+ font-weight: 400;
+ font-style: italic;
+ font-size: 22px;
+ line-height: 1;
+ color: var(--ink);
+ margin-left: -2px;
+}
+
+.app-shell__brand-tag {
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--fg-subtle);
+ margin-left: 4px;
+ padding-left: 10px;
+ border-left: 1px solid var(--border);
+ position: relative;
+ top: -2px;
+}
+
+.app-shell__header-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.app-shell__view-site {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 10px;
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
+ font-weight: 500;
+ color: var(--fg-muted);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--r-sm);
+ text-decoration: none;
+ transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
+}
+
+.app-shell__view-site:hover {
+ background: var(--paper-2);
+ color: var(--ink);
+ text-decoration: none;
+}
+
+.app-shell__icon-btn {
+ position: relative;
+ width: 30px;
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--r-sm);
+ color: var(--fg-muted);
+ cursor: pointer;
+ transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
+}
+
+.app-shell__icon-btn:hover {
+ background: var(--paper-2);
color: var(--ink);
}
+.app-shell__icon-badge {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--emerald);
+ border: 1.5px solid var(--paper);
+}
+
.app-shell__content {
- padding: var(--s-6);
+ padding: 32px 36px 48px;
flex: 1;
+ overflow: auto;
}
/* Forest sidebar — matches docs/design/ui_kits/admin/index.html. */
@@ -153,34 +262,92 @@ h2 {
display: flex;
flex-direction: column;
transition: width 150ms cubic-bezier(0.2, 0.7, 0.2, 1);
+ flex-shrink: 0;
+ overflow: hidden;
}
.sidebar--collapsed {
width: var(--sidebar-width-collapsed);
}
-.sidebar__header {
- height: var(--header-height);
+/* ── org switcher (top chip on the forest sidebar) ───────────── */
+.sidebar__org {
display: flex;
align-items: center;
- justify-content: space-between;
- padding: 0 var(--s-4);
+ gap: 10px;
+ padding: 14px 16px;
border-bottom: 1px solid var(--forest-border);
+ cursor: default;
+}
+
+.sidebar__org-mark {
+ width: 30px;
+ height: 30px;
+ border-radius: var(--r-sm);
+ background: var(--forest-2);
+ border: 1px solid var(--forest-border);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ padding: 0 4px;
+ overflow: hidden;
+}
+
+.sidebar__org-info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar__org-name {
+ font-size: var(--t-sm);
+ font-weight: 600;
color: var(--fg-on-forest);
- font-family: var(--font-display);
- font-weight: 800;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sidebar__org-plan {
+ font-size: var(--t-2xs);
+ color: var(--fg-on-forest-muted);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 1px;
+}
+
+.sidebar__org-plan::before {
+ content: '';
+ width: 5px;
+ height: 5px;
+ border-radius: 999px;
+ background: var(--emerald-bright);
+ flex-shrink: 0;
+}
+
+.sidebar__org-chev {
+ color: var(--fg-on-forest-muted);
+ flex-shrink: 0;
}
.sidebar__toggle {
background: transparent;
border: 1px solid var(--forest-border);
- border-radius: var(--r-sm);
- width: 28px;
- height: 28px;
+ border-radius: var(--r-xs);
+ width: 22px;
+ height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fg-on-forest-muted);
+ cursor: pointer;
+ transition: background var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
+ flex-shrink: 0;
}
.sidebar__toggle:hover {
@@ -188,27 +355,54 @@ h2 {
color: var(--fg-on-forest);
}
+/* ── nav (sectioned) ─────────────────────────────────────────── */
+.sidebar__nav-wrap {
+ padding: 12px 8px;
+ flex: 1;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.sidebar__section {
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar__section-head {
+ padding: 12px 10px 6px;
+ font-family: var(--font-sans);
+ font-size: var(--t-2xs);
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--fg-on-forest-muted);
+ opacity: 0.6;
+}
+
.sidebar__nav {
list-style: none;
margin: 0;
- padding: var(--s-2) 0;
+ padding: 0;
display: flex;
flex-direction: column;
- gap: 2px;
+ gap: 1px;
}
.sidebar__item a {
display: flex;
align-items: center;
- gap: var(--s-3);
- padding: var(--s-2) var(--s-4);
+ gap: 10px;
+ padding: 7px 10px;
color: var(--fg-on-forest-muted);
text-decoration: none;
- border-left: 2px solid transparent;
+ border-radius: var(--r-sm);
+ font-family: var(--font-sans);
font-size: var(--t-sm);
font-weight: 500;
- transition: background 100ms cubic-bezier(0.2, 0.7, 0.2, 1),
- color 100ms cubic-bezier(0.2, 0.7, 0.2, 1);
+ transition: background var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
}
.sidebar__item a:hover {
@@ -219,34 +413,216 @@ h2 {
.sidebar__item--active a {
background: var(--forest-2);
- border-left-color: var(--emerald-bright);
color: var(--fg-on-forest);
- font-weight: 600;
+ font-weight: 500;
+ box-shadow: inset 2px 0 0 var(--emerald-bright);
+}
+
+/* The active icon adopts emerald-bright — the signature accent
+ that ties Lucide line icons into the brand's accent system. */
+.sidebar__item--active a .sidebar__icon {
+ color: var(--emerald-bright);
}
.sidebar__icon {
display: inline-flex;
align-items: center;
justify-content: center;
- width: 16px;
- height: 16px;
+ width: 15px;
+ height: 15px;
flex-shrink: 0;
- color: inherit;
+ color: var(--fg-on-forest-muted);
+ opacity: 0.85;
+}
+
+.sidebar__item a:hover .sidebar__icon {
+ color: var(--fg-on-forest);
+ opacity: 1;
+}
+
+.sidebar__count {
+ margin-left: auto;
+ font-family: var(--font-mono);
+ font-size: var(--t-2xs);
+ color: var(--fg-on-forest-muted);
+ opacity: 0.7;
}
-.sidebar--collapsed .sidebar__label {
+.sidebar--collapsed .sidebar__label,
+.sidebar--collapsed .sidebar__count {
display: none;
}
+/* ── wordmark inside org switcher ────────────────────────────── */
.sidebar__wordmark {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0;
+ line-height: 1;
+}
+
+.sidebar__wm-go {
+ font-family: var(--font-display);
+ font-weight: 800;
+ font-size: 13px;
+ letter-spacing: -0.03em;
+ color: var(--fg-on-forest);
+}
+
+.sidebar__wm-next {
+ font-family: var(--font-serif);
+ font-weight: 400;
+ font-style: italic;
+ font-size: 15px;
+ color: var(--fg-on-forest);
+ margin-left: 1px;
+}
+
+/* ── upgrade card (the organic-glow forest-2 surface) ────────── */
+.sidebar__upgrade {
+ margin: 8px;
+ padding: 14px;
+ border-radius: var(--r-md);
+ background: linear-gradient(135deg, var(--forest-2) 0%, var(--forest-3) 100%);
+ border: 1px solid var(--forest-border);
+ position: relative;
+ overflow: hidden;
+}
+
+.sidebar__upgrade::before {
+ content: '';
+ position: absolute;
+ top: -20px;
+ right: -20px;
+ width: 80px;
+ height: 80px;
+ border-radius: 999px;
+ background: radial-gradient(
+ circle,
+ rgba(52, 211, 153, 0.2) 0%,
+ transparent 70%
+ );
+ pointer-events: none;
+}
+
+.sidebar__upgrade-title {
+ position: relative;
+ font-family: var(--font-sans);
+ font-size: var(--t-sm);
+ font-weight: 600;
+ color: var(--fg-on-forest);
+}
+
+.sidebar__upgrade-title em {
+ font-family: var(--font-serif);
+ font-weight: 400;
+ font-style: italic;
+ color: var(--emerald-bright);
+ font-size: 1.1em;
+}
+
+.sidebar__upgrade-body {
+ position: relative;
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
+ color: var(--fg-on-forest-muted);
+ margin: 4px 0 10px;
+ line-height: 1.4;
+}
+
+.sidebar__upgrade-cta {
+ position: relative;
display: inline-flex;
align-items: center;
- height: 22px;
+ justify-content: center;
+ padding: 5px 10px;
+ font-family: var(--font-sans);
+ font-size: var(--t-xs);
+ font-weight: 500;
+ color: var(--emerald-ink);
+ background: var(--emerald);
+ border: 1px solid var(--emerald);
+ border-radius: var(--r-sm);
+ text-decoration: none;
+ width: 100%;
+ transition: background var(--dur) var(--ease);
}
-.sidebar__wordmark svg {
- height: 22px;
- width: auto;
+.sidebar__upgrade-cta:hover {
+ background: var(--emerald-bright);
+ border-color: var(--emerald-bright);
+ text-decoration: none;
+ color: var(--emerald-ink);
+}
+
+/* ── user foot ───────────────────────────────────────────────── */
+.sidebar__foot {
+ padding: 10px 12px;
+ border-top: 1px solid var(--forest-border);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.sidebar__avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--r-pill);
+ background: var(--emerald);
+ color: var(--emerald-ink);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-display);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ flex-shrink: 0;
+}
+
+.sidebar__who {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar__who-name {
+ font-family: var(--font-sans);
+ font-size: var(--t-sm);
+ font-weight: 500;
+ color: var(--fg-on-forest);
+ line-height: 1.3;
+}
+
+.sidebar__who-email {
+ font-family: var(--font-sans);
+ font-size: var(--t-2xs);
+ color: var(--fg-on-forest-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sidebar__signout {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--r-sm);
+ color: var(--fg-on-forest-muted);
+ text-decoration: none;
+ transition: background var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
+}
+
+.sidebar__signout:hover {
+ background: var(--forest-2);
+ color: var(--fg-on-forest);
+ text-decoration: none;
}
.widget-grid {
diff --git a/apps/admin/src/components/ui/brand-chart.tsx b/apps/admin/src/components/ui/brand-chart.tsx
new file mode 100644
index 00000000..878a8fd1
--- /dev/null
+++ b/apps/admin/src/components/ui/brand-chart.tsx
@@ -0,0 +1,312 @@
+'use client';
+
+/**
+ * BrandChart — recharts wrapper that ports the brand "Living systems"
+ * data-viz language onto a real charting library.
+ *
+ * Why a wrapper?
+ * The brand has a strong opinion on chart appearance — emerald and
+ * lavender bars on a paper surface; emerald-bright peaks; soft
+ * gridlines; Geist Mono axis ticks. Threading those choices into
+ * every chart call site would duplicate the brand contract.
+ * This module concentrates the choices in one place so the canvas
+ * stays consistent across the dashboard, pulse, and any
+ * per-resource report screens that ship later.
+ *
+ * Two primitives are exported:
+ *
+ *
+ * Vertical bar chart on a paper-2 card. Bars are lavender by
+ * default; rows flagged `accent: 'emerald'` swap to emerald-bright
+ * — matching the "peak bars" pattern from
+ * docs/design/ui_kits/admin/pulse.html.
+ *
+ *
+ * Time-series line on paper. The primary line is emerald; an
+ * optional conversions overlay renders in lavender. A soft
+ * gradient fills under the primary line.
+ *
+ * Both primitives use a 240px default height and stretch to fill
+ * their container. They render inside ResponsiveContainer so they
+ * adapt to whatever card width they're dropped into.
+ */
+import * as React from 'react';
+import {
+ Area,
+ AreaChart,
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import { cn } from '@/lib/utils';
+
+// Token-mirrored colour constants. These mirror docs/design/colors_and_type.css
+// — when the tokens move the constants here must move too. We intentionally
+// hard-code them here rather than reading CSS variables because Recharts
+// computes colours during a layout pass that happens before the cascade
+// resolves on the SVG primitives.
+const EMERALD = '#10B981';
+const EMERALD_BRIGHT = '#34D399';
+const EMERALD_DEEP = '#047857';
+const LAVENDER = '#A78BFA';
+const LAVENDER_DEEP = '#7C3AED';
+const PAPER_3 = '#E6E1D2';
+const BORDER = '#D9D2C0';
+const FG_MUTED = '#4A5C52';
+const FG_SUBTLE = '#6B7B72';
+const INK = '#0E1A14';
+
+export interface BarChartDatum {
+ name: string;
+ value: number;
+ /** When 'emerald', the bar renders in emerald-bright — peak emphasis. */
+ accent?: 'lavender' | 'emerald';
+}
+
+export interface BarChartSurfaceProps {
+ data: BarChartDatum[];
+ /** Pixel height for the chart canvas. Default 240. */
+ height?: number;
+ /** Optional accessible label for the canvas. */
+ ariaLabel?: string;
+ className?: string;
+}
+
+export function BarChartSurface({
+ data,
+ height = 240,
+ ariaLabel,
+ className,
+}: BarChartSurfaceProps): React.ReactElement {
+ return (
+
+
+
+
+
+
+
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+ );
+}
+
+export interface LineChartDatum {
+ name: string;
+ value: number;
+ conversions?: number;
+}
+
+export interface LineChartSurfaceProps {
+ data: LineChartDatum[];
+ height?: number;
+ /** When true, draw a lavender conversions overlay using `data.conversions`. */
+ showConversions?: boolean;
+ ariaLabel?: string;
+ className?: string;
+}
+
+export function LineChartSurface({
+ data,
+ height = 240,
+ showConversions = false,
+ ariaLabel,
+ className,
+}: LineChartSurfaceProps): React.ReactElement {
+ const gradientId = React.useId();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showConversions ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+/**
+ * Sparkline — micro bar chart for inline stat tiles. Pure SVG (no
+ * recharts) so it stays cheap to render dozens at a time. Bars are
+ * paper-4 with the highest bar(s) in emerald (or lavender if
+ * `accent='lavender'`).
+ */
+export interface SparklineProps {
+ /** Array of bar heights as percentages (0 to 100). */
+ values: number[];
+ /** Highlight the top N tallest bars in the accent colour. Default 1. */
+ peakCount?: number;
+ /** Colour used for peak bars. Default emerald. */
+ accent?: 'emerald' | 'lavender';
+ className?: string;
+ ariaLabel?: string;
+}
+
+export function Sparkline({
+ values,
+ peakCount = 1,
+ accent = 'emerald',
+ className,
+ ariaLabel,
+}: SparklineProps): React.ReactElement {
+ // Find the N highest values so we know which bars get the accent.
+ const peakSet = React.useMemo(() => {
+ const sorted = [...values]
+ .map((v, i) => ({ v, i }))
+ .sort((a, b) => b.v - a.v)
+ .slice(0, Math.max(0, peakCount))
+ .map((x) => x.i);
+ return new Set(sorted);
+ }, [values, peakCount]);
+
+ const peakColour = accent === 'emerald' ? EMERALD : LAVENDER;
+ return (
+
+ {values.map((value, index) => (
+
+ ))}
+
+ );
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 754c6d83..ac4cbfc4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -65,6 +65,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.6(react@19.2.6)
+ recharts:
+ specifier: ^3.8.1
+ version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1)
sonner:
specifier: ^1.7.1
version: 1.7.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -2081,6 +2084,17 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@reduxjs/toolkit@2.12.0':
+ resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
+ peerDependencies:
+ react: ^16.9.0 || ^17.0.0 || ^18 || ^19
+ react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-redux:
+ optional: true
+
'@rollup/rollup-android-arm-eabi@4.60.4':
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
cpu: [arm]
@@ -2236,6 +2250,12 @@ packages:
'@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
+ '@standard-schema/utils@0.3.0':
+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -2285,6 +2305,33 @@ packages:
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -2340,6 +2387,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+ '@types/use-sync-external-store@0.0.6':
+ resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+
'@typescript-eslint/eslint-plugin@8.59.3':
resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2832,6 +2882,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -2872,6 +2966,9 @@ packages:
supports-color:
optional: true
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -2998,6 +3095,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
+ es-toolkit@1.47.0:
+ resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==}
+
esast-util-from-estree@2.0.0:
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
@@ -3169,6 +3269,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
@@ -3417,6 +3520,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
+ immer@10.2.0:
+ resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
+
+ immer@11.1.8:
+ resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
+
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3443,6 +3552,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -4274,6 +4387,18 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+ react-redux@9.3.0:
+ resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
+ peerDependencies:
+ '@types/react': ^18.2.25 || ^19
+ react: ^18.0 || ^19
+ redux: ^5.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ redux:
+ optional: true
+
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -4319,6 +4444,14 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
+ recharts@3.8.1:
+ resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
recma-build-jsx@1.0.0:
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
@@ -4337,6 +4470,14 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
+ redux-thunk@3.1.0:
+ resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
+ peerDependencies:
+ redux: ^5.0.0
+
+ redux@5.0.1:
+ resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -4385,6 +4526,9 @@ packages:
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+ reselect@5.1.1:
+ resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -4657,6 +4801,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -4872,6 +5019,9 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+ victory-vendor@37.3.6:
+ resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
+
vite-node@1.6.1:
resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -6255,6 +6405,18 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@standard-schema/utils': 0.3.0
+ immer: 11.1.8
+ redux: 5.0.1
+ redux-thunk: 3.1.0(redux@5.0.1)
+ reselect: 5.1.1
+ optionalDependencies:
+ react: 19.2.6
+ react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1)
+
'@rollup/rollup-android-arm-eabi@4.60.4':
optional: true
@@ -6371,6 +6533,10 @@ snapshots:
'@sinclair/typebox@0.27.10': {}
+ '@standard-schema/spec@1.1.0': {}
+
+ '@standard-schema/utils@0.3.0': {}
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -6437,6 +6603,30 @@ snapshots:
'@types/aria-query@5.0.4': {}
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
@@ -6488,6 +6678,8 @@ snapshots:
'@types/unist@3.0.3': {}
+ '@types/use-sync-external-store@0.0.6': {}
+
'@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -7032,6 +7224,44 @@ snapshots:
csstype@3.2.3: {}
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-color@3.1.0: {}
+
+ d3-ease@3.0.1: {}
+
+ d3-format@3.1.2: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
damerau-levenshtein@1.0.8: {}
data-urls@5.0.0:
@@ -7072,6 +7302,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decimal.js-light@2.5.1: {}
+
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0:
@@ -7281,6 +7513,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
+ es-toolkit@1.47.0: {}
+
esast-util-from-estree@2.0.0:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -7393,8 +7627,8 @@ snapshots:
'@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -7413,7 +7647,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -7424,22 +7658,22 @@ snapshots:
tinyglobby: 0.2.16
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -7450,7 +7684,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.10
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.3
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -7618,6 +7852,8 @@ snapshots:
esutils@2.0.3: {}
+ eventemitter3@5.0.4: {}
+
execa@8.0.1:
dependencies:
cross-spawn: 7.0.6
@@ -7935,6 +8171,10 @@ snapshots:
ignore@7.0.5: {}
+ immer@10.2.0: {}
+
+ immer@11.1.8: {}
+
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -7959,6 +8199,8 @@ snapshots:
hasown: 2.0.3
side-channel: 1.1.0
+ internmap@2.0.3: {}
+
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@@ -9076,6 +9318,15 @@ snapshots:
react-is@18.3.1: {}
+ react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
+ dependencies:
+ '@types/use-sync-external-store': 0.0.6
+ react: 19.2.6
+ use-sync-external-store: 1.6.0(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ redux: 5.0.1
+
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6):
dependencies:
react: 19.2.6
@@ -9115,6 +9366,26 @@ snapshots:
readdirp@4.1.2: {}
+ recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1):
+ dependencies:
+ '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
+ clsx: 2.1.1
+ decimal.js-light: 2.5.1
+ es-toolkit: 1.47.0
+ eventemitter3: 5.0.4
+ immer: 10.2.0
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-is: 18.3.1
+ react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1)
+ reselect: 5.1.1
+ tiny-invariant: 1.3.3
+ use-sync-external-store: 1.6.0(react@19.2.6)
+ victory-vendor: 37.3.6
+ transitivePeerDependencies:
+ - '@types/react'
+ - redux
+
recma-build-jsx@1.0.0:
dependencies:
'@types/estree': 1.0.9
@@ -9149,6 +9420,12 @@ snapshots:
indent-string: 4.0.0
strip-indent: 3.0.0
+ redux-thunk@3.1.0(redux@5.0.1):
+ dependencies:
+ redux: 5.0.1
+
+ redux@5.0.1: {}
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -9250,6 +9527,8 @@ snapshots:
requires-port@1.0.0: {}
+ reselect@5.1.1: {}
+
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@@ -9632,6 +9911,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
+ tiny-invariant@1.3.3: {}
+
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -9894,6 +10175,23 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ victory-vendor@37.3.6:
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.4
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-timer: 3.0.1
+
vite-node@1.6.1(@types/node@22.19.19):
dependencies:
cac: 6.7.14