From 715819f5c88e0cf3b9087ba576ccca9e2b524620 Mon Sep 17 00:00:00 2001 From: John Paul Wile Date: Mon, 10 Aug 2026 13:48:27 -0600 Subject: [PATCH 1/2] [verified] feat: revamp civic field-notebook interface --- package-lock.json | 18 +- package.json | 3 +- src/App.test.tsx | 3 + src/App.tsx | 755 +++++++++--------- src/components/interest-mode-notice.tsx | 8 +- src/components/site-header.test.tsx | 50 ++ src/components/site-header.tsx | 77 ++ src/components/ui/button.test.tsx | 16 + src/components/ui/button.tsx | 25 +- src/features/admin/admin-page.tsx | 340 ++++---- src/features/auth/auth-callback-page.tsx | 7 +- src/features/auth/auth-page.tsx | 11 +- src/features/ideas/idea-detail-page.tsx | 419 +++++----- .../ideas/idea-discovery-page.test.tsx | 29 +- src/features/ideas/idea-discovery-page.tsx | 461 ++++++----- src/features/ideas/idea-editor-page.tsx | 11 +- src/features/ideas/idea-interest-panel.tsx | 9 +- .../ideas/idea-validation-evidence-panel.tsx | 8 +- src/features/ideas/idea-validation-panel.tsx | 15 +- src/features/pilots/pilot-page.tsx | 330 ++++---- src/features/pilots/pilot-readiness-panel.tsx | 10 +- src/features/profiles/profile-page.tsx | 11 +- src/features/theme/theme-toggle.tsx | 2 +- src/index.css | 236 ++++-- src/theme-css.test.ts | 45 +- 25 files changed, 1543 insertions(+), 1356 deletions(-) create mode 100644 src/components/site-header.test.tsx create mode 100644 src/components/site-header.tsx create mode 100644 src/components/ui/button.test.tsx diff --git a/package-lock.json b/package-lock.json index 5415bb5..ba4fc16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.7.0", - "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@stripe/react-stripe-js": "^6.8.0", "@stripe/stripe-js": "^9.13.0", "@supabase/supabase-js": "^2.112.2", @@ -1148,10 +1149,19 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@fontsource-variable/geist": { + "node_modules/@fontsource-variable/ibm-plex-sans": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", - "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" diff --git a/package.json b/package.json index 023ddd3..92aa23e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ }, "dependencies": { "@base-ui/react": "^1.7.0", - "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@stripe/react-stripe-js": "^6.8.0", "@stripe/stripe-js": "^9.13.0", "@supabase/supabase-js": "^2.112.2", diff --git a/src/App.test.tsx b/src/App.test.tsx index 91415c5..a191414 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -63,6 +63,9 @@ beforeEach(() => { describe("App", () => { it("introduces the Ideascape mission at the home route", () => { renderApp(); + const main = screen.getByRole("main"); + const banner = screen.getByRole("banner"); + expect(main).not.toContainElement(banner); expect( screen.getByRole("heading", { diff --git a/src/App.tsx b/src/App.tsx index 452ea71..b061154 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { import { lazy, Suspense } from "react"; import { Link, Route, Routes } from "react-router-dom"; import { InterestModeNotice } from "@/components/interest-mode-notice"; +import { SiteHeader } from "@/components/site-header"; import { buttonVariants } from "@/components/ui/button"; import { useAuth } from "@/features/auth/auth-provider"; import { AuthNavigation } from "@/features/auth/auth-navigation"; @@ -165,430 +166,418 @@ function HomePage() { const { user, isLoading: isAuthLoading } = useAuth(); return ( -
-
-
+ <> + } + showExplore={false} + showStartIdea={false} + /> +
+
+ -
-
- - - - Ideascape - -
- - Building in public - - -
-
- +
+
+

+ Test the possibility +

+

+ Great ideas deserve a place to{" "} + grow. +

+

+ Ideascape is a concept-validation platform where creators test + demand, gather public interest, and learn what deserves deeper + work before funding begins. +

+

+ The newest previews explore owner-authorized device work, + consent-based file recovery, verified cloud exports, and private + local AI. +

