From 9c652c745709766a22126497e30e706057610f74 Mon Sep 17 00:00:00 2001 From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:03:35 +0400 Subject: [PATCH 1/9] Deduplicate Initiative workspace details --- src/lib/initiativeUi.ts | 14 ++++++++ src/pages/initiatives/Initiative.tsx | 13 ++++++- .../components/InitiativeSettingsSection.tsx | 35 +++++++++++++++++-- tests/unit/initiative-ui.test.ts | 19 ++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/lib/initiativeUi.ts b/src/lib/initiativeUi.ts index dcb95e4..46d17c2 100644 --- a/src/lib/initiativeUi.ts +++ b/src/lib/initiativeUi.ts @@ -106,6 +106,20 @@ export function initiativeSummaryPreview(value: string, maxLength = 150) { return normalized.slice(0, cutAt).trim(); } +export function initiativeDistinctDescription( + summary: string, + description: string, +) { + const normalizedSummary = summary.replace(/\s+/g, " ").trim(); + const normalizedDescription = description.replace(/\s+/g, " ").trim(); + + if (!normalizedDescription || normalizedDescription === normalizedSummary) { + return ""; + } + + return description.trim(); +} + export function initiativeOptionsWithSelection( options: Array<{ value: string; label: string }>, selectedId?: string, diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index 358ebca..b5f1833 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -13,6 +13,7 @@ import { formatLoadError } from "@/lib/errorFormatting"; import { canManageInitiative, defaultInitiativeBoardColumns, + initiativeDistinctDescription, initiativeRoleLabel, initiativeStatusLabel, initiativeStatusTone, @@ -70,6 +71,10 @@ const Initiative: React.FC = () => { const viewerRole = initiative.viewerRole ? initiativeRoleLabel[initiative.viewerRole] : "Observer"; + const description = initiativeDistinctDescription( + initiative.summary, + initiative.description, + ); return (
@@ -80,7 +85,7 @@ const Initiative: React.FC = () => { @@ -92,6 +97,12 @@ const Initiative: React.FC = () => { } /> + {description ? ( +

+ {description} +

+ ) : null} +
{initiative.tags.map((tag) => ( diff --git a/src/pages/initiatives/components/InitiativeSettingsSection.tsx b/src/pages/initiatives/components/InitiativeSettingsSection.tsx index 4c9b5c7..9a48edd 100644 --- a/src/pages/initiatives/components/InitiativeSettingsSection.tsx +++ b/src/pages/initiatives/components/InitiativeSettingsSection.tsx @@ -1,5 +1,5 @@ import type { FormEvent } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { GlassySection } from "@/components/GlassySection"; import { Textarea } from "@/components/Textarea"; @@ -24,12 +24,13 @@ export function InitiativeSettingsSection({ const [description, setDescription] = useState(initiative.description); const [tags, setTags] = useState(initiative.tags.join(", ")); const [status, setStatus] = useState(initiative.status); + const [editing, setEditing] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const archivedReadOnly = initiative.status === "archived" && status !== "active"; - useEffect(() => { + const resetDraft = useCallback(() => { setTitle(initiative.title); setSummary(initiative.summary); setDescription(initiative.description); @@ -37,6 +38,16 @@ export function InitiativeSettingsSection({ setStatus(initiative.status); }, [initiative]); + useEffect(() => { + resetDraft(); + }, [resetDraft]); + + function cancelEditing() { + resetDraft(); + setError(null); + setEditing(false); + } + async function updateInitiative(event: FormEvent) { event.preventDefault(); if (!title.trim() || !summary.trim()) return; @@ -52,6 +63,7 @@ export function InitiativeSettingsSection({ status, }); await onChanged(); + setEditing(false); } catch (err) { setError((err as Error).message); } finally { @@ -59,8 +71,25 @@ export function InitiativeSettingsSection({ } } + if (!editing) { + return ( +
+ +
+ ); + } + return ( - + + Cancel + + } + >
{ parseInitiativeTags("research, governance, research, governance "), ).toEqual(["research", "governance"]); }); + +test("initiative descriptions omit empty and summary-equivalent content", () => { + expect( + initiativeDistinctDescription( + "Coordinate public work.", + " Coordinate public work. ", + ), + ).toBe(""); + expect(initiativeDistinctDescription("Coordinate public work.", " ")).toBe( + "", + ); + expect( + initiativeDistinctDescription( + "Coordinate public work.", + "Publish evidence and assign owners.", + ), + ).toBe("Publish evidence and assign owners."); +}); From f377d0b624b1a4c4221b3f229b35af072f7253b3 Mon Sep 17 00:00:00 2001 From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:25:35 +0400 Subject: [PATCH 2/9] Align Initiative edit and board actions --- src/pages/initiatives/Initiative.tsx | 23 ++++++++++++++++--- .../components/InitiativeBoardSection.tsx | 20 +++++++++++----- .../components/InitiativeSettingsSection.tsx | 17 ++++---------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index b5f1833..f67e34c 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Link, useParams } from "react-router"; import { Chip } from "@/components/Chip"; @@ -30,6 +30,7 @@ import { useInitiativePageData } from "./hooks/useInitiativePageData"; const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); const { initiative, loadError, reload } = useInitiativePageData(id); + const [editing, setEditing] = useState(false); const boardColumns = useMemo(() => { if (!initiative) return []; @@ -124,8 +125,12 @@ const Initiative: React.FC = () => { - {canAdmin ? ( - + {canAdmin && editing ? ( + setEditing(false)} + /> ) : null} { columns={boardColumns} initiativeId={initiative.id} onChanged={reload} + secondaryAction={ + canAdmin && !editing ? ( + + ) : null + } /> Promise | void; + secondaryAction?: ReactNode; }; export function InitiativeBoardSection({ @@ -34,6 +36,7 @@ export function InitiativeBoardSection({ columns, initiativeId, onChanged, + secondaryAction, }: InitiativeBoardSectionProps) { const [updatingCardId, setUpdatingCardId] = useState(null); const [confirmDeleteCardId, setConfirmDeleteCardId] = useState( @@ -84,12 +87,17 @@ export function InitiativeBoardSection({ - - Create card - - + secondaryAction || canManage ? ( +
+ {secondaryAction} + {canManage ? ( + + ) : null} +
) : null } > diff --git a/src/pages/initiatives/components/InitiativeSettingsSection.tsx b/src/pages/initiatives/components/InitiativeSettingsSection.tsx index 9a48edd..ecca238 100644 --- a/src/pages/initiatives/components/InitiativeSettingsSection.tsx +++ b/src/pages/initiatives/components/InitiativeSettingsSection.tsx @@ -13,18 +13,19 @@ import type { InitiativeDto, InitiativeStatusDto } from "@/types/api"; type InitiativeSettingsSectionProps = { initiative: InitiativeDto; onChanged: () => Promise | void; + onClose: () => void; }; export function InitiativeSettingsSection({ initiative, onChanged, + onClose, }: InitiativeSettingsSectionProps) { const [title, setTitle] = useState(initiative.title); const [summary, setSummary] = useState(initiative.summary); const [description, setDescription] = useState(initiative.description); const [tags, setTags] = useState(initiative.tags.join(", ")); const [status, setStatus] = useState(initiative.status); - const [editing, setEditing] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const archivedReadOnly = @@ -45,7 +46,7 @@ export function InitiativeSettingsSection({ function cancelEditing() { resetDraft(); setError(null); - setEditing(false); + onClose(); } async function updateInitiative(event: FormEvent) { @@ -63,7 +64,7 @@ export function InitiativeSettingsSection({ status, }); await onChanged(); - setEditing(false); + onClose(); } catch (err) { setError((err as Error).message); } finally { @@ -71,16 +72,6 @@ export function InitiativeSettingsSection({ } } - if (!editing) { - return ( -
- -
- ); - } - return ( Date: Wed, 1 Jul 2026 00:59:50 +0400 Subject: [PATCH 3/9] Rebuild Initiative workspace header --- src/pages/initiatives/Initiative.tsx | 113 +++++++++++------- .../components/InitiativeBoardSection.tsx | 23 +--- tests/unit/initiative-layout-contract.test.ts | 30 +++++ 3 files changed, 99 insertions(+), 67 deletions(-) create mode 100644 tests/unit/initiative-layout-contract.test.ts diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index f67e34c..01578c5 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -6,13 +6,13 @@ import { GlassyCard } from "@/components/GlassyCard"; import { GlassyStatusChip } from "@/components/GlassySection"; import { MetricTile } from "@/components/MetricTile"; import { NoDataYetBar } from "@/components/NoDataYetBar"; -import { PageHeader } from "@/components/PageHeader"; import { Button } from "@/components/primitives/button"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { canManageInitiative, defaultInitiativeBoardColumns, + initiativeBoardCardCreatePath, initiativeDistinctDescription, initiativeRoleLabel, initiativeStatusLabel, @@ -27,6 +27,8 @@ import { InitiativeSettingsSection } from "./components/InitiativeSettingsSectio import { InitiativeThreadsSection } from "./components/InitiativeThreadsSection"; import { useInitiativePageData } from "./hooks/useInitiativePageData"; +const initiativeActionButtonClassName = "min-w-32 opacity-90 hover:opacity-100"; + const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); const { initiative, loadError, reload } = useInitiativePageData(id); @@ -79,44 +81,77 @@ const Initiative: React.FC = () => { return (
- - - +
+
+
+

+ {initiative.title} +

+

+ {initiative.summary} +

+
+ + {description ? ( +

+ {description} +

+ ) : null} + +
{initiativeStatusLabel[initiative.status]} - {viewerRole} + {viewerRole} + {initiative.tags.length > 0 ? ( +
- } - /> - {description ? ( -

- {description} -

- ) : null} - -
-
- {initiative.tags.map((tag) => ( - - {tag} - - ))} -
-
- Created by {shortAddress(initiative.createdByAddress)} - Updated {formatDateTime(initiative.updatedAt)} +
+ Created by {shortAddress(initiative.createdByAddress)} + Updated {formatDateTime(initiative.updatedAt)} +
-
+ + {canAdmin || canManage ? ( +
+ {canAdmin ? ( + + ) : null} + {canManage ? ( + + ) : null} +
+ ) : null} +
@@ -138,18 +173,6 @@ const Initiative: React.FC = () => { columns={boardColumns} initiativeId={initiative.id} onChanged={reload} - secondaryAction={ - canAdmin && !editing ? ( - - ) : null - } /> Promise | void; - secondaryAction?: ReactNode; }; export function InitiativeBoardSection({ @@ -36,7 +32,6 @@ export function InitiativeBoardSection({ columns, initiativeId, onChanged, - secondaryAction, }: InitiativeBoardSectionProps) { const [updatingCardId, setUpdatingCardId] = useState(null); const [confirmDeleteCardId, setConfirmDeleteCardId] = useState( @@ -84,23 +79,7 @@ export function InitiativeBoardSection({ } return ( - - {secondaryAction} - {canManage ? ( - - ) : null} -
- ) : null - } - > + {error ?

{error}

: null}
diff --git a/tests/unit/initiative-layout-contract.test.ts b/tests/unit/initiative-layout-contract.test.ts new file mode 100644 index 0000000..9ac73ee --- /dev/null +++ b/tests/unit/initiative-layout-contract.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "@rstest/core"; + +const initiativeSource = readFileSync( + join(process.cwd(), "src/pages/initiatives/Initiative.tsx"), + "utf8", +); +const boardSource = readFileSync( + join( + process.cwd(), + "src/pages/initiatives/components/InitiativeBoardSection.tsx", + ), + "utf8", +); + +test("Initiative header owns aligned edit and card creation actions", () => { + assert.doesNotMatch(initiativeSource, /Back to initiatives/); + assert.doesNotMatch(initiativeSource, /\s*Edit initiative\s*\s*Create card\s* Date: Wed, 1 Jul 2026 01:27:10 +0400 Subject: [PATCH 4/9] Unify Initiative header actions and details --- src/lib/initiativeUi.ts | 7 ++ src/pages/initiatives/Initiative.tsx | 97 ++++++++++--------- .../components/InitiativeHeaderAction.tsx | 25 +++++ tests/unit/initiative-layout-contract.test.ts | 19 ++-- tests/unit/initiative-ui.test.ts | 14 +++ 5 files changed, 108 insertions(+), 54 deletions(-) create mode 100644 src/pages/initiatives/components/InitiativeHeaderAction.tsx diff --git a/src/lib/initiativeUi.ts b/src/lib/initiativeUi.ts index 46d17c2..3da4668 100644 --- a/src/lib/initiativeUi.ts +++ b/src/lib/initiativeUi.ts @@ -120,6 +120,13 @@ export function initiativeDistinctDescription( return description.trim(); } +export function initiativeDescriptionParagraphs(description: string) { + return description + .split(/\n\s*\n/) + .map((paragraph) => paragraph.replace(/\s*\n\s*/g, " ").trim()) + .filter(Boolean); +} + export function initiativeOptionsWithSelection( options: Array<{ value: string; label: string }>, selectedId?: string, diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index 01578c5..f757ebb 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -1,18 +1,18 @@ import { useMemo, useState } from "react"; -import { Link, useParams } from "react-router"; +import { useNavigate, useParams } from "react-router"; import { Chip } from "@/components/Chip"; import { GlassyCard } from "@/components/GlassyCard"; import { GlassyStatusChip } from "@/components/GlassySection"; import { MetricTile } from "@/components/MetricTile"; import { NoDataYetBar } from "@/components/NoDataYetBar"; -import { Button } from "@/components/primitives/button"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { canManageInitiative, defaultInitiativeBoardColumns, initiativeBoardCardCreatePath, + initiativeDescriptionParagraphs, initiativeDistinctDescription, initiativeRoleLabel, initiativeStatusLabel, @@ -21,16 +21,16 @@ import { import { shortAddress } from "@/lib/profileUi"; import { InitiativeBoardSection } from "./components/InitiativeBoardSection"; import { InitiativeChatSection } from "./components/InitiativeChatSection"; +import { InitiativeHeaderAction } from "./components/InitiativeHeaderAction"; import { InitiativeMembersSection } from "./components/InitiativeMembersSection"; import { InitiativeProposalsSection } from "./components/InitiativeProposalsSection"; import { InitiativeSettingsSection } from "./components/InitiativeSettingsSection"; import { InitiativeThreadsSection } from "./components/InitiativeThreadsSection"; import { useInitiativePageData } from "./hooks/useInitiativePageData"; -const initiativeActionButtonClassName = "min-w-32 opacity-90 hover:opacity-100"; - const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); const { initiative, loadError, reload } = useInitiativePageData(id); const [editing, setEditing] = useState(false); @@ -78,12 +78,13 @@ const Initiative: React.FC = () => { initiative.summary, initiative.description, ); + const descriptionParagraphs = initiativeDescriptionParagraphs(description); return (
-
-
-
+ +
+

{initiative.title}

@@ -92,12 +93,42 @@ const Initiative: React.FC = () => {

- {description ? ( -

- {description} -

+ {canAdmin || canManage ? ( +
+ {canAdmin ? ( + setEditing(true)}> + Edit initiative + + ) : null} + {canManage ? ( + + navigate( + initiativeBoardCardCreatePath({ id: initiative.id }), + ) + } + > + Create card + + ) : null} +
) : null} +
+ + {descriptionParagraphs.length > 0 ? ( +
+ {descriptionParagraphs.map((paragraph, index) => ( +

+ {paragraph} +

+ ))} +
+ ) : null} +
{initiativeStatusLabel[initiative.status]} @@ -109,49 +140,19 @@ const Initiative: React.FC = () => { className="mx-1 h-4 w-px bg-[color:var(--surface-glass-border)]" /> ) : null} - {initiative.tags.length > 0 - ? initiative.tags.map((tag) => ( - - {tag} - - )) - : null} + {initiative.tags.map((tag) => ( + + {tag} + + ))}
Created by {shortAddress(initiative.createdByAddress)} Updated {formatDateTime(initiative.updatedAt)}
-
- - {canAdmin || canManage ? ( -
- {canAdmin ? ( - - ) : null} - {canManage ? ( - - ) : null} -
- ) : null} -
+ +
diff --git a/src/pages/initiatives/components/InitiativeHeaderAction.tsx b/src/pages/initiatives/components/InitiativeHeaderAction.tsx new file mode 100644 index 0000000..cc37fd5 --- /dev/null +++ b/src/pages/initiatives/components/InitiativeHeaderAction.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; + +import { Button } from "@/components/primitives/button"; + +type InitiativeHeaderActionProps = { + children: ReactNode; + onClick: () => void; +}; + +export function InitiativeHeaderAction({ + children, + onClick, +}: InitiativeHeaderActionProps) { + return ( + + ); +} diff --git a/tests/unit/initiative-layout-contract.test.ts b/tests/unit/initiative-layout-contract.test.ts index 9ac73ee..57ee4c5 100644 --- a/tests/unit/initiative-layout-contract.test.ts +++ b/tests/unit/initiative-layout-contract.test.ts @@ -14,17 +14,24 @@ const boardSource = readFileSync( ), "utf8", ); +const headerActionSource = readFileSync( + join( + process.cwd(), + "src/pages/initiatives/components/InitiativeHeaderAction.tsx", + ), + "utf8", +); test("Initiative header owns aligned edit and card creation actions", () => { assert.doesNotMatch(initiativeSource, /Back to initiatives/); - assert.doesNotMatch(initiativeSource, /\s*Edit initiative\s*\s*Create card\s* ), ).toBe("Publish evidence and assign owners."); }); + +test("initiative descriptions preserve distinct paragraphs without blank rows", () => { + expect( + initiativeDescriptionParagraphs( + "Analyze voting patterns.\n\nCollect evidence.\n\nReport findings.", + ), + ).toEqual([ + "Analyze voting patterns.", + "Collect evidence.", + "Report findings.", + ]); + expect(initiativeDescriptionParagraphs("")).toEqual([]); +}); From 1e8ffe497999406cf68fe72b4b747e2e1daadbc6 Mon Sep 17 00:00:00 2001 From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:32:52 +0400 Subject: [PATCH 5/9] Revert "Unify Initiative header actions and details" This reverts commit 0e93ad2e4cb665b1ba35ae7bfaf5e243cc41c007. --- src/lib/initiativeUi.ts | 7 -- src/pages/initiatives/Initiative.tsx | 97 +++++++++---------- .../components/InitiativeHeaderAction.tsx | 25 ----- tests/unit/initiative-layout-contract.test.ts | 19 ++-- tests/unit/initiative-ui.test.ts | 14 --- 5 files changed, 54 insertions(+), 108 deletions(-) delete mode 100644 src/pages/initiatives/components/InitiativeHeaderAction.tsx diff --git a/src/lib/initiativeUi.ts b/src/lib/initiativeUi.ts index 3da4668..46d17c2 100644 --- a/src/lib/initiativeUi.ts +++ b/src/lib/initiativeUi.ts @@ -120,13 +120,6 @@ export function initiativeDistinctDescription( return description.trim(); } -export function initiativeDescriptionParagraphs(description: string) { - return description - .split(/\n\s*\n/) - .map((paragraph) => paragraph.replace(/\s*\n\s*/g, " ").trim()) - .filter(Boolean); -} - export function initiativeOptionsWithSelection( options: Array<{ value: string; label: string }>, selectedId?: string, diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index f757ebb..01578c5 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -1,18 +1,18 @@ import { useMemo, useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { Link, useParams } from "react-router"; import { Chip } from "@/components/Chip"; import { GlassyCard } from "@/components/GlassyCard"; import { GlassyStatusChip } from "@/components/GlassySection"; import { MetricTile } from "@/components/MetricTile"; import { NoDataYetBar } from "@/components/NoDataYetBar"; +import { Button } from "@/components/primitives/button"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { canManageInitiative, defaultInitiativeBoardColumns, initiativeBoardCardCreatePath, - initiativeDescriptionParagraphs, initiativeDistinctDescription, initiativeRoleLabel, initiativeStatusLabel, @@ -21,16 +21,16 @@ import { import { shortAddress } from "@/lib/profileUi"; import { InitiativeBoardSection } from "./components/InitiativeBoardSection"; import { InitiativeChatSection } from "./components/InitiativeChatSection"; -import { InitiativeHeaderAction } from "./components/InitiativeHeaderAction"; import { InitiativeMembersSection } from "./components/InitiativeMembersSection"; import { InitiativeProposalsSection } from "./components/InitiativeProposalsSection"; import { InitiativeSettingsSection } from "./components/InitiativeSettingsSection"; import { InitiativeThreadsSection } from "./components/InitiativeThreadsSection"; import { useInitiativePageData } from "./hooks/useInitiativePageData"; +const initiativeActionButtonClassName = "min-w-32 opacity-90 hover:opacity-100"; + const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const { initiative, loadError, reload } = useInitiativePageData(id); const [editing, setEditing] = useState(false); @@ -78,13 +78,12 @@ const Initiative: React.FC = () => { initiative.summary, initiative.description, ); - const descriptionParagraphs = initiativeDescriptionParagraphs(description); return (
- -
-
+
+
+

{initiative.title}

@@ -93,42 +92,12 @@ const Initiative: React.FC = () => {

- {canAdmin || canManage ? ( -
- {canAdmin ? ( - setEditing(true)}> - Edit initiative - - ) : null} - {canManage ? ( - - navigate( - initiativeBoardCardCreatePath({ id: initiative.id }), - ) - } - > - Create card - - ) : null} -
+ {description ? ( +

+ {description} +

) : null} -
- - {descriptionParagraphs.length > 0 ? ( -
- {descriptionParagraphs.map((paragraph, index) => ( -

- {paragraph} -

- ))} -
- ) : null} -
{initiativeStatusLabel[initiative.status]} @@ -140,19 +109,49 @@ const Initiative: React.FC = () => { className="mx-1 h-4 w-px bg-[color:var(--surface-glass-border)]" /> ) : null} - {initiative.tags.map((tag) => ( - - {tag} - - ))} + {initiative.tags.length > 0 + ? initiative.tags.map((tag) => ( + + {tag} + + )) + : null}
Created by {shortAddress(initiative.createdByAddress)} Updated {formatDateTime(initiative.updatedAt)}
-
- +
+ + {canAdmin || canManage ? ( +
+ {canAdmin ? ( + + ) : null} + {canManage ? ( + + ) : null} +
+ ) : null} +
diff --git a/src/pages/initiatives/components/InitiativeHeaderAction.tsx b/src/pages/initiatives/components/InitiativeHeaderAction.tsx deleted file mode 100644 index cc37fd5..0000000 --- a/src/pages/initiatives/components/InitiativeHeaderAction.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { ReactNode } from "react"; - -import { Button } from "@/components/primitives/button"; - -type InitiativeHeaderActionProps = { - children: ReactNode; - onClick: () => void; -}; - -export function InitiativeHeaderAction({ - children, - onClick, -}: InitiativeHeaderActionProps) { - return ( - - ); -} diff --git a/tests/unit/initiative-layout-contract.test.ts b/tests/unit/initiative-layout-contract.test.ts index 57ee4c5..9ac73ee 100644 --- a/tests/unit/initiative-layout-contract.test.ts +++ b/tests/unit/initiative-layout-contract.test.ts @@ -14,24 +14,17 @@ const boardSource = readFileSync( ), "utf8", ); -const headerActionSource = readFileSync( - join( - process.cwd(), - "src/pages/initiatives/components/InitiativeHeaderAction.tsx", - ), - "utf8", -); test("Initiative header owns aligned edit and card creation actions", () => { assert.doesNotMatch(initiativeSource, /Back to initiatives/); + assert.doesNotMatch(initiativeSource, /\s*Edit initiative\s*\s*Create card\s* ), ).toBe("Publish evidence and assign owners."); }); - -test("initiative descriptions preserve distinct paragraphs without blank rows", () => { - expect( - initiativeDescriptionParagraphs( - "Analyze voting patterns.\n\nCollect evidence.\n\nReport findings.", - ), - ).toEqual([ - "Analyze voting patterns.", - "Collect evidence.", - "Report findings.", - ]); - expect(initiativeDescriptionParagraphs("")).toEqual([]); -}); From cb5c4d9a5d6d1ac317a48eb9b69165abda9c76b3 Mon Sep 17 00:00:00 2001 From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:32:52 +0400 Subject: [PATCH 6/9] Revert "Rebuild Initiative workspace header" This reverts commit 7f9047c0cfacbeded323142c37b57b87183db19f. --- src/pages/initiatives/Initiative.tsx | 113 +++++++----------- .../components/InitiativeBoardSection.tsx | 23 +++- tests/unit/initiative-layout-contract.test.ts | 30 ----- 3 files changed, 67 insertions(+), 99 deletions(-) delete mode 100644 tests/unit/initiative-layout-contract.test.ts diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index 01578c5..f67e34c 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -6,13 +6,13 @@ import { GlassyCard } from "@/components/GlassyCard"; import { GlassyStatusChip } from "@/components/GlassySection"; import { MetricTile } from "@/components/MetricTile"; import { NoDataYetBar } from "@/components/NoDataYetBar"; +import { PageHeader } from "@/components/PageHeader"; import { Button } from "@/components/primitives/button"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { canManageInitiative, defaultInitiativeBoardColumns, - initiativeBoardCardCreatePath, initiativeDistinctDescription, initiativeRoleLabel, initiativeStatusLabel, @@ -27,8 +27,6 @@ import { InitiativeSettingsSection } from "./components/InitiativeSettingsSectio import { InitiativeThreadsSection } from "./components/InitiativeThreadsSection"; import { useInitiativePageData } from "./hooks/useInitiativePageData"; -const initiativeActionButtonClassName = "min-w-32 opacity-90 hover:opacity-100"; - const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); const { initiative, loadError, reload } = useInitiativePageData(id); @@ -81,77 +79,44 @@ const Initiative: React.FC = () => { return (
-
-
-
-

- {initiative.title} -

-

- {initiative.summary} -

-
- - {description ? ( -

- {description} -

- ) : null} - -
+ + + {initiativeStatusLabel[initiative.status]} - {viewerRole} - {initiative.tags.length > 0 ? ( -
+ } + /> -
- Created by {shortAddress(initiative.createdByAddress)} - Updated {formatDateTime(initiative.updatedAt)} -
-
+ {description ? ( +

+ {description} +

+ ) : null} - {canAdmin || canManage ? ( -
- {canAdmin ? ( - - ) : null} - {canManage ? ( - - ) : null} -
- ) : null} -
+
+
+ {initiative.tags.map((tag) => ( + + {tag} + + ))} +
+
+ Created by {shortAddress(initiative.createdByAddress)} + Updated {formatDateTime(initiative.updatedAt)} +
+
@@ -173,6 +138,18 @@ const Initiative: React.FC = () => { columns={boardColumns} initiativeId={initiative.id} onChanged={reload} + secondaryAction={ + canAdmin && !editing ? ( + + ) : null + } /> Promise | void; + secondaryAction?: ReactNode; }; export function InitiativeBoardSection({ @@ -32,6 +36,7 @@ export function InitiativeBoardSection({ columns, initiativeId, onChanged, + secondaryAction, }: InitiativeBoardSectionProps) { const [updatingCardId, setUpdatingCardId] = useState(null); const [confirmDeleteCardId, setConfirmDeleteCardId] = useState( @@ -79,7 +84,23 @@ export function InitiativeBoardSection({ } return ( - + + {secondaryAction} + {canManage ? ( + + ) : null} +
+ ) : null + } + > {error ?

{error}

: null}
diff --git a/tests/unit/initiative-layout-contract.test.ts b/tests/unit/initiative-layout-contract.test.ts deleted file mode 100644 index 9ac73ee..0000000 --- a/tests/unit/initiative-layout-contract.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { test } from "@rstest/core"; - -const initiativeSource = readFileSync( - join(process.cwd(), "src/pages/initiatives/Initiative.tsx"), - "utf8", -); -const boardSource = readFileSync( - join( - process.cwd(), - "src/pages/initiatives/components/InitiativeBoardSection.tsx", - ), - "utf8", -); - -test("Initiative header owns aligned edit and card creation actions", () => { - assert.doesNotMatch(initiativeSource, /Back to initiatives/); - assert.doesNotMatch(initiativeSource, /\s*Edit initiative\s*\s*Create card\s* Date: Wed, 1 Jul 2026 01:38:57 +0400 Subject: [PATCH 7/9] Share workspace headers across Initiatives and factions --- src/components/WorkspaceHeader.tsx | 91 ++++++++++++ src/lib/initiativeUi.ts | 7 + src/pages/factions/components/FactionHero.tsx | 139 +++++++++--------- src/pages/initiatives/Initiative.tsx | 102 +++++++------ .../components/InitiativeBoardSection.tsx | 23 +-- tests/unit/initiative-ui.test.ts | 14 ++ tests/unit/workspace-header-contract.test.ts | 36 +++++ 7 files changed, 276 insertions(+), 136 deletions(-) create mode 100644 src/components/WorkspaceHeader.tsx create mode 100644 tests/unit/workspace-header-contract.test.ts diff --git a/src/components/WorkspaceHeader.tsx b/src/components/WorkspaceHeader.tsx new file mode 100644 index 0000000..1935613 --- /dev/null +++ b/src/components/WorkspaceHeader.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; + +import { GlassyCard } from "@/components/GlassyCard"; +import { Button } from "@/components/primitives/button"; + +type WorkspaceHeaderProps = { + actions?: ReactNode; + details?: string[]; + markers?: ReactNode; + meta?: ReactNode; + summary: ReactNode; + title: ReactNode; +}; + +type WorkspaceHeaderActionProps = { + children: ReactNode; + disabled?: boolean; + onClick: () => void; +}; + +export function WorkspaceHeader({ + actions, + details = [], + markers, + meta, + summary, + title, +}: WorkspaceHeaderProps) { + return ( + +
+
+

+ {title} +

+
{summary}
+
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ + {details.length > 0 ? ( +
+ {details.map((detail, index) => ( +

+ {detail} +

+ ))} +
+ ) : null} + + {markers || meta ? ( +
+ {markers ? ( +
{markers}
+ ) : null} + {meta ? ( +
+ {meta} +
+ ) : null} +
+ ) : null} +
+ ); +} + +export function WorkspaceHeaderAction({ + children, + disabled = false, + onClick, +}: WorkspaceHeaderActionProps) { + return ( + + ); +} diff --git a/src/lib/initiativeUi.ts b/src/lib/initiativeUi.ts index 46d17c2..3da4668 100644 --- a/src/lib/initiativeUi.ts +++ b/src/lib/initiativeUi.ts @@ -120,6 +120,13 @@ export function initiativeDistinctDescription( return description.trim(); } +export function initiativeDescriptionParagraphs(description: string) { + return description + .split(/\n\s*\n/) + .map((paragraph) => paragraph.replace(/\s*\n\s*/g, " ").trim()) + .filter(Boolean); +} + export function initiativeOptionsWithSelection( options: Array<{ value: string; label: string }>, selectedId?: string, diff --git a/src/pages/factions/components/FactionHero.tsx b/src/pages/factions/components/FactionHero.tsx index be32dc3..e7385ad 100644 --- a/src/pages/factions/components/FactionHero.tsx +++ b/src/pages/factions/components/FactionHero.tsx @@ -1,9 +1,11 @@ -import { Kicker } from "@/components/Kicker"; -import { StatTile } from "@/components/StatTile"; +import { Chip } from "@/components/Chip"; +import { GlassyStatusChip } from "@/components/GlassySection"; import { HintLabel } from "@/components/Hint"; -import { Badge } from "@/components/primitives/badge"; -import { Button } from "@/components/primitives/button"; -import { Card, CardContent } from "@/components/primitives/card"; +import { MetricTile } from "@/components/MetricTile"; +import { + WorkspaceHeader, + WorkspaceHeaderAction, +} from "@/components/WorkspaceHeader"; import type { FactionDto } from "@/types/api"; type FactionHeroProps = { @@ -35,25 +37,22 @@ export const FactionHero: React.FC = ({ viewerMembershipActive, viewerRole, }) => { + const roleLabel = viewerRole + ? viewerRole.charAt(0).toUpperCase() + viewerRole.slice(1) + : "Observer"; + const pendingJoin = + viewerJoinRequest?.status === "pending" && !viewerMembershipActive; + return ( <> - - -
-
- {faction.focus} -

- {faction.name} -

-

- {faction.description} -

-
- -
+ {canJoin ? ( - + ) : null} {canLeave ? ( - + ) : null} {isFounderAdmin ? ( - + ) : null} -
-
- - {viewerRole === "founder" ? ( -
- - Founder leave disabled until transfer - -
- ) : null} - {viewerJoinRequest?.status === "pending" && - !viewerMembershipActive ? ( -
- - Private faction join request pending - -
- ) : null} -
-
+ + ) : undefined + } + markers={ + <> + + {faction.visibility === "public" ? "Public" : "Private"} + + {roleLabel} + + {faction.focus} + + + } + meta={ + viewerRole === "founder" || pendingJoin ? ( + <> + {viewerRole === "founder" ? ( + + Founder leave requires transfer + + ) : null} + {pendingJoin ? ( + + Join request pending + + ) : null} + + ) : undefined + } + /> -
- - + + } value={String(faction.acm)} - align="center" /> - +
diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index f67e34c..611bef5 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -1,18 +1,22 @@ import { useMemo, useState } from "react"; -import { Link, useParams } from "react-router"; +import { useNavigate, useParams } from "react-router"; import { Chip } from "@/components/Chip"; import { GlassyCard } from "@/components/GlassyCard"; import { GlassyStatusChip } from "@/components/GlassySection"; import { MetricTile } from "@/components/MetricTile"; import { NoDataYetBar } from "@/components/NoDataYetBar"; -import { PageHeader } from "@/components/PageHeader"; -import { Button } from "@/components/primitives/button"; +import { + WorkspaceHeader, + WorkspaceHeaderAction, +} from "@/components/WorkspaceHeader"; import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; import { canManageInitiative, defaultInitiativeBoardColumns, + initiativeBoardCardCreatePath, + initiativeDescriptionParagraphs, initiativeDistinctDescription, initiativeRoleLabel, initiativeStatusLabel, @@ -29,6 +33,7 @@ import { useInitiativePageData } from "./hooks/useInitiativePageData"; const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); const { initiative, loadError, reload } = useInitiativePageData(id); const [editing, setEditing] = useState(false); @@ -76,48 +81,63 @@ const Initiative: React.FC = () => { initiative.summary, initiative.description, ); + const descriptionParagraphs = initiativeDescriptionParagraphs(description); return (
- - - + summary={initiative.summary} + details={descriptionParagraphs} + actions={ + canAdmin || canManage ? ( + <> + {canAdmin ? ( + setEditing(true)}> + Edit initiative + + ) : null} + {canManage ? ( + + navigate( + initiativeBoardCardCreatePath({ id: initiative.id }), + ) + } + > + Create card + + ) : null} + + ) : undefined + } + markers={ + <> {initiativeStatusLabel[initiative.status]} - {viewerRole} -
+ {viewerRole} + {initiative.tags.length > 0 ? ( +
- ) : null - } - > + {error ?

{error}

: null}
diff --git a/tests/unit/initiative-ui.test.ts b/tests/unit/initiative-ui.test.ts index 123184b..a0ccbb1 100644 --- a/tests/unit/initiative-ui.test.ts +++ b/tests/unit/initiative-ui.test.ts @@ -5,6 +5,7 @@ import { defaultInitiativeBoardColumns, initiativeBoardCardCreatePath, initiativeCardsForColumn, + initiativeDescriptionParagraphs, initiativeDistinctDescription, initiativeOptionsWithSelection, initiativePath, @@ -125,3 +126,16 @@ test("initiative descriptions omit empty and summary-equivalent content", () => ), ).toBe("Publish evidence and assign owners."); }); + +test("initiative descriptions preserve distinct paragraphs without blank rows", () => { + expect( + initiativeDescriptionParagraphs( + "Analyze voting patterns.\n\nCollect evidence.\n\nReport findings.", + ), + ).toEqual([ + "Analyze voting patterns.", + "Collect evidence.", + "Report findings.", + ]); + expect(initiativeDescriptionParagraphs("")).toEqual([]); +}); diff --git a/tests/unit/workspace-header-contract.test.ts b/tests/unit/workspace-header-contract.test.ts new file mode 100644 index 0000000..43c1783 --- /dev/null +++ b/tests/unit/workspace-header-contract.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "@rstest/core"; + +function source(path: string) { + return readFileSync(join(process.cwd(), path), "utf8"); +} + +const header = source("src/components/WorkspaceHeader.tsx"); +const initiative = source("src/pages/initiatives/Initiative.tsx"); +const board = source( + "src/pages/initiatives/components/InitiativeBoardSection.tsx", +); +const faction = source("src/pages/factions/components/FactionHero.tsx"); + +test("Initiatives and factions share one workspace header grammar", () => { + assert.match(header, / Date: Wed, 1 Jul 2026 13:16:44 +0400 Subject: [PATCH 8/9] Bound Initiative chat history and move it last --- src/pages/initiatives/Initiative.tsx | 12 +- .../components/InitiativeChatSection.tsx | 123 ++++++++++++------ .../initiative-chat-layout-contract.test.ts | 37 ++++++ 3 files changed, 126 insertions(+), 46 deletions(-) create mode 100644 tests/unit/initiative-chat-layout-contract.test.ts diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index 611bef5..079e580 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -166,12 +166,6 @@ const Initiative: React.FC = () => { onChanged={reload} threads={threads} /> - { memberships={memberships} onChanged={reload} /> +
); }; diff --git a/src/pages/initiatives/components/InitiativeChatSection.tsx b/src/pages/initiatives/components/InitiativeChatSection.tsx index 7801a6b..3d8389a 100644 --- a/src/pages/initiatives/components/InitiativeChatSection.tsx +++ b/src/pages/initiatives/components/InitiativeChatSection.tsx @@ -1,10 +1,9 @@ import type { FormEvent } from "react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; -import { NoDataYetBar } from "@/components/NoDataYetBar"; import { GlassySection } from "@/components/GlassySection"; +import { Textarea } from "@/components/Textarea"; import { Button } from "@/components/primitives/button"; -import { Input } from "@/components/primitives/input"; import { apiInitiativeChatPost } from "@/lib/apiClient"; import { formatDateTime } from "@/lib/dateTime"; import { shortAddress } from "@/lib/profileUi"; @@ -26,6 +25,22 @@ export function InitiativeChatSection({ const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const messageViewportRef = useRef(null); + const stickToBottomRef = useRef(true); + + useEffect(() => { + const viewport = messageViewportRef.current; + if (!viewport || !stickToBottomRef.current) return; + viewport.scrollTop = viewport.scrollHeight; + }, [messages.length]); + + function trackScrollPosition() { + const viewport = messageViewportRef.current; + if (!viewport) return; + const distanceFromBottom = + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + stickToBottomRef.current = distanceFromBottom < 48; + } async function postMessage(event: FormEvent) { event.preventDefault(); @@ -33,6 +48,7 @@ export function InitiativeChatSection({ if (!message) return; setSubmitting(true); setError(null); + stickToBottomRef.current = true; try { await apiInitiativeChatPost({ initiativeId, body: message }); setBody(""); @@ -45,45 +61,72 @@ export function InitiativeChatSection({ } return ( - -
- {messages.length === 0 ? ( - - ) : ( - messages.map((message) => ( -
+ {messages.length} {messages.length === 1 ? "message" : "messages"} + + } + > +
+
+ {messages.length === 0 ? ( +
+ No chat messages yet. +
+ ) : ( +
+ {messages.map((message) => ( +
+
+ + {shortAddress(message.authorAddress)} + + +
+

+ {message.body} +

+
+ ))} +
+ )} +
+ + {canPost ? ( + +