+
+ + Start an idea +
-
-
-

-

-

- Great ideas deserve a place to{" "} - grow. -

-

- Ideascape is a concept-validation platform where creators test - demand, gather public interest, and learn what deserves deeper - work before funding begins. -

-

- The newest previews explore owner-authorized device work, - consent-based file recovery, verified cloud exports, and private - local AI. -

-
- - Start an idea -
-
-
-
- Concept previews -
-
- 21 -
-
-
-
- Categories -
-
- 6 -
+
+
+
+
+ Field sample / 021 + Permission checked +
+ Owner-controlled devices, an open padlock, and a terminal share an isolated repair bench +
+ Device Liberation Lab + + Technology + +
+
+
+ A read-only recovery station transfers files from damaged storage into an encrypted folder
-
-
- Momentum -
-
- Fresh concepts ready to explore. -
+
+
-
-
- -
-
-
- Owner-controlled devices, an open padlock, and a terminal share an isolated repair bench -
- Device Liberation Lab - - Technology - -
-
-
- A read-only recovery station transfers files from damaged storage into an encrypted folder -
-
-
-
-
+
-
-
-
-
-

- From idea to evidence -

-

+
+
+
+

From idea to evidence

+

+ How Ideascape works +

+

+ Start with proof of demand, not a checkout. The live product + is focused on learning what people actually want. +

+
+ - How Ideascape works -

-

- Start with proof of demand, not a checkout. The live product - is focused on learning what people actually want. -

+ Explore the live experiment +
+
+ {validationSteps.map( + ({ icon: Icon, number, title, description }) => ( +
+
+ + + + {number} + +
+

+ {title} +

+

+ {description} +

+
+ ), + )}
- - Explore the live experiment -
-
- {validationSteps.map( - ({ icon: Icon, number, title, description }) => ( -
+ +
+
+
+
+
+
+

-
- - - - {number} - -
-

- {title} -

-

- {description} -

-
- ), - )} -
-
-
+ Planned smart-contract funding + +

+ If a concept earns a campaign phase, the goal is to move + from social proof to explicit, inspectable funding terms. No + funds are accepted today. +

+
+ +
-
-
-
-
-
-
-
+
-
- {fundingSteps.map( - ({ icon: Icon, number, title, description }) => ( +
+
+
+
+

+

+

+ Security before custody +

+

+ No custody is live. These examples show the failure paths a + real funding design must handle before a contract, wallet + connection, or fiat rail can reach production. +

+
+
+

+ Security claim policy +

+

+ A control is not a guarantee until code, deployment + settings, governance, tests, and external review all prove + it. +

+
+
+ +
+ {securityExamples.map(({ number, title, risk, control }) => (
-
- - - - {number} - +
+
+ + Threat scenario + + + {number} + +
+

+ {title} +

+

+ {risk} +

+
+
+

+ Example control design +

+

+ {control} +

-

{title}

-

- {description} -

- ), - )} -
+ ))} +
-
-
-

- What the contract can—and cannot—do -

-

- Smart contracts can make agreed rules and fund movements - easier to inspect. They do not replace identity, moderation, - real-world milestone review, dispute handling, or legal - responsibility. +

+

+ Non-negotiable baseline

+
+ {[ + "Least-privilege roles and separated duties", + "Multisig quorum for privileged actions", + "Independent audits and reproducible deployments", + "Timelocked changes with public notice", + "On-chain monitoring and incident alerts", + "Tested pause, recovery, dispute, and refund runbooks", + ].map((control) => ( +
+
+ ))} +
-
    - {[ - "Independent security review before any contract can hold funds", - "Published source, addresses, release rules, and upgrade controls", - "Multisig approvals, pause controls, and documented dispute windows", - "Self-custodied signatures—never wallet seed phrases or private keys", - "Clear fees, network, asset, and transaction status before signing", - "Legal, tax, sanctions, privacy, and consumer-protection review", - ].map((guardrail) => ( -
  • -
  • - ))} -
-
-
+ -
-
-
+
+
-

-

+ Built for momentum

- Security before custody + From possibility to progress

-

- No custody is live. These examples show the failure paths a - real funding design must handle before a contract, wallet - connection, or fiat rail can reach production. -

-
-
-

- Security claim policy -

-

- A control is not a guarantee until code, deployment settings, - governance, tests, and external review all prove it. -

- -
- {securityExamples.map(({ number, title, risk, control }) => ( +
+ {principles.map(({ icon: Icon, title, description }, index) => (
-
-
- - Threat scenario - - - {number} - -
-

- {title} -

-

- {risk} -

-
-
-

- Example control design -

-

- {control} -

+
+ + + + 0{index + 1} +
+

{title}

+

+ {description} +

))}
- -
-

- Non-negotiable baseline -

-
- {[ - "Least-privilege roles and separated duties", - "Multisig quorum for privileged actions", - "Independent audits and reproducible deployments", - "Timelocked changes with public notice", - "On-chain monitoring and incident alerts", - "Tested pause, recovery, dispute, and refund runbooks", - ].map((control) => ( -
-
- ))} -
-
-
-
- -
-
-
-

- Built for momentum -

-

- From possibility to progress -

-
-
-
- {principles.map(({ icon: Icon, title, description }, index) => ( -
-
- - - - 0{index + 1} - -
-

{title}

-

- {description} -

-
- ))} -
-
-
-
+ +
+
+ ); } diff --git a/src/components/interest-mode-notice.tsx b/src/components/interest-mode-notice.tsx index 2acd771..7e9a1a9 100644 --- a/src/components/interest-mode-notice.tsx +++ b/src/components/interest-mode-notice.tsx @@ -16,15 +16,14 @@ export function InterestModeNotice({
-
- +
@@ -40,8 +39,7 @@ export function InterestModeNotice({ diff --git a/src/components/site-header.test.tsx b/src/components/site-header.test.tsx new file mode 100644 index 0000000..60a8b5c --- /dev/null +++ b/src/components/site-header.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it } from "vitest"; +import { SiteHeader } from "@/components/site-header"; + +afterEach(cleanup); + +describe("SiteHeader", () => { + it("provides a consistent brand, primary navigation, and account area", () => { + render( + + Account controls} /> + , + ); + + expect( + screen.getByRole("link", { name: /ideascape home/i }), + ).toHaveAttribute("href", "/"); + expect(screen.getByText(/public idea fieldwork/i)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /explore ideas/i }), + ).toHaveAttribute("href", "/ideas"); + expect( + screen.getByRole("link", { name: /start an idea/i }), + ).toHaveAttribute("href", "/ideas/new"); + expect(screen.getByText(/account controls/i)).toBeInTheDocument(); + }); + + it("does not require provider-aware account controls", () => { + render( + + + , + ); + + expect(screen.queryByText(/account controls/i)).not.toBeInTheDocument(); + }); + + it("omits the primary navigation landmark when it has no links", () => { + render( + + + , + ); + + expect( + screen.queryByRole("navigation", { name: /primary/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx new file mode 100644 index 0000000..1e000f1 --- /dev/null +++ b/src/components/site-header.tsx @@ -0,0 +1,77 @@ +import { ArrowRight, MapPinned } from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function SiteHeader({ + account, + exploreLabel = "Explore ideas", + exploreTo = "/ideas", + showExplore = true, + showStartIdea = true, +}: { + account?: ReactNode; + exploreLabel?: string; + exploreTo?: string; + showExplore?: boolean; + showStartIdea?: boolean; +}) { + return ( +
+
+ + +
+
+ ); +} diff --git a/src/components/ui/button.test.tsx b/src/components/ui/button.test.tsx new file mode 100644 index 0000000..0797c06 --- /dev/null +++ b/src/components/ui/button.test.tsx @@ -0,0 +1,16 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { Button } from "@/components/ui/button"; + +afterEach(cleanup); + +describe("Button", () => { + it("keeps the field-notebook shape and an accessible touch target", () => { + render(); + + const button = screen.getByRole("button", { name: /primary action/i }); + expect(button).toHaveClass("min-h-11"); + expect(button).toHaveClass("rounded-sm"); + expect(button).toHaveClass("focus-visible:ring-2"); + }); +}); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 0fcd8a1..f146c3b 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex min-h-11 shrink-0 items-center justify-center rounded-sm border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,color,border-color,transform] outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/55 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", + default: "bg-primary text-primary-foreground hover:bg-primary/90", outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + "border-border bg-card hover:border-primary hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:hover:border-primary", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: @@ -20,17 +20,14 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", + default: "h-11 gap-2 px-4", + xs: "h-11 gap-1.5 px-3 text-xs [&_svg:not([class*='size-'])]:size-3.5", + sm: "h-11 gap-1.5 px-3 text-[0.8rem] [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-12 gap-2 px-5 text-base", + icon: "size-11", + "icon-xs": "size-11 [&_svg:not([class*='size-'])]:size-3.5", + "icon-sm": "size-11", + "icon-lg": "size-12", }, }, defaultVariants: { diff --git a/src/features/admin/admin-page.tsx b/src/features/admin/admin-page.tsx index 4c58c21..f23f0da 100644 --- a/src/features/admin/admin-page.tsx +++ b/src/features/admin/admin-page.tsx @@ -1,11 +1,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, - ArrowLeft, ClipboardCheck, FileStack, HeartHandshake, - Lightbulb, LockKeyhole, ShieldCheck, type LucideIcon, @@ -13,6 +11,7 @@ import { } from "lucide-react"; import { useEffect } from "react"; import { Link } from "react-router-dom"; +import { SiteHeader } from "@/components/site-header"; import { buttonVariants } from "@/components/ui/button"; import { getAdminDashboardSummary, @@ -128,184 +127,171 @@ function AdminDashboard({ ); return ( -
-
-
- -
-
- - - - Ideascape - - -
- -
-
-

-

-

- Operations dashboard -

-

- A privacy-preserving view of IdeaScape activity, validation, and - pilot readiness. Counts are live at request time. -

-

- Generated{" "} - -

-
-
+ <> + +
+
+
+
+

+

+

+ Operations dashboard +

+

+ A privacy-preserving view of IdeaScape activity, validation, and + pilot readiness. Counts are live at request time. +

+

+ Generated{" "} + +

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

- Published concepts only +

+
+
+

+ Published concepts only +

+

+ Idea activity +

+
+

+ Sorted by combined interest, validation responses, and pilot + applications.

-

- Idea activity -

-

- Sorted by combined interest, validation responses, and pilot - applications. -

-
- {isActivityLoading ? ( -

- Loading concept activity… -

- ) : null} - {isActivityError ? ( -

- Unable to load concept activity. The dashboard summary is still - available. -

- ) : null} - {!isActivityLoading && !isActivityError && activity.length === 0 ? ( -

- No published concept activity yet. -

- ) : null} - {activity.length > 0 ? ( -
- - - - - - - - - - - {activity.map((idea) => ( - - - - - + {isActivityLoading ? ( +

+ Loading concept activity… +

+ ) : null} + {isActivityError ? ( +

+ Unable to load concept activity. The dashboard summary is still + available. +

+ ) : null} + {!isActivityLoading && !isActivityError && activity.length === 0 ? ( +

+ No published concept activity yet. +

+ ) : null} + {activity.length > 0 ? ( +
+
- Concept - - Interest - - Validation - - Applications -
- - {idea.title} - -

- {idea.categoryName ?? "Uncategorized"} -

-
- {idea.interestSignalCount} - - {idea.validationResponseCount} - - {idea.pilotApplicationCount} -
+ + + + + + - ))} - -
+ Concept + + Interest + + Validation + + Applications +
-
- ) : null} -
+ + + {activity.map((idea) => ( + + + + {idea.title} + +

+ {idea.categoryName ?? "Uncategorized"} +

+ + + {idea.interestSignalCount} + + + {idea.validationResponseCount} + + + {idea.pilotApplicationCount} + + + ))} + + +
+ ) : null} + - -
-
+ +
+ + ); } @@ -321,7 +307,7 @@ function MetricCard({ detail: string; }) { return ( -
+

@@ -329,7 +315,7 @@ function MetricCard({

{label}

- +
@@ -351,8 +337,8 @@ function AdminStatusPage({ }) { return (
-
- +
+

diff --git a/src/features/auth/auth-callback-page.tsx b/src/features/auth/auth-callback-page.tsx index 3790a34..0ac65dd 100644 --- a/src/features/auth/auth-callback-page.tsx +++ b/src/features/auth/auth-callback-page.tsx @@ -8,11 +8,10 @@ import { } from "@/features/auth/auth-return-path"; const pageClassName = - "relative grid min-h-screen place-items-center overflow-hidden bg-background px-6 py-12 text-foreground"; -const backdropClassName = - "pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,oklch(0.84_0.12_155_/_0.3),transparent_34%),radial-gradient(circle_at_80%_10%,oklch(0.82_0.11_250_/_0.22),transparent_30%)]"; + "field-grid contour-field relative grid min-h-screen place-items-center overflow-hidden bg-background px-6 py-12 text-foreground"; +const backdropClassName = "hidden"; const cardClassName = - "relative w-full max-w-md rounded-2xl border bg-background/90 p-8 text-center shadow-sm backdrop-blur"; + "field-panel relative w-full max-w-md border-t-4 border-t-primary p-8 text-center"; export function AuthCallbackPage() { const { user, isLoading } = useAuth(); diff --git a/src/features/auth/auth-page.tsx b/src/features/auth/auth-page.tsx index fec32a6..d4e6716 100644 --- a/src/features/auth/auth-page.tsx +++ b/src/features/auth/auth-page.tsx @@ -1,4 +1,4 @@ -import { Lightbulb } from "lucide-react"; +import { MapPinned } from "lucide-react"; import { type FormEvent, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; @@ -81,15 +81,14 @@ export function AuthPage({ mode }: AuthPageProps) { } return ( -
-
-
+
+
- -
+ ) : null} -
-
-
- - -

- About this idea -

-
-

- {ideaQuery.data.description} + {ideaQuery.isSuccess && !ideaQuery.data ? ( +

+ + +

+ Idea not found +

+

+ This idea may be private, unpublished, or no longer available.

+ + Browse ideas +
- - - {ideaQuery.data.slug === "project-time-capsule" ? ( -
-
-

- Evidence before commitment -

-

- See the precommitted pilot rules -

-

- Review the evidence window, capacity, safety boundaries, and - continue, revise, or archive thresholds before intake opens. -

+ ) : null} + + {ideaQuery.data ? ( +
+
+
+ {ideaQuery.data.category ? ( + + {ideaQuery.data.category.name} + + ) : null} + + {statusLabels[ideaQuery.data.status]} +
- - View pilot plan -
- ) : null} - {ideaQuery.data.category && relatedIdeas.length > 0 ? ( +

+ {ideaQuery.data.title} +

+

+ {ideaQuery.data.summary} +

+

+ Created by{" "} + + {ideaQuery.data.creator.display_name} + +

+
+ + + {ideaQuery.data.media.length > 0 ? ( +
+ {ideaQuery.data.media.map((media) => ( + + ))} +
+ ) : null} +
-
+
+ + +

+ About this idea +

+
+

+ {ideaQuery.data.description} +

+
+ + + {ideaQuery.data.slug === "project-time-capsule" ? ( +

- Keep exploring + Evidence before commitment

-

+ See the precommitted pilot rules

+

+ Review the evidence window, capacity, safety boundaries, + and continue, revise, or archive thresholds before intake + opens. +

- Browse all {ideaQuery.data.category.name} concepts + View pilot plan
- -
- {relatedIdeas.map((relatedIdea) => { - const cover = relatedIdea.media.find( - (media) => - media.kind === "image" && isSafeMediaUrl(media.url), - ); +

+ ) : null} - return ( -
0 ? ( +
+
+
+

+ Keep exploring +

+
+ +
+ {relatedIdeas.map((relatedIdea) => { + const cover = relatedIdea.media.find( + (media) => + media.kind === "image" && isSafeMediaUrl(media.url), + ); + + return ( +
+ -
-
- ); - })} -
- - ) : null} - - - ) : null} -
- + {cover ? ( + + ) : null} +
+

+ {relatedIdea.title} +

+

+ {relatedIdea.summary} +

+
+ + ); + })} +
+ + ) : null} + + + ) : null} +
+ + ); } diff --git a/src/features/ideas/idea-discovery-page.test.tsx b/src/features/ideas/idea-discovery-page.test.tsx index c3467d6..c2dd672 100644 --- a/src/features/ideas/idea-discovery-page.test.tsx +++ b/src/features/ideas/idea-discovery-page.test.tsx @@ -48,10 +48,14 @@ const healthIdea = { media: [], }; -function renderDiscovery(initialEntry = "/ideas") { +function renderDiscovery( + initialEntry = "/ideas", + configureQueryClient?: (queryClient: QueryClient) => void, +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + configureQueryClient?.(queryClient); return render( @@ -85,6 +89,9 @@ describe("IdeaDiscoveryPage", () => { expect( await screen.findByRole("heading", { name: /discover ideas/i }), ).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: /^explore ideas$/i }), + ).not.toBeInTheDocument(); const cardLink = await screen.findByRole("link", { name: `View ${idea.title}`, }); @@ -191,6 +198,26 @@ describe("IdeaDiscoveryPage", () => { expect(await screen.findByRole("alert")).toHaveTextContent( "Unable to load ideas. Please try again.", ); + expect(screen.getByText("Catalog unavailable")).toBeInTheDocument(); expect(screen.getByRole("alert")).not.toHaveTextContent(/sensitive/i); }); + + it("keeps a stale catalog coherent when a refresh fails", async () => { + mockedListPublishedIdeas.mockRejectedValue( + new Error("network unavailable"), + ); + + renderDiscovery("/ideas", (queryClient) => { + queryClient.setQueryData(["published-ideas"], [idea]); + }); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Unable to refresh ideas. Showing the latest available catalog.", + ); + expect(screen.getByText("1 demo concept")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: `View ${idea.title}` }), + ).toBeInTheDocument(); + expect(screen.queryByText("Catalog unavailable")).not.toBeInTheDocument(); + }); }); diff --git a/src/features/ideas/idea-discovery-page.tsx b/src/features/ideas/idea-discovery-page.tsx index 66c08f0..9bdc5c0 100644 --- a/src/features/ideas/idea-discovery-page.tsx +++ b/src/features/ideas/idea-discovery-page.tsx @@ -3,7 +3,6 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowRight, ArrowUpRight, - Lightbulb, LoaderCircle, Search, Sparkles, @@ -11,6 +10,7 @@ import { } from "lucide-react"; import { Link, useSearchParams } from "react-router-dom"; import { InterestModeNotice } from "@/components/interest-mode-notice"; +import { SiteHeader } from "@/components/site-header"; import { buttonVariants } from "@/components/ui/button"; import { listPublishedIdeas, @@ -124,256 +124,243 @@ export function IdeaDiscoveryPage() { } return ( -
-
-
- -
-
- - - - Ideascape - - - Start an idea -
- -
-
-
-

-

-

- Discover ideas -

-

- Meet creators testing ambitious solutions and follow their - progress from first proposal to real-world impact. -

-
-
-

- Now exploring -

-

- {ideasQuery.data - ? conceptCountLabel(ideasQuery.data.length) - : "Loading concepts"} -

+ <> + +
+
+
+
+
+

+ Community possibilities +

+

+ Discover ideas +

+

+ Meet creators testing ambitious solutions and follow their + progress from first proposal to real-world impact. +

+
+
+

+ {ideasQuery.isError && ideasQuery.data === undefined + ? "Catalog status" + : "Now exploring"} +

+

+ {ideasQuery.isError && ideasQuery.data === undefined + ? "Catalog unavailable" + : ideasQuery.data + ? conceptCountLabel(ideasQuery.data.length) + : "Loading concepts"} +

+
-
- + - {ideasQuery.data && ideasQuery.data.length > 0 ? ( -
- Filter concepts - - + -

updateCategory(event.target.value)} + value={selectedCategory} + > + + {categories.map((category) => ( + + ))} + + +

+ Showing {visibleIdeas.length} of {ideasQuery.data.length}{" "} + concepts +

+
+ ) : null} + + {ideasQuery.isPending ? ( +
- Showing {visibleIdeas.length} of {ideasQuery.data.length}{" "} - concepts -

- - ) : null} - - {ideasQuery.isPending ? ( -
-
- ) : null} +
+ ) : null} - {ideasQuery.isError ? ( -
-

- Unable to load ideas. Please try again. -

-
- ) : null} + {ideasQuery.isError ? ( +
+

+ {ideasQuery.data === undefined + ? "Unable to load ideas. Please try again." + : "Unable to refresh ideas. Showing the latest available catalog."} +

+
+ ) : null} - {ideasQuery.data?.length === 0 ? ( -
- - -

- The first ideas are taking shape -

-

- Be the first creator to share a proposal with the Ideascape - community. -

- - Start an idea -
- ) : null} + {ideasQuery.data?.length === 0 ? ( +
+ + +

+ The first ideas are taking shape +

+

+ Be the first creator to share a proposal with the Ideascape + community. +

+ + Start an idea +
+ ) : null} - {ideasQuery.data && - ideasQuery.data.length > 0 && - visibleIdeas.length === 0 ? ( -
- - -

- No concepts match these filters -

-

- Try a broader search, choose another category, or reset the - catalog. -

- -
- ) : null} + {ideasQuery.data && + ideasQuery.data.length > 0 && + visibleIdeas.length === 0 ? ( +
+ + +

+ No concepts match these filters +

+

+ Try a broader search, choose another category, or reset the + catalog. +

+ +
+ ) : null} - {ideasQuery.data && visibleIdeas.length > 0 ? ( -
- {visibleIdeas.map((idea) => { - const cover = idea.media.find( - (media) => - media.kind === "image" && isSafeImageUrl(media.url), - ); + {ideasQuery.data && visibleIdeas.length > 0 ? ( +
+ {visibleIdeas.map((idea) => { + const cover = idea.media.find( + (media) => + media.kind === "image" && isSafeImageUrl(media.url), + ); - return ( -
- - {cover ? ( -
- {cover.alt_text -
- ) : null} -
-
- {idea.category ? ( - - {idea.category.name} + return ( +
+ + {cover ? ( +
+ {cover.alt_text +
+ ) : null} +
+
+ {idea.category ? ( + + {idea.category.name} + + ) : null} + + {statusLabels[idea.status]} - ) : null} - - {statusLabels[idea.status]} - -
-

- {idea.title} -

-

- {idea.summary} -

-
-

- By{" "} - - {idea.creator.display_name} - +

+

+ {idea.title} +

+

+ {idea.summary}

-
-

-

+

+ By{" "} + + {idea.creator.display_name} +

-
-
-
- ); - })} -
- ) : null} -
-
-
+ + ); + })} + + ) : null} + + + + ); } diff --git a/src/features/ideas/idea-editor-page.tsx b/src/features/ideas/idea-editor-page.tsx index f407b4f..cc40a9f 100644 --- a/src/features/ideas/idea-editor-page.tsx +++ b/src/features/ideas/idea-editor-page.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, Lightbulb, LoaderCircle } from "lucide-react"; +import { ArrowLeft, LoaderCircle, MapPinned } from "lucide-react"; import { type FormEvent, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -213,15 +213,14 @@ export function IdeaEditorPage() { function EditorShell({ children }: { children: React.ReactNode }) { return ( -
-
-
+
+
- -
+
+ + +
+
+
+

+ Current gate +

+

+ {pilot.status === "validating" + ? "Pilot intake is not open yet" + : pilot.status === "recruiting" + ? "Private pilot applications are open" + : statusLabels[pilot.status]} +

+

+ {pilot.status === "validating" + ? "Ideascape is collecting practical participation signals before inviting project applications." + : "One strong, authorized project per member. Intake remains capped and reversible."} +

+
+ {pilot.status === "recruiting" && !user ? ( + + Sign in to apply privately +
+
+ +
+ ); } @@ -262,7 +242,7 @@ function EvidenceTarget({ value, label }: { value: string; label: string }) { return (

{value} @@ -284,7 +264,7 @@ function DecisionCard({ description: string; }) { return ( -

+