Merge pull request 'Backend / staging cleanup, performance substrate, and create-flow polish' (#60) from adilallo/Backend/StagingCleanup into main

Reviewed-on: #60
This commit was merged in pull request #60.
This commit is contained in:
2026-05-26 15:11:46 +00:00
123 changed files with 2523 additions and 611 deletions
+9
View File
@@ -0,0 +1,9 @@
# Gitea issue template config — see https://docs.gitea.com/usage/issue-pull-request-templates
blank_issues_enabled: false
contact_links:
- name: Open the staging site
url: https://staging.communityrule.info
about: Preview version of Community Rule, test here before reporting
- name: How to sign in (read first)
url: https://staging.communityrule.info/login
about: Use your email to recieve a link to click
@@ -0,0 +1,36 @@
---
name: "Feedback or suggestion"
about: "Something worked but was confusing, or you have an idea to improve it"
title: "[Staging feedback] "
---
Thank you for sharing your thoughts on the staging preview. **Clear, everyday language is exactly what we need.**
### Before you submit
- This form is for **ideas and usability feedback**, not broken pages — if something failed or errored, use **"Something isn't working"** instead.
- **One topic per report** keeps feedback easier to act on.
---
## Where on the site?
_Copy the page address from your browser, or describe where you were — e.g. "Create rule — choose methods step"_
## What is your feedback?
_Tell us what felt confusing, missing, or could work better. What would make this easier for you or your community?_
## Why would this help? (optional)
_Who benefits — e.g. first-time organizers, small groups, non-English speakers?_
## Screenshot or example (optional)
_Drag an image here, or describe what you were looking at._
## Your browser and device (optional)
_Examples: "Safari on iPad," "Chrome on Android phone"_
## Anything else?
@@ -0,0 +1,67 @@
---
name: "Something isn't working"
about: "A page broke, a button didn't work, or you got stuck"
title: "[Staging] "
---
Thank you for helping us test Community Rule. **You do not need to be technical** — plain language is perfect. Answer what you can and skip anything you are not sure about.
### Before you submit
- **One report = one problem.** If you found several separate issues, please open a new report for each.
- **Check [existing reports](https://git.medlab.host/CommunityRule/community-rule/issues)** — someone may have already reported the same thing.
- **Never share your password** or paste the **sign-in link from your email** here (those are private to you).
---
## Where were you on the site?
_Copy the web address from your browser's address bar, or check the box that fits:_
- [ ] https://staging.communityrule.info (staging / preview site)
- [ ] https://communityrule.info (live site)
- [ ] Other:
## What were you trying to do?
_Examples: "Sign in with my email," "Create a new rule," "Upload a community photo," "Publish my rule"_
## What happened instead?
_Describe what you saw. Did an error message appear? Did a button do nothing? Did the page look wrong?_
## What did you expect to happen?
## Steps to get there (if you remember)
_Numbered steps help us reproduce the problem — e.g. "1. Clicked Log in → 2. Entered my email → 3. …"_
1.
2.
3.
## Does it happen every time?
- [ ] Yes — it happens every time I try
- [ ] Sometimes — it only happened once or occasionally
- [ ] Not sure — I have not tried again
## Your browser and device
_Examples: "Safari on iPhone," "Chrome on a Windows laptop," "Firefox on Mac"_
## Screenshot (optional, very helpful)
You can **drag an image into this text box** or use the **attachment** button below it.
## Sign-in email (only if this is about logging in)
_The email address you used. We will not ask for your password._
**Did you receive the sign-in email?**
- [ ] Yes, but the link did not work
- [ ] No, nothing arrived (I checked spam/junk)
- [ ] Not applicable — this is not about signing in
## Anything else we should know?
+6 -1
View File
@@ -28,7 +28,7 @@ npm-cache/
/lhci-results/
/.lighthouseci/
# Ignore other image files (but not visual regression snapshots)
# Ignore other image files (but not visual regression snapshots or favicons)
*.png
*.jpg
*.jpeg
@@ -39,6 +39,11 @@ npm-cache/
*.avi
*.mkv
# Root favicons (generated via `npm run generate:favicons`)
!public/favicon-16x16.png
!public/favicon-32x32.png
!public/apple-touch-icon.png
# Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
+19 -2
View File
@@ -1,7 +1,24 @@
import type { ReactNode } from "react";
import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
// See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale
// — the fallback can't access `usePathname()` since it sits in the static shell.
//
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
// public marketing footer. Auth/access is enforced upstream.
export default function AdminLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>;
return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<Suspense fallback={null}>
<ConditionalNavigation />
</Suspense>
<main className="flex-1">{children}</main>
</AuthModalProvider>
</MessagesProvider>
);
}
+3 -1
View File
@@ -12,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import { usePrefetchMethodFacetRecommendations } from "./hooks/usePrefetchMethodFacetRecommendations";
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport";
@@ -167,6 +168,7 @@ function CreateFlowLayoutContent({
replaceState,
markCreateFlowInteraction,
} = useCreateFlow();
usePrefetchMethodFacetRecommendations();
const manageStakeholdersIntent =
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
@@ -692,7 +694,7 @@ function CreateFlowLayoutContent({
}`.trim()}
/>
<main
className={`flex min-h-0 flex-1 w-full ${mainContentClass} ${mainResponsiveLayout}`}
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`}
>
{children}
</main>
+7 -23
View File
@@ -1,28 +1,12 @@
"use client";
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import { useTranslation } from "../../contexts/MessagesContext";
function CreateFlowLayoutLoading() {
const t = useTranslation("controlsChrome");
return (
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
aria-busy="true"
aria-label={t("loadingCreateFlow")}
/>
);
}
const CreateFlowLayoutClient = dynamic(
() => import("./CreateFlowLayoutClient"),
{
ssr: false,
loading: () => <CreateFlowLayoutLoading />,
},
);
import CreateFlowLayoutClient from "./CreateFlowLayoutClient";
/**
* Server-renders the create-flow chrome shell so users see real layout instead
* of a black `aria-busy` div while the client bundle hydrates. The provider
* inside `CreateFlowLayoutClient` defers `localStorage` reads to a mount-once
* effect so SSR + first client render align.
*/
export default function CreateFlowLayoutGate({
children,
}: {
@@ -16,6 +16,7 @@ import {
isValidStep,
parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps";
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
@@ -52,6 +53,22 @@ export function SignedInDraftHydration({
const [loadingHydration, setLoadingHydration] = useState(false);
const finishedUserIdRef = useRef<string | null>(null);
const [freshEntryPending, setFreshEntryPending] = useState(false);
// Poll the sessionStorage sentinel set by `prepareFreshCreateFlowEntrySync`.
// Cheap because the gate is open within a few hundred ms in practice; the
// poll stops as soon as the in-flight DELETE clears the flag.
useEffect(() => {
if (!hasFreshEntryPending()) return;
setFreshEntryPending(true);
const id = window.setInterval(() => {
if (!hasFreshEntryPending()) {
setFreshEntryPending(false);
window.clearInterval(id);
}
}, 50);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
if (!isBackendSyncEnabled()) return;
@@ -68,6 +85,10 @@ export function SignedInDraftHydration({
return;
}
if (freshEntryPending) {
return;
}
// Local draft wins over server: no fetch, no replaceState. The provider
// already hydrated from localStorage at mount, so the user sees their
// unsaved keystrokes immediately.
@@ -122,6 +143,7 @@ export function SignedInDraftHydration({
replaceState,
pathname,
router,
freshEntryPending,
]);
if (!loadingHydration) return null;
+34 -14
View File
@@ -56,29 +56,49 @@ export function CreateFlowProvider({
initialStep = null,
enableLocalDraftMirroring = false,
}: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>(() => {
const base = enableLocalDraftMirroring
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
if (Object.keys(storedDetails).length === 0) return base;
return {
...base,
coreValueDetailsByChipId: {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
},
};
});
// Initializer must NOT touch `localStorage`: this provider runs through SSR
// now (CreateFlowLayoutGate dropped `ssr: false`), and a server `{}` followed
// by a client read of stored data would be a hydration mismatch. The
// `mount-once` effect below replays the read on the client.
const [state, setState] = useState<CreateFlowState>({});
const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const prevPersistRef = useRef(enableLocalDraftMirroring);
const persistWriteSkipRef = useRef(true);
const initialHydrateDoneRef = useRef(false);
useEffect(() => {
clearLegacyCreateFlowKeysOnce();
}, []);
// Replay the previous `useState` initializer on mount (client-only). Keeps
// SSR + first client render aligned with the empty default while still
// hydrating any persisted draft / core-value details that existed before
// the user landed back on a wizard step.
useEffect(() => {
if (initialHydrateDoneRef.current) return;
initialHydrateDoneRef.current = true;
const base = enableLocalDraftMirroring
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
const baseEmpty = Object.keys(base).length === 0;
const detailsEmpty = Object.keys(storedDetails).length === 0;
if (baseEmpty && detailsEmpty) return;
setState((prev) => {
const merged: CreateFlowState = { ...base, ...prev };
if (!detailsEmpty) {
merged.coreValueDetailsByChipId = {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
...(prev.coreValueDetailsByChipId ?? {}),
};
}
return merged;
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional mount-once
}, []);
// Session resolved after initial paint: hydrate from localStorage, merging
// with anything already in state. We can't bail on `prev` being non-empty:
// the initializer pre-populates `coreValueDetailsByChipId` from a separate
@@ -1,8 +1,13 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
import {
buildFacetRecommendationRequestKey,
getCachedFacetScores,
loadFacetScores,
} from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext";
/**
@@ -25,6 +30,34 @@ export type FacetRecommendationsResult = {
const EMPTY_SCORES: Record<string, number> = {};
function initialFacetRecommendationsResult(
section: RecommendationSection,
queryString: string,
): FacetRecommendationsResult {
const hasAnyFacets = queryString.length > 0;
if (!hasAnyFacets) {
return {
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false,
};
}
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
const cached = getCachedFacetScores(requestKey);
if (cached) {
return {
isReady: true,
scoresBySlug: cached,
hasAnyFacets: true,
};
}
return {
isReady: false,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: true,
};
}
/**
* Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the
* card-deck step `section` and returns a `slug → score` map for re-ranking
@@ -46,14 +79,9 @@ export function useFacetRecommendations(
);
const hasAnyFacets = queryString.length > 0;
const [result, setResult] = useState<FacetRecommendationsResult>({
isReady: !hasAnyFacets,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets,
});
// Track the last successful request input so we don't re-fetch on every state poke.
const lastQueryRef = useRef<string | null>(null);
const [result, setResult] = useState<FacetRecommendationsResult>(() =>
initialFacetRecommendationsResult(section, queryString),
);
useEffect(() => {
if (!hasAnyFacets) {
@@ -62,51 +90,34 @@ export function useFacetRecommendations(
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false,
});
lastQueryRef.current = null;
return;
}
const requestKey = `${section}?${queryString}`;
if (lastQueryRef.current === requestKey) return;
lastQueryRef.current = requestKey;
const ctrl = new AbortController();
setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true }));
fetch(`/api/create-flow/methods?section=${section}&${queryString}`, {
credentials: "include",
signal: ctrl.signal,
})
.then(async (res) => {
if (!res.ok) throw new Error(`status ${res.status}`);
return (await res.json()) as {
methods?: { slug: string; matches?: { score?: number } }[];
};
})
.then((json) => {
const scoresBySlug: Record<string, number> = {};
for (const m of json.methods ?? []) {
if (typeof m.slug === "string") {
scoresBySlug[m.slug] = m.matches?.score ?? 0;
}
}
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
})
.catch((e) => {
if ((e as { name?: string }).name === "AbortError") return;
setResult({
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: true,
});
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
const cached = getCachedFacetScores(requestKey);
if (cached) {
setResult({
isReady: true,
scoresBySlug: cached,
hasAnyFacets: true,
});
return;
}
let cancelled = false;
setResult((prev) =>
prev.isReady && prev.hasAnyFacets
? { ...prev, isReady: false }
: { isReady: false, scoresBySlug: EMPTY_SCORES, hasAnyFacets: true },
);
void loadFacetScores(section, queryString).then((scoresBySlug) => {
if (cancelled) return;
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
});
return () => {
ctrl.abort();
// Clear the dedup key so React 19 Strict Mode's mount → unmount → mount
// cycle (and any future remount) re-issues the request instead of
// returning early on the same key.
if (lastQueryRef.current === requestKey) {
lastQueryRef.current = null;
}
cancelled = true;
};
}, [section, queryString, hasAnyFacets]);
@@ -25,7 +25,9 @@ export function useMethodCardDeckOrdering(
methods: readonly MethodEntry[],
selectedIds: readonly string[],
) {
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
const { scoresBySlug, hasAnyFacets, isReady } =
useFacetRecommendations(section);
const recommendationsReady = !hasAnyFacets || isReady;
const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug),
@@ -90,5 +92,6 @@ export function useMethodCardDeckOrdering(
recommendedIds,
sampleCards,
methodById,
recommendationsReady,
};
}
@@ -0,0 +1,27 @@
"use client";
import { useEffect, useMemo } from "react";
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
import { METHOD_FACET_API_SECTION_IDS } from "../../../../lib/create/customRuleFacets";
import { loadFacetScores } from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext";
/**
* Warms the facet recommendation cache for all method-deck sections once the
* user has community facet selections, so method screens can render ranked
* cards on first paint instead of flashing authoring order.
*/
export function usePrefetchMethodFacetRecommendations(): void {
const { state } = useCreateFlow();
const queryString = useMemo(
() => buildFacetQueryString(state),
[state],
);
useEffect(() => {
if (queryString.length === 0) return;
for (const section of METHOD_FACET_API_SECTION_IDS) {
void loadFacetScores(section, queryString);
}
}, [queryString]);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Route-level fallback shown while `/create/...` RSC streams in. Mirrors the
* create-flow chrome surface (top bar height + dark canvas) so the user sees
* structural feedback instead of a flash of the previous page.
*/
export default function CreateFlowRouteLoading() {
return (
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
aria-busy="true"
aria-live="polite"
>
<div className="h-14 w-full border-b border-[var(--color-border-default-primary)] md:h-16" />
<div className="flex-1" />
</div>
);
}
@@ -97,7 +97,8 @@ export function CommunicationMethodsScreen() {
[comm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"communication",
mergedMethods,
selectedIds,
@@ -735,23 +736,28 @@ export function CommunicationMethodsScreen() {
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady && (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
</div>
@@ -94,7 +94,8 @@ export function ConflictManagementScreen() {
[cm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"conflictManagement",
mergedMethods,
selectedIds,
@@ -734,23 +735,28 @@ export function ConflictManagementScreen() {
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady && (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
</div>
@@ -95,7 +95,8 @@ export function MembershipMethodsScreen() {
[mem.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"membership",
mergedMethods,
selectedIds,
@@ -727,23 +728,28 @@ export function MembershipMethodsScreen() {
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady && (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
</div>
@@ -2,24 +2,108 @@
* Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView}
* thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so
* new steps do not drift.
*
* `InformationalScreen` is statically imported because it is the entry step;
* every other screen is lazy-loaded so visiting `/create/informational` does
* not pull the rest of the wizard into the initial bundle.
*/
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import type { CreateFlowStep } from "../types";
import { InformationalScreen } from "./informational/InformationalScreen";
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
import { FinalReviewScreen } from "./review/FinalReviewScreen";
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
import { CompletedScreen } from "./completed/CompletedScreen";
const CreateFlowTextFieldScreen = dynamic(
() =>
import("./text/CreateFlowTextFieldScreen").then((m) => ({
default: m.CreateFlowTextFieldScreen,
})),
{ loading: () => null },
);
const CommunitySizeSelectScreen = dynamic(
() =>
import("./select/CommunitySizeSelectScreen").then((m) => ({
default: m.CommunitySizeSelectScreen,
})),
{ loading: () => null },
);
const CommunityStructureSelectScreen = dynamic(
() =>
import("./select/CommunityStructureSelectScreen").then((m) => ({
default: m.CommunityStructureSelectScreen,
})),
{ loading: () => null },
);
const CoreValuesSelectScreen = dynamic(
() =>
import("./select/CoreValuesSelectScreen").then((m) => ({
default: m.CoreValuesSelectScreen,
})),
{ loading: () => null },
);
const ConfirmStakeholdersScreen = dynamic(
() =>
import("./select/ConfirmStakeholdersScreen").then((m) => ({
default: m.ConfirmStakeholdersScreen,
})),
{ loading: () => null },
);
const CommunityUploadScreen = dynamic(
() =>
import("./upload/CommunityUploadScreen").then((m) => ({
default: m.CommunityUploadScreen,
})),
{ loading: () => null },
);
const CommunityReviewScreen = dynamic(
() =>
import("./review/CommunityReviewScreen").then((m) => ({
default: m.CommunityReviewScreen,
})),
{ loading: () => null },
);
const FinalReviewScreen = dynamic(
() =>
import("./review/FinalReviewScreen").then((m) => ({
default: m.FinalReviewScreen,
})),
{ loading: () => null },
);
const CommunicationMethodsScreen = dynamic(
() =>
import("./card/CommunicationMethodsScreen").then((m) => ({
default: m.CommunicationMethodsScreen,
})),
{ loading: () => null },
);
const MembershipMethodsScreen = dynamic(
() =>
import("./card/MembershipMethodsScreen").then((m) => ({
default: m.MembershipMethodsScreen,
})),
{ loading: () => null },
);
const ConflictManagementScreen = dynamic(
() =>
import("./card/ConflictManagementScreen").then((m) => ({
default: m.ConflictManagementScreen,
})),
{ loading: () => null },
);
const DecisionApproachesScreen = dynamic(
() =>
import("./right-rail/DecisionApproachesScreen").then((m) => ({
default: m.DecisionApproachesScreen,
})),
{ loading: () => null },
);
const CompletedScreen = dynamic(
() =>
import("./completed/CompletedScreen").then((m) => ({
default: m.CompletedScreen,
})),
{ loading: () => null },
);
export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
switch (screenId) {
@@ -108,7 +108,8 @@ export function DecisionApproachesScreen() {
[da.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"decisionApproaches",
mergedMethods,
selectedIds,
@@ -761,36 +762,41 @@ export function DecisionApproachesScreen() {
</div>
}
>
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess}
title=""
description={
expanded ? (
<>
{da.cardStack.expandedStackDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{da.sidebar.descriptionLinkLabel}
</InlineTextButton>
{da.cardStack.expandedStackDescriptionAfter}
</>
) : (
""
)
}
layout="singleStack"
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
<div
className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0"
aria-busy={!recommendationsReady}
>
{recommendationsReady && (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess}
title=""
description={
expanded ? (
<>
{da.cardStack.expandedStackDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{da.sidebar.descriptionLinkLabel}
</InlineTextButton>
{da.cardStack.expandedStackDescriptionAfter}
</>
) : (
""
)
}
layout="singleStack"
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
<Create
+3 -2
View File
@@ -7,13 +7,14 @@ import type { CreateFlowStep } from "../types";
import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
FIRST_STEP,
} from "./flowSteps";
export const CREATE_ROUTES = {
root: "/",
createRoot: "/create",
/** First step resolves via redirect from `/create`. */
createFirstStep: "/create",
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
createFirstStep: `/create/${FIRST_STEP}`,
review: "/create/review",
finalReview: "/create/final-review",
completed: "/create/completed",
@@ -4,19 +4,92 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
/**
* Sentinel set on click and cleared once the in-flight DELETE settles. Read by
* {@link SignedInDraftHydration} so it skips the server draft fetch while the
* fresh-entry cleanup is racing the user's first paint of `/create`.
*/
export const FRESH_ENTRY_PENDING_KEY = "create:fresh-entry-pending";
export type PrepareFreshCreateFlowEntryOptions = {
/**
* When `true`, and backend sync is on, also `DELETE /api/drafts/me`.
* Omit or pass `false` for guests — they have no server draft to clear.
*/
signedIn?: boolean;
};
export function hasFreshEntryPending(): boolean {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(FRESH_ENTRY_PENDING_KEY) === "1";
} catch {
return false;
}
}
function setFreshEntryPending(): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(FRESH_ENTRY_PENDING_KEY, "1");
} catch {
/* ignore — sessionStorage may be unavailable */
}
}
function clearFreshEntryPending(): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(FRESH_ENTRY_PENDING_KEY);
} catch {
/* ignore */
}
}
function clearServerDraftWhenSignedIn(signedIn: boolean): void {
if (!signedIn || !isBackendSyncEnabled()) return;
setFreshEntryPending();
void deleteServerDraft().finally(clearFreshEntryPending);
}
/**
* Call **before** navigating into `/create` from marketing or profile “new rule”
* entry points so signed-in + sync matches an anonymous fresh start: wipe
* `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`.
* Anonymous `DELETE` is harmless (401). Await ensures the server draft is gone
* before mount so {@link SignedInDraftHydration} does not rehydrate stale work.
* `localStorage` draft keys and, when sync is on and the user is signed in,
* `DELETE /api/drafts/me`.
*
* Synchronous variant: returns immediately after clearing local state and
* scheduling the server draft delete in the background. Sets a sessionStorage
* sentinel that {@link SignedInDraftHydration} checks before fetching, so the
* brief race window does not hydrate from a not-yet-deleted server draft.
*
* Do **not** use for “Continue draft” — that path should load the server draft.
*/
export async function prepareFreshCreateFlowEntry(): Promise<void> {
export function prepareFreshCreateFlowEntrySync(
options: PrepareFreshCreateFlowEntryOptions = {},
): void {
const signedIn = options.signedIn === true;
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
if (isBackendSyncEnabled()) {
clearServerDraftWhenSignedIn(signedIn);
}
/**
* Awaitable variant kept for callers that genuinely need the DELETE to settle
* before continuing (e.g. tests, programmatic reset flows). Most click handlers
* should use {@link prepareFreshCreateFlowEntrySync} for instant navigation.
*/
export async function prepareFreshCreateFlowEntry(
options: PrepareFreshCreateFlowEntryOptions = {},
): Promise<void> {
const signedIn = options.signedIn === true;
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
if (!signedIn || !isBackendSyncEnabled()) return;
setFreshEntryPending();
try {
await deleteServerDraft();
} finally {
clearFreshEntryPending();
}
}
+25 -2
View File
@@ -1,8 +1,31 @@
import type { ReactNode } from "react";
import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()`
// transitively in `ConditionalNavigationClient`) — both are uncached, so it
// lives behind a `<Suspense>` boundary so the rest of the layout stays in the
// static shell while the session/pathname-aware nav streams in. The fallback
// is `null` because any non-null fallback would also need to live in the
// static shell, and the nav's chromeless decision depends on the pathname
// (e.g. `/create/*` and `/login` render no top-nav). Brief blank-nav while
// the dynamic island resolves is acceptable on signed-in product surfaces.
//
// Signed-in product surfaces (`/create/*`, `/login`) run without the marketing
// footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g.
// CreateFlow) is composed in nested layouts.
export default function AppLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>;
return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<Suspense fallback={null}>
<ConditionalNavigation />
</Suspense>
<main className="flex-1">{children}</main>
</AuthModalProvider>
</MessagesProvider>
);
}
+3 -5
View File
@@ -23,7 +23,7 @@ import {
import type { CreateFlowStep } from "../create/types";
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry";
import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import {
ProfilePageSignedOutView,
@@ -253,10 +253,8 @@ export default function ProfilePageClient() {
}, [draft, router]);
const handleStartNewCustomRule = useCallback(() => {
void (async () => {
await prepareFreshCreateFlowEntry();
router.push("/create");
})();
prepareFreshCreateFlowEntrySync({ signedIn: true });
router.push("/create/informational");
}, [router]);
const handleRequestDeleteDraft = useCallback(() => {
+10 -1
View File
@@ -1,10 +1,19 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
// Development-only previews (e.g. `/components-preview`) — no public chrome.
export default function DevLayout({ children }: { children: ReactNode }) {
if (process.env.NODE_ENV === "production") {
notFound();
}
return <main className="flex-1">{children}</main>;
return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<main className="flex-1">{children}</main>
</AuthModalProvider>
</MessagesProvider>
);
}
+1 -1
View File
@@ -126,7 +126,7 @@ export default async function BlogPostPage({ params }: PageProps) {
headline: post.frontmatter.title,
description: post.frontmatter.description,
author: {
"@type": "Person",
"@type": "Organization",
name: post.frontmatter.author,
},
publisher: {
+19 -5
View File
@@ -1,5 +1,9 @@
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import { Suspense, type ReactNode } from "react";
import MarketingNavigation from "../components/navigation/MarketingNavigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import marketingMessages from "../../messages/en/marketing";
// Site footer is part of the public marketing chrome only — not rendered for
// signed-in product surfaces, admin dashboards, or dev previews. See
@@ -13,9 +17,19 @@ const Footer = dynamic(() => import("../components/navigation/Footer"), {
export default function MarketingLayout({ children }: { children: ReactNode }) {
return (
<>
<main className="flex-1">{children}</main>
<Footer />
</>
<MessagesProvider messages={marketingMessages}>
<AuthModalProvider>
{/*
* MarketingNavigation reads `usePathname()` to decide chromeless paths
* (uncached data under `cacheComponents`). Suspense lets the static
* shell prerender; the nav streams in with the correct visibility.
*/}
<Suspense fallback={null}>
<MarketingNavigation />
</Suspense>
<main className="flex-1">{children}</main>
<Footer />
</AuthModalProvider>
</MessagesProvider>
);
}
+9 -13
View File
@@ -29,22 +29,18 @@ export default function LearnPage() {
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<ContentLockup {...contentLockupData} />
<div className="smd:hidden sm:pt-[var(--spacing-scale-024)] sm:pb-[var(--spacing-scale-024)] sm:px-[var(--spacing-scale-020)] space-y-[var(--spacing-scale-002)] sm:space-y-[var(--spacing-scale-008)]">
{/*
* Single responsive render: ContentThumbnailTemplate variant="responsive"
* uses <picture> to swap horizontal/vertical art at smd (530px). The
* container switches from a vertical flex stack (<smd) to a grid (≥smd),
* matching the prior twin-region layout without doubling the DOM.
*/}
<div className="flex flex-col space-y-[var(--spacing-scale-002)] sm:space-y-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-024)] sm:pb-[var(--spacing-scale-024)] smd:grid smd:grid-cols-2 smd:gap-[var(--spacing-scale-008)] smd:space-y-0 smd:px-[var(--spacing-scale-020)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-016)] md:px-[var(--spacing-scale-032)] xmd:grid-cols-3 xmd:gap-[var(--spacing-scale-012)] lg:grid-cols-3 lg:gap-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-064)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg2:grid-cols-4 lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:grid-cols-5 xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] [&>*]:min-w-0">
{allPosts.map((post) => (
<ContentThumbnailTemplate
key={`${post.slug}-horizontal`}
key={post.slug}
post={post}
variant="horizontal"
/>
))}
</div>
<div className="hidden smd:grid smd:grid-cols-2 xmd:grid-cols-3 lg:grid-cols-3 lg2:grid-cols-4 xl:grid-cols-5 smd:gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-016)] xmd:gap-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-016)] lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] smd:px-[var(--spacing-scale-020)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] [&>*]:min-w-0">
{allPosts.map((post) => (
<ContentThumbnailTemplate
key={`${post.slug}-vertical`}
post={post}
variant="vertical"
variant="responsive"
/>
))}
</div>
+14
View File
@@ -0,0 +1,14 @@
/**
* Lightweight skeleton shown while the next marketing route streams. Matches
* the page background so navigations feel instant on the user's phone instead
* of stalling on the previous page until RSC payload arrives.
*/
export default function MarketingRouteLoading() {
return (
<div
className="min-h-screen w-full bg-[var(--color-surface-default-primary)]"
aria-busy="true"
aria-live="polite"
/>
);
}
+1
View File
@@ -49,6 +49,7 @@ export default function Page() {
description: t("pages.home.heroBanner.description"),
ctaText: t("pages.home.heroBanner.ctaText"),
ctaHref: t("pages.home.heroBanner.ctaHref"),
imageAlt: t("heroBanner.imageAlt"),
};
const cardStepsData = {
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { prepareFreshCreateFlowEntrySync } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
import {
buildTemplateReviewHref,
TEMPLATES_FACET_RECOMMEND_QUERY,
@@ -102,13 +102,7 @@ function TemplatesGrid({
entries={entries}
onTemplateClick={(slug) => {
if (!fromFlow) {
void (async () => {
await prepareFreshCreateFlowEntry();
router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
);
})();
return;
prepareFreshCreateFlowEntrySync();
}
router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
+24 -25
View File
@@ -15,7 +15,10 @@ import TripleTextBlock from "../../components/type/TripleTextBlock";
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
import AskOrganizer from "../../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "../_components/MarketingRuleStackSection";
import { getAssetPath, vectorMarkPath } from "../../../lib/assetUtils";
import WorkerCoopMark from "../../../public/assets/vector/worker-coop.svg";
import MutualAidMark from "../../../public/assets/vector/mutual-aid.svg";
import OpenSourceMark from "../../../public/assets/vector/open-source.svg";
import DaoMark from "../../../public/assets/vector/dao.svg";
const RelatedArticles = dynamic(
() => import("../../components/sections/RelatedArticles"),
@@ -41,12 +44,12 @@ const CASE_STUDY_LINK_CLASS = [
"active:scale-[0.98]",
].join(" ");
/** Matches `pages.useCases.groups.items` order ↔ `public/assets/vector/*.svg`. */
const USE_CASES_GROUP_VECTOR_SLUGS = [
"worker-coop",
"mutual-aid",
"open-source",
"dao",
/** Matches `pages.useCases.groups.items` order ↔ inlined vector mark components. */
const USE_CASES_GROUP_MARKS = [
WorkerCoopMark,
MutualAidMark,
OpenSourceMark,
DaoMark,
] as const;
const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__";
@@ -77,24 +80,20 @@ export default function UseCasesPage() {
page.groups.items,
);
const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => ({
...item,
icon: (
/* eslint-disable-next-line @next/next/no-img-element -- small vector marks from `public/assets/vector` */
<img
alt=""
aria-hidden
className="block size-9 shrink-0 object-contain"
height={36}
src={getAssetPath(
vectorMarkPath(
USE_CASES_GROUP_VECTOR_SLUGS[index] ?? USE_CASES_GROUP_VECTOR_SLUGS[0],
),
)}
width={36}
/>
),
}));
const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => {
const Mark = USE_CASES_GROUP_MARKS[index] ?? USE_CASES_GROUP_MARKS[0];
return {
...item,
icon: (
<Mark
aria-hidden
className="block size-9 shrink-0"
width={36}
height={36}
/>
),
};
});
const askOrganizerData = {
title: page.askOrganizer.title,
+10 -3
View File
@@ -1,4 +1,7 @@
import type { ReactNode } from "react";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import marketingMessages from "../../messages/en/marketing";
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
export default function MarketingCaseStudyLayout({
@@ -7,8 +10,12 @@ export default function MarketingCaseStudyLayout({
children: ReactNode;
}) {
return (
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
{children}
</main>
<MessagesProvider messages={marketingMessages}>
<AuthModalProvider>
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
{children}
</main>
</AuthModalProvider>
</MessagesProvider>
);
}
+9 -1
View File
@@ -24,7 +24,15 @@ const Avatar = memo<AvatarProps>(
return (
/* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */
<img src={src} alt={alt} className={baseStyles} {...props} />
<img
src={src}
alt={alt}
className={baseStyles}
loading="eager"
decoding="async"
fetchPriority="high"
{...props}
/>
);
},
);
+1 -1
View File
@@ -156,7 +156,7 @@ const Button = memo<ButtonProps>(
// Note: State prop is informational for Figma alignment - actual state is handled by CSS pseudo-classes
// For now, we maintain existing behavior and state prop is for documentation/alignment purposes
const baseStyles = `inline-flex items-center justify-start box-border whitespace-nowrap shrink-0 ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${fontStyles[size]} transition-all duration-500 ease-in-out cursor-pointer ${variantStyles[variant]} ${outlineStyles}`;
const baseStyles = `inline-flex items-center justify-start box-border whitespace-nowrap shrink-0 touch-manipulation [-webkit-tap-highlight-color:transparent] ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${fontStyles[size]} transition-[transform,color,background-color,border-color,box-shadow,outline-color] duration-150 ease-out cursor-pointer ${variantStyles[variant]} ${outlineStyles}`;
const combinedStyles = `${baseStyles} ${className}`;
const sharedA11y = {
@@ -1,8 +1,10 @@
"use client";
import Image from "next/image";
import { memo } from "react";
import { caseStudyVisualPath, getAssetPath } from "../../../../lib/assetUtils";
import type { ComponentType, SVGProps } from "react";
import MutualAidArt from "../../../../public/assets/case-study/case-study-mutual-aid.svg";
import FoodNotBombsArt from "../../../../public/assets/case-study/case-study-food-not-bombs.svg";
import BoulderCountyStreetMedicsArt from "../../../../public/assets/case-study/case-study-boulder-county-street-medics.svg";
import type { CaseStudyProps } from "./CaseStudy.types";
const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
@@ -11,11 +13,23 @@ const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
rose: "bg-[var(--color-surface-invert-brand-red)]",
};
/** Default art per tile: Figma-exported SVG composites (305×305 incl. rounded bg). */
const SURFACE_ART: Record<CaseStudyProps["surface"], string> = {
lavender: getAssetPath(caseStudyVisualPath("lavender")),
neutral: getAssetPath(caseStudyVisualPath("neutral")),
rose: getAssetPath(caseStudyVisualPath("rose")),
/**
* Inline SVGR components avoid the network round-trip the prior `next/image`
* version required, so the illustration paints with the colored tile shell.
*/
const SURFACE_ART: Record<
CaseStudyProps["surface"],
ComponentType<SVGProps<SVGSVGElement>>
> = {
lavender: MutualAidArt,
neutral: FoodNotBombsArt,
rose: BoulderCountyStreetMedicsArt,
};
const SURFACE_ART_DATA_KEY: Record<CaseStudyProps["surface"], string> = {
lavender: "case-study-mutual-aid",
neutral: "case-study-food-not-bombs",
rose: "case-study-boulder-county-street-medics",
};
/** Figma: ~23px corner (“Card / CaseStudy” shells). */
@@ -27,23 +41,26 @@ function CaseStudyView({
visual,
className = "",
}: CaseStudyProps) {
const Art = SURFACE_ART[surface];
return (
<div
data-figma-node="21993-32352"
className={`relative flex h-[305px] w-[305px] shrink-0 overflow-hidden ${CASE_TILE_RADIUS_CLASS} ${SURFACE_CLASS[surface]} ${className}`.trim()}
className={`relative h-[305px] w-[305px] shrink-0 overflow-hidden ${CASE_TILE_RADIUS_CLASS} ${SURFACE_CLASS[surface]} ${className}`.trim()}
>
{visual ? (
<div className="flex size-full items-center justify-center p-2">{visual}</div>
) : (
<Image
src={SURFACE_ART[surface]}
alt={imageAlt}
width={305}
height={305}
unoptimized
className="pointer-events-none size-full select-none object-contain object-center"
draggable={false}
/>
<div className="absolute inset-0">
<Art
role="img"
aria-label={imageAlt}
data-case-study-art={SURFACE_ART_DATA_KEY[surface]}
width="100%"
height="100%"
className="pointer-events-none block select-none"
preserveAspectRatio="xMidYMid meet"
/>
</div>
)}
</div>
);
@@ -79,7 +79,6 @@ const MiniContainer = memo<MiniProps>(
return {
wrapperElement: "div" as const,
wrapperProps: {
...baseProps,
className: "block",
},
};
@@ -23,14 +23,13 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
}) => {
const variant = variantProp;
const sizing = sizingProp;
// Get article-specific background image from frontmatter
const getBackgroundImage = (
post: ContentThumbnailTemplateProps["post"],
variant: "vertical" | "horizontal",
orientation: "vertical" | "horizontal",
): string => {
if (post.frontmatter?.thumbnail) {
const imageName =
variant === "vertical"
orientation === "vertical"
? post.frontmatter.thumbnail.vertical
: post.frontmatter.thumbnail.horizontal;
@@ -47,12 +46,21 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
? slug
: contentCatalogSlugForFallback(slug);
return variant === "vertical"
return orientation === "vertical"
? contentBlogVerticalPath(resolvedSlug)
: contentBlogHorizontalPath(resolvedSlug);
};
const backgroundImage = getBackgroundImage(post, variant);
// For "responsive", emit both orientations so the <picture> source can
// swap at smd without a second card in the DOM.
const backgroundImage =
variant === "responsive"
? getBackgroundImage(post, "horizontal")
: getBackgroundImage(post, variant);
const backgroundImageSmd =
variant === "responsive"
? getBackgroundImage(post, "vertical")
: undefined;
return (
<ContentThumbnailTemplateView
@@ -61,6 +69,7 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
variant={variant}
sizing={sizing}
backgroundImage={backgroundImage}
backgroundImageSmd={backgroundImageSmd}
/>
);
},
@@ -1,6 +1,9 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
export type ContentThumbnailTemplateVariantValue =
| "vertical"
| "horizontal"
| "responsive";
export type ContentThumbnailTemplateSizingValue = "fluid" | "fixed";
@@ -8,7 +11,8 @@ export interface ContentThumbnailTemplateProps {
post: BlogPost;
className?: string;
/**
* Content thumbnail variant.
* vertical | horizontal — single layout. responsive — horizontal at <smd,
* vertical at ≥smd (Learn grid); single card, viewport-swapped via <picture>.
*/
variant?: ContentThumbnailTemplateVariantValue;
/**
@@ -21,7 +25,9 @@ export interface ContentThumbnailTemplateProps {
export interface ContentThumbnailTemplateViewProps {
post: BlogPost;
className: string;
variant: "vertical" | "horizontal";
variant: ContentThumbnailTemplateVariantValue;
sizing: ContentThumbnailTemplateSizingValue;
backgroundImage: string;
/** Wide-viewport image source for variant="responsive" (≥smd). */
backgroundImageSmd?: string;
}
@@ -9,7 +9,41 @@ function ContentThumbnailTemplateView({
variant,
sizing,
backgroundImage,
backgroundImageSmd,
}: ContentThumbnailTemplateViewProps) {
if (variant === "responsive") {
// Single card; <picture> swaps the orientation-specific image at smd
// (530px), aspect-ratio and content positioning switch via Tailwind.
return (
<Link
href={`/blog/${post.slug}`}
className={`group block w-full transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative aspect-[320/225.5] w-full overflow-hidden smd:aspect-[260/390]">
<div className="absolute inset-0 z-0">
<picture>
{backgroundImageSmd ? (
<source
media="(min-width: 530px)"
srcSet={backgroundImageSmd}
/>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt=""
className="pointer-events-none size-full object-cover"
/>
</picture>
</div>
<div className="absolute left-[4.375%] top-[6.099%] z-20 w-[71.875%] smd:left-[6.923%] smd:top-[4.615%] smd:w-[76.923%]">
<ContentContainer post={post} size="xs" />
</div>
</div>
</Link>
);
}
if (variant === "vertical") {
if (sizing === "fixed") {
return (
@@ -155,6 +155,7 @@ function ChipView({
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.target instanceof HTMLInputElement) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick(e as unknown as React.MouseEvent<HTMLButtonElement>);
@@ -73,7 +73,9 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
const sizeStyles =
inputSize === "small"
? {
input: "h-[32px] px-[10px] py-[6px] text-[14px]",
// 16px on narrow viewports prevents iOS Safari focus zoom (causes horizontal scroll).
input:
"h-[32px] px-[10px] py-[6px] text-[16px] md:text-[14px]",
label: "text-[12px] leading-[16px] font-medium",
container: "gap-[6px]",
radius: "var(--measures-radius-200,8px)",
@@ -14,6 +14,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
({ isOpen, onClose }) => {
const t = useTranslation("modals.askOrganizerInquiry");
const tLogin = useTranslation("pages.login");
const copy = useMemo(
() => ({
title: t("title"),
@@ -28,8 +29,9 @@ const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
successDescription: t("successDescription"),
ariaDialog: t("ariaDialog"),
honeypotLabel: t("honeypotLabel"),
backToHome: tLogin("backToHome"),
}),
[t],
[t, tLogin],
);
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
@@ -16,6 +16,7 @@ export interface AskOrganizerInquiryModalCopy {
successDescription: string;
ariaDialog: string;
honeypotLabel: string;
backToHome: string;
}
export interface AskOrganizerInquiryModalViewProps
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import Create from "../Create";
import TextInput from "../../controls/TextInput";
import TextArea from "../../controls/TextArea";
@@ -72,6 +73,15 @@ export function AskOrganizerInquiryModalView({
ariaLabel={copy.ariaDialog}
footerContent={footer}
footerClassName="!h-auto min-h-[112px] shrink-0 flex flex-col justify-end pb-8 pt-3 px-4"
belowCard={
<Link
href="/"
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
onClick={() => onClose()}
>
{copy.backToHome}
</Link>
}
>
{success ? (
<div className="flex flex-col gap-3 py-2">
@@ -36,6 +36,7 @@ const CreateContainer = memo<CreateProps>(
kebabTriggerAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
belowCard,
}) => {
const createRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
@@ -72,6 +73,7 @@ const CreateContainer = memo<CreateProps>(
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
kebabMenuAriaLabel={kebabMenuAriaLabel}
kebabMenuItems={kebabMenuItems}
belowCard={belowCard}
/>
);
},
@@ -43,6 +43,8 @@ export interface CreateProps {
kebabTriggerAriaLabel?: string;
kebabMenuAriaLabel?: string;
kebabMenuItems?: ModalHeaderMenuItem[];
/** Rendered below the dialog card on the backdrop (e.g. “Back to home”). */
belowCard?: React.ReactNode;
}
export interface CreateViewProps {
@@ -73,4 +75,5 @@ export interface CreateViewProps {
kebabTriggerAriaLabel?: string;
kebabMenuAriaLabel?: string;
kebabMenuItems?: ModalHeaderMenuItem[];
belowCard?: React.ReactNode;
}
@@ -34,6 +34,7 @@ export function CreateView({
kebabTriggerAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
belowCard,
}: CreateViewProps) {
return (
<CreateModalFrameView
@@ -45,6 +46,7 @@ export function CreateView({
ariaLabelledBy={ariaLabelledBy}
overlayRef={overlayRef}
dialogRef={createRef}
belowCard={belowCard}
>
<ModalHeader
onClose={onClose}
@@ -22,6 +22,8 @@ export type CreateModalFrameViewProps = {
overlayRef: RefObject<HTMLDivElement | null>;
dialogRef: RefObject<HTMLDivElement | null>;
children: ReactNode;
/** Rendered below the dialog card on the backdrop (e.g. “Back to home”). */
belowCard?: ReactNode;
};
/**
@@ -37,28 +39,34 @@ export function CreateModalFrameView({
overlayRef,
dialogRef,
children,
belowCard,
}: CreateModalFrameViewProps) {
if (!isOpen) return null;
const content = (
<>
<div
ref={overlayRef}
className={backdropOverlayClasses[backdropVariant]}
onClick={onOverlayClick}
aria-hidden="true"
/>
<div
ref={overlayRef}
className={`${backdropOverlayClasses[backdropVariant]} flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8`}
onClick={onOverlayClick}
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[90vh] flex min-h-0 flex-col overflow-hidden z-[9999] ${className}`}
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500,20px)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</>
{belowCard ? (
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
{belowCard}
</div>
) : null}
</div>
);
if (typeof window !== "undefined") {
+36 -17
View File
@@ -17,7 +17,7 @@ const Footer = memo(() => {
const tChrome = useTranslation("controlsChrome");
const linkFocusClass =
"hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity";
"touch-manipulation [-webkit-tap-highlight-color:transparent] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity duration-150 ease-out";
const bodyTextClass =
"text-[var(--color-content-default-primary)] font-inter text-base font-medium leading-5 tracking-[0%] lg:text-2xl lg:font-normal lg:leading-7";
@@ -25,10 +25,10 @@ const Footer = memo(() => {
/** Figma 18411:62925 (1024+): org name is one line, `w-full whitespace-nowrap`. */
const orgNameClass = `${bodyTextClass} lg:whitespace-nowrap`;
const primaryLinkClass = `text-[var(--color-content-default-primary)] font-inter text-base font-medium leading-5 tracking-[0%] ${linkFocusClass} p-2 -m-2 cursor-pointer lg:text-2xl lg:font-normal lg:leading-7`;
const primaryLinkClass = `inline-flex w-fit max-w-full shrink-0 whitespace-nowrap self-start md:self-end text-[var(--color-content-default-primary)] font-inter text-base font-medium leading-5 tracking-[0%] ${linkFocusClass} p-2 -m-2 cursor-pointer lg:text-2xl lg:font-normal lg:leading-7`;
/** Figma 18411:62944: 40px gaps, w-[396px] link block; `p-2` on links overruns 396px—tighten x at `md+` row. */
const legalLinkClass = `text-[var(--color-content-default-secondary)] font-inter text-sm font-normal leading-5 tracking-[0%] ${linkFocusClass} p-2 -m-2 cursor-pointer underline decoration-solid [text-decoration-skip-ink:none] md:px-0 md:py-1 md:mx-0 md:text-xs md:leading-4 md:whitespace-nowrap md:no-underline md:text-[var(--color-content-default-primary)] lg:text-sm lg:leading-5 lg:text-[var(--color-content-default-primary)]`;
const legalLinkClass = `inline-flex w-fit max-w-full self-start text-[var(--color-content-default-secondary)] font-inter text-sm font-normal leading-5 tracking-[0%] ${linkFocusClass} p-2 -m-2 cursor-pointer underline decoration-solid [text-decoration-skip-ink:none] md:self-auto md:px-0 md:py-1 md:mx-0 md:text-xs md:leading-4 md:whitespace-nowrap md:no-underline md:text-[var(--color-content-default-primary)] lg:text-sm lg:leading-5 lg:text-[var(--color-content-default-primary)]`;
// Schema markup for organization information
const schemaData = {
@@ -37,7 +37,11 @@ const Footer = memo(() => {
name: t("organization.name"),
email: t("organization.email"),
url: t("organization.url"),
sameAs: [t("social.bluesky.url"), t("social.gitlab.url")],
sameAs: [
t("social.bluesky.url"),
t("social.gitea.url"),
t("social.mastodon.url"),
],
};
return (
@@ -86,7 +90,7 @@ const Footer = memo(() => {
<div className={orgNameClass}>{t("organization.name")}</div>
<a
href={`mailto:${t("organization.email")}`}
className={`${bodyTextClass} ${linkFocusClass} p-2 -m-2 cursor-pointer`}
className={`inline-flex w-fit max-w-full ${bodyTextClass} ${linkFocusClass} p-2 -m-2 cursor-pointer`}
>
{t("organization.email")}
</a>
@@ -98,33 +102,48 @@ const Footer = memo(() => {
>
<a
href={t("social.bluesky.url")}
className={`group flex items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
className={`group inline-flex w-fit max-w-full items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
aria-label={t("social.bluesky.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social logo */}
<img
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
alt="Bluesky"
alt=""
width={24}
height={22}
className="h-[21px] w-[24px] flex-shrink-0 transition-transform group-hover:scale-110"
/>
<div className={bodyTextClass}>{t("social.bluesky.handle")}</div>
<div className={bodyTextClass}>{t("social.bluesky.label")}</div>
</a>
<a
href={t("social.gitlab.url")}
className={`group flex items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
aria-label={t("social.gitlab.ariaLabel")}
href={t("social.gitea.url")}
className={`group inline-flex w-fit max-w-full items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
aria-label={t("social.gitea.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
<img
src={getAssetPath(ASSETS.GITLAB_ICON)}
alt="GitLab"
src={getAssetPath(ASSETS.GITEA_ICON)}
alt=""
width={22}
height={22}
className="h-5 w-[22px] flex-shrink-0 grayscale transition-transform group-hover:scale-110"
/>
<div className={bodyTextClass}>{t("social.gitlab.handle")}</div>
<div className={bodyTextClass}>{t("social.gitea.label")}</div>
</a>
<a
href={t("social.mastodon.url")}
className={`group inline-flex w-fit max-w-full items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
aria-label={t("social.mastodon.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
<img
src={getAssetPath(ASSETS.MASTODON_LOGO)}
alt=""
width={22}
height={22}
className="h-5 w-[22px] flex-shrink-0 grayscale transition-transform group-hover:scale-110"
/>
<div className={bodyTextClass}>{t("social.mastodon.label")}</div>
</a>
</div>
</div>
@@ -139,19 +158,19 @@ const Footer = memo(() => {
>
<Link
href="/use-cases"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
className={`text-left ${primaryLinkClass} md:text-right`}
>
{t("navigation.useCases")}
</Link>
<Link
href="/learn"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
className={`text-left ${primaryLinkClass} md:text-right`}
>
{t("navigation.learn")}
</Link>
<Link
href="/about"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
className={`text-left ${primaryLinkClass} md:text-right`}
>
{t("navigation.about")}
</Link>
@@ -0,0 +1,27 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import { isChromelessNavigationPath } from "../../../lib/navigationChromelessPath";
import TopWithPathname from "./Top/TopWithPathname";
/**
* Marketing-only navigation. Skips the server-side `getNavAuthSignedIn()` call
* so marketing pages can render statically (no `force-dynamic`); `TopWithPathname`
* fetches `/api/auth/session` on mount and updates the header from "Log in" to
* "Profile" when the user is signed in. Brief mismatch is acceptable here —
* `(app)` / `(admin)` keep the server-rendered nav.
*/
const MarketingNavigation = memo(() => {
const pathname = usePathname();
if (isChromelessNavigationPath(pathname)) {
return null;
}
return <TopWithPathname initialSignedIn={false} />;
});
MarketingNavigation.displayName = "MarketingNavigation";
export default MarketingNavigation;
@@ -70,15 +70,15 @@ const MenuItemContainer = memo<MenuItemProps>(
"border border-[var(--color-border-default-brand-primary,#fdfaa8)] text-[var(--color-content-default-brand-primary,#fefcc9)] bg-transparent hover:bg-[var(--color-gray-800)]",
};
// State styles for Inverse mode (black text on yellow background)
// State styles for Inverse mode (black text on yellow HeaderTab / inverse surfaces)
const inverseModeStyles: Record<"default" | "hover" | "selected", string> =
{
default:
"bg-transparent text-[var(--color-content-inverse-primary,black)] hover:bg-[var(--color-surface-brand-accent,#4d4a00)] hover:text-[var(--color-content-inverse-primary,black)]",
"bg-transparent text-[var(--color-content-inverse-primary,black)] hover:bg-[var(--color-surface-inverse-brand-secondary)] hover:text-[var(--color-content-inverse-primary,black)]",
hover:
"bg-[var(--color-surface-brand-accent,#4d4a00)] text-[var(--color-content-inverse-primary,black)]",
"bg-[var(--color-surface-inverse-brand-secondary)] text-[var(--color-content-inverse-primary,black)]",
selected:
"border border-[var(--color-border-default-primary,#141414)] text-[var(--color-content-inverse-primary,black)] bg-transparent hover:bg-[var(--color-surface-brand-accent,#4d4a00)]",
"border border-[var(--color-border-default-primary,#141414)] text-[var(--color-content-inverse-primary,black)] bg-transparent hover:bg-[var(--color-surface-inverse-brand-secondary)]",
};
// Get state styles based on mode
@@ -13,7 +13,7 @@ import Button from "../../buttons/Button";
import AvatarContainer from "../../asset/AvatarContainer";
import Avatar from "../../asset/Avatar";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { TopView } from "./Top.view";
import type { TopProps, NavSize } from "./Top.types";
@@ -51,16 +51,15 @@ const TopContainer = memo<TopProps>(
/**
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
* this button is always clicked from outside the wizard. Clears anonymous
* `localStorage` and, when backend sync is on, deletes the server draft
* so signed-in users get the same fresh start as guests (see
* {@link prepareFreshCreateFlowEntry}).
* `localStorage` synchronously and, when backend sync is on, fires the
* server `DELETE /api/drafts/me` in the background. `SignedInDraftHydration`
* reads the `create:fresh-entry-pending` sentinel and waits before fetching
* (see {@link prepareFreshCreateFlowEntrySync}).
*/
const handleCreateRuleClick = useCallback(() => {
void (async () => {
await prepareFreshCreateFlowEntry();
router.push("/create");
})();
}, [router]);
prepareFreshCreateFlowEntrySync({ signedIn: loggedIn });
router.push("/create/informational");
}, [loggedIn, router]);
// Schema markup for site navigation
const schemaData = {
@@ -27,7 +27,6 @@ const FeatureGridContainer = memo<FeatureGridProps>(
panelContent: getAssetPath(featurePanelPath("support")),
...featurePanelLayout("support"),
ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"),
href: "#decision-making",
},
{
backgroundColor: "bg-[var(--color-surface-invert-brand-lime)]",
@@ -40,7 +39,6 @@ const FeatureGridContainer = memo<FeatureGridProps>(
panelContent: getAssetPath(featurePanelPath("exercises")),
...featurePanelLayout("exercises"),
ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"),
href: "#values-alignment",
},
{
backgroundColor: "bg-[var(--color-surface-invert-brand-rust)]",
@@ -53,7 +51,6 @@ const FeatureGridContainer = memo<FeatureGridProps>(
panelContent: getAssetPath(featurePanelPath("guidance")),
...featurePanelLayout("guidance"),
ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"),
href: "#membership-guidance",
},
{
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
@@ -66,7 +63,6 @@ const FeatureGridContainer = memo<FeatureGridProps>(
panelContent: getAssetPath(featurePanelPath("tools")),
...featurePanelLayout("tools"),
ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"),
href: "#conflict-resolution",
},
],
[t],
@@ -13,7 +13,6 @@ export interface Feature {
panelHeight: number;
panelImageClassName?: string;
ariaLabel: string;
href: string;
}
export interface FeatureGridViewProps extends FeatureGridProps {
@@ -26,7 +26,7 @@ function FeatureGridView({
>
<div
data-figma-node="18847-22410"
className="rounded-[var(--measures-radius-500,20px)] bg-[var(--color-surface-default-secondary)] px-[var(--spacing-scale-020)] py-[var(--spacing-scale-032)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2 md:px-[var(--spacing-scale-048)] md:pb-[var(--spacing-scale-048)] md:pt-[var(--spacing-scale-076)] lg:pb-[var(--spacing-scale-076)]"
className="rounded-[var(--measures-radius-500,20px)] bg-[var(--color-surface-default-secondary)] px-[var(--spacing-scale-020)] py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-048)] md:pb-[var(--spacing-scale-048)] md:pt-[var(--spacing-scale-076)] lg:pb-[var(--spacing-scale-076)]"
>
<div className="mx-auto w-full gap-[var(--spacing-scale-048)] [container-type:inline-size] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)]">
<div className="lg:min-w-0 lg:shrink">
@@ -52,8 +52,7 @@ function FeatureGridView({
panelHeight={feature.panelHeight}
panelImageClassName={feature.panelImageClassName}
ariaLabel={feature.ariaLabel}
href={feature.href}
featureGridShell
featureGridShell
/>
))}
</div>
@@ -1,28 +1,39 @@
"use client";
/**
* Figma: "Sections / Hero" (see registry)
*/
import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import Image from "next/image";
import ContentLockup from "../../type/ContentLockup";
import HeroDecor from "./HeroDecor";
import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
/**
* Intrinsic dimensions of `public/assets/marketing/hero-image.png` (2560×1600,
* 16:10). Passed to `next/image` to reserve aspect ratio + drive responsive
* srcset generation. Actual rendered size is governed by `sizes`.
*/
const HERO_IMAGE_WIDTH = 2560;
const HERO_IMAGE_HEIGHT = 1600;
interface HeroBannerProps {
title?: string;
subtitle?: string;
description?: string;
ctaText?: string;
ctaHref?: string;
imageAlt?: string;
}
const HeroBanner = memo<HeroBannerProps>(
({ title, subtitle, description, ctaText, ctaHref }) => {
const t = useTranslation();
const imageAlt = t("heroBanner.imageAlt");
({
title,
subtitle,
description,
ctaText,
ctaHref,
imageAlt = "Hero illustration",
}) => {
return (
<section className="bg-transparent px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]">
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
@@ -49,14 +60,15 @@ const HeroBanner = memo<HeroBannerProps>(
</div>
{/* Hero Image Container */}
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element -- dynamic path from getAssetPath */}
<img
<div className="relative z-10 flex w-full items-center justify-center overflow-hidden rounded-[8px] aspect-[16/10] md:flex-1">
<Image
src={getAssetPath(ASSETS.HERO_IMAGE)}
alt={imageAlt}
className="w-full h-auto"
loading="eager"
fetchPriority="high"
width={HERO_IMAGE_WIDTH}
height={HERO_IMAGE_HEIGHT}
priority
sizes="(min-width: 768px) 50vw, 100vw"
className="size-full object-contain"
/>
</div>
</div>
@@ -1,12 +1,23 @@
"use client";
import { memo } from "react";
import { memo, useEffect, useState } from "react";
interface HeroDecorProps {
className?: string;
}
const HeroDecor = memo<HeroDecorProps>(({ className = "" }) => {
const [grainEnabled, setGrainEnabled] = useState(false);
useEffect(() => {
// feTurbulence forces tiled rasterization that reads as top-down segments on
// first paint. Flat shapes render immediately; grain applies after paint.
const frame = requestAnimationFrame(() => {
setGrainEnabled(true);
});
return () => cancelAnimationFrame(frame);
}, []);
return (
<svg
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
@@ -59,7 +70,7 @@ const HeroDecor = memo<HeroDecorProps>(({ className = "" }) => {
</defs>
{/* apply filter only to the decoration paths */}
<g fill="currentColor" filter="url(#grain)">
<g fill="currentColor" filter={grainEnabled ? "url(#grain)" : undefined}>
<path d="M1441.54 226.758C1495.92 226.758 1540 320.385 1540 435.879C1540 551.373 1495.92 645 1441.54 645C1387.16 645 1343.08 551.373 1343.08 435.879C1343.08 320.385 1387.16 226.758 1441.54 226.758Z" />
<path d="M1441.54 226.758C1495.92 226.758 1540 320.385 1540 435.879C1540 551.373 1495.92 645 1441.54 645C1387.16 645 1343.08 551.373 1343.08 435.879C1343.08 320.385 1387.16 226.758 1441.54 226.758Z" />
<path d="M674.066 209.121C728.443 209.121 772.525 302.748 772.525 418.242C772.525 533.737 728.443 627.363 674.066 627.363C619.688 627.363 575.607 533.737 575.607 418.242C575.607 302.748 619.688 209.121 674.066 209.121Z" />
@@ -20,37 +20,31 @@ const LogoWallContainer = memo<LogoWallProps>(({ logos, className = "" }) => {
src: getAssetPath(partnerLogoPath("food-not-bombs")),
alt: t("partners.foodNotBombs"),
size: "h-11 lg:h-14 xl:h-[70px]",
order: "order-1 sm:order-4",
},
{
src: getAssetPath(partnerLogoPath("start-coop")),
alt: t("partners.startCoop"),
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
order: "order-2 sm:order-2",
},
{
src: getAssetPath(partnerLogoPath("metagov")),
alt: t("partners.metagov"),
size: "h-6 lg:h-8 xl:h-[41px]",
order: "order-3 sm:order-1",
},
{
src: getAssetPath(partnerLogoPath("open-civics")),
alt: t("partners.openCivics"),
size: "h-8 lg:h-10 xl:h-[50px]",
order: "order-4 sm:order-5 md:order-6",
},
{
src: getAssetPath(partnerLogoPath("mutual-aid-co")),
alt: t("partners.mutualAidCo"),
size: "h-11 lg:h-14 xl:h-[70px]",
order: "order-5 sm:order-6 md:order-5",
},
{
src: getAssetPath(partnerLogoPath("cu-boulder")),
alt: t("partners.cuBoulder"),
size: "h-10 lg:h-12 xl:h-[60px]",
order: "order-6 sm:order-3",
},
],
[t],
@@ -14,18 +14,12 @@ function LogoWallView({
className={`p-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-048)] xl:px-[160px] xl:py-[var(--spacing-scale-064)] ${className}`}
>
<div className="flex flex-col gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-024)] xl:gap-[var(--spacing-scale-032)]">
{/* Label */}
<p className="font-inter font-medium text-[10px] leading-[12px] xl:text-[14px] xl:leading-[12px] uppercase text-[var(--color-content-default-secondary)] text-center">
Trusted by leading cooperators
</p>
{/* Logo Grid Container */}
<div
className={`transition-opacity duration-500 ${
isVisible ? "opacity-60" : "opacity-0"
}`}
>
<div className="grid grid-cols-2 grid-rows-3 sm:grid-cols-3 sm:grid-rows-2 md:flex md:justify-between md:items-center gap-x-[var(--spacing-scale-032)] gap-y-[var(--spacing-scale-032)] sm:gap-y-[var(--spacing-scale-048)]">
<div className="grid grid-cols-2 grid-rows-3 sm:grid-cols-3 sm:grid-rows-2 md:flex md:flex-wrap md:justify-center md:items-center gap-x-[var(--spacing-scale-032)] gap-y-[var(--spacing-scale-032)] sm:gap-y-[var(--spacing-scale-048)]">
{displayLogos.map((logo, index) => (
<div
key={index}
@@ -21,7 +21,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
onError,
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Variant configurations
const variants: Record<string, VariantConfig> = {
@@ -97,16 +96,13 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
const quoteId = `${baseId}-content`;
const authorId = `${baseId}-author`;
// Error handling functions
const handleImageError = (error: unknown) => {
logger.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`,
error,
);
setImageError(true);
setImageLoading(false);
// Call error callback if provided
if (onError) {
onError({
type: "image_load_error",
@@ -118,11 +114,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
}
};
const handleImageLoad = () => {
setImageLoading(false);
setImageError(false);
};
// Validate required props
if (variantProp === "statement") {
if (!quote?.trim() || !quoteSecondary?.trim()) {
@@ -166,9 +157,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
authorId={authorId}
config={config}
imageError={imageError}
imageLoading={imageLoading}
currentAvatarSrc={currentAvatarSrc}
onImageLoad={handleImageLoad}
onImageError={handleImageError}
/>
);
@@ -52,8 +52,6 @@ export interface QuoteBlockViewProps {
authorId: string;
config: VariantConfig;
imageError: boolean;
imageLoading: boolean;
currentAvatarSrc: string;
onImageLoad: () => void;
onImageError: (_error: unknown) => void;
}
@@ -17,9 +17,7 @@ function QuoteBlockView({
authorId,
config,
imageError,
imageLoading,
currentAvatarSrc,
onImageLoad,
onImageError,
}: QuoteBlockViewProps) {
const t = useTranslation("quoteBlock");
@@ -89,7 +87,6 @@ function QuoteBlockView({
<div className={`flex flex-col ${config.gap} relative z-10`}>
<div className={`flex flex-col ${config.avatarGap}`}>
{/* Avatar with error handling */}
<div className="relative">
{!imageError ? (
<Image
@@ -97,26 +94,12 @@ function QuoteBlockView({
alt={avatarAlt}
width={64}
height={64}
className={`filter sepia ${
config.avatar
} transition-opacity duration-300 ${
imageLoading ? "opacity-0" : "opacity-100"
}`}
loading="lazy"
className={`filter sepia ${config.avatar}`}
loading="eager"
priority
onError={onImageError}
onLoad={onImageLoad}
/>
) : null}
{/* Loading state */}
{imageLoading && !imageError && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
/>
)}
{/* Error state - show initials */}
{imageError && (
) : (
<div
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
>
@@ -8,7 +8,7 @@ import { memo, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import { logger } from "../../../../lib/logger";
import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import {
fetchTemplates,
isTemplatesFetchAborted,
@@ -99,10 +99,8 @@ const RuleStackContainer = memo<RuleStackProps>(
logger.debug(`${slug} template clicked`);
// Marketing home “Popular templates”: same fresh start as Top “Create rule”
// (local + server draft when sync) so stale state cannot break template apply.
void (async () => {
await prepareFreshCreateFlowEntry();
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
})();
prepareFreshCreateFlowEntrySync();
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
};
return (
@@ -12,6 +12,21 @@ function columnUsesLargeBreakpointCopy(column: TripleTextBlockColumn): boolean {
return column.lgTitle !== undefined || column.lgDescription !== undefined;
}
function splitDescriptionParagraphs(description: string): {
primary: string;
secondary?: string;
} {
const parts = description.split(/\n\n+/).map((part) => part.trim()).filter(Boolean);
if (parts.length <= 1) {
return { primary: description };
}
return {
primary: parts[0] ?? description,
secondary: parts.slice(1).join("\n\n"),
};
}
function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn }) {
return (
<article className="flex w-full flex-col gap-[var(--spacing-scale-006)] md:gap-[var(--spacing-scale-008)] lg:gap-[var(--spacing-scale-004)] xl:gap-[var(--spacing-scale-008)]">
@@ -30,6 +45,22 @@ function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn })
);
}
function TripleTextStackedColumn({ column }: { column: TripleTextBlockColumn }) {
const { primary, secondary } = column.descriptionSecondary
? { primary: column.description, secondary: column.descriptionSecondary }
: splitDescriptionParagraphs(column.description);
return (
<TripleTextUseCasesColumn
column={{
...column,
description: primary,
descriptionSecondary: secondary,
}}
/>
);
}
function TripleTextBlockColumnLockup({
column,
layoutPreset,
@@ -38,7 +69,7 @@ function TripleTextBlockColumnLockup({
layoutPreset: "default" | "useCases";
}) {
if (layoutPreset === "useCases") {
return <TripleTextUseCasesColumn column={column} />;
return <TripleTextStackedColumn column={column} />;
}
const dual = columnUsesLargeBreakpointCopy(column);
@@ -47,24 +78,26 @@ function TripleTextBlockColumnLockup({
if (!dual) {
return (
<ContentLockup
variant="about"
alignment="left"
subtitle={column.title}
description={column.description}
/>
<>
<div className="lg:hidden">
<TripleTextStackedColumn column={column} />
</div>
<div className="hidden lg:block">
<ContentLockup
variant="about"
alignment="left"
subtitle={column.title}
description={column.description}
/>
</div>
</>
);
}
return (
<>
<div className="lg:hidden">
<ContentLockup
variant="about"
alignment="left"
subtitle={column.title}
description={column.description}
/>
<TripleTextStackedColumn column={column} />
</div>
<div className="hidden lg:block">
<ContentLockup
@@ -105,7 +138,7 @@ function TripleTextBlockView({
className={`bg-black py-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] ${
isUseCases
? "px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-096)] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] xl:px-[var(--spacing-scale-160)]"
: "px-[calc(var(--spacing-scale-032)+var(--spacing-scale-096))] md:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] xl:px-[calc(var(--spacing-scale-160)+var(--spacing-scale-096))]"
: "px-[var(--spacing-scale-032)] md:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] xl:px-[calc(var(--spacing-scale-160)+var(--spacing-scale-096))]"
} ${className}`.trim()}
>
<div
@@ -131,7 +164,7 @@ function TripleTextBlockView({
className={
isUseCases
? "flex w-full flex-col gap-[var(--spacing-scale-048)] lg:flex-row lg:items-start lg:gap-[var(--spacing-scale-032)]"
: "flex w-full flex-col gap-[var(--spacing-scale-032)] lg:flex-row lg:items-start lg:gap-[var(--spacing-scale-032)]"
: "flex w-full flex-col gap-[var(--spacing-scale-048)] lg:flex-row lg:items-start lg:gap-[var(--spacing-scale-032)]"
}
>
{columns.map((column, index) => (
+54 -15
View File
@@ -1,15 +1,19 @@
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { AuthModalProvider } from "./contexts/AuthModalContext";
import { MessagesProvider } from "./contexts/MessagesContext";
import messages from "../messages/en/index";
import { ASSETS, getAssetPath } from "../lib/assetUtils";
import "./globals.css";
import ConditionalNavigation from "./components/navigation/ConditionalNavigation";
/** Header reads `cr_session` via Server Components; must not use prerendered guest HTML. */
export const dynamic = "force-dynamic";
// `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx`
// (the only groups that read the session via `ConditionalNavigation`). Marketing
// renders a client-side `MarketingNavigation` so its HTML can be statically
// optimized — TTFB drops to CDN speed for guests.
//
// MessagesProvider + AuthModalProvider are mounted per route group (Phase 4b):
// `(marketing)` gets a trimmed slice without `create.*` (~41 KB gzipped saved
// per static page); `(app)`/`(admin)`/`(dev)` get the full tree. See
// `messages/en/marketing.ts` and `docs/perf/next16-eval.md`.
const inter = Inter({
subsets: ["latin"],
@@ -34,7 +38,9 @@ const spaceGrotesk = Space_Grotesk({
weight: ["400", "500", "700"],
variable: "--font-space-grotesk",
display: "swap",
preload: true,
// Below-the-fold (subtitle in `ContentLockup` only). Skipping preload keeps
// the marketing critical-path bytes for Inter + Bricolage.
preload: false,
fallback: ["system-ui", "arial"],
});
@@ -60,7 +66,27 @@ export const metadata: Metadata = {
},
metadataBase: new URL("https://communityrule.com"),
icons: {
icon: [{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" }],
icon: [
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
{ url: "/favicon.ico", sizes: "any" },
{
url: "/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
url: "/favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
apple: [
{
url: "/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
],
},
alternates: {
canonical: "/",
@@ -96,17 +122,30 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className="font-sans">
<head>
<link
rel="preload"
as="image"
href={getAssetPath(ASSETS.AVATAR_1)}
type="image/svg+xml"
/>
<link
rel="preload"
as="image"
href={getAssetPath(ASSETS.AVATAR_2)}
type="image/svg+xml"
/>
<link
rel="preload"
as="image"
href={getAssetPath(ASSETS.AVATAR_3)}
type="image/svg+xml"
/>
</head>
<body
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
>
<MessagesProvider messages={messages}>
<AuthModalProvider>
<div className="min-h-screen flex flex-col">
<ConditionalNavigation />
{children}
</div>
</AuthModalProvider>
</MessagesProvider>
<div className="min-h-screen flex flex-col">{children}</div>
</body>
</html>
);
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: "Your Article Title Here"
description: "A brief, compelling description of what this article covers"
author: "Author Name"
author: "CommunityRule"
date: "2025-01-15"
related: ["slug-of-related-article-1", "slug-of-related-article-2"]
---
@@ -1,7 +1,7 @@
---
title: "Avoiding Burnout: Sustainability in the Ruins"
description: "Building a practice of resistance that doesn't consume you"
author: "Author name"
author: "CommunityRule"
date: "2025-08-12"
related:
- "resolving-active-conflicts"
@@ -1,7 +1,7 @@
---
title: "Digital Mediation and the Death of Nuance"
description: "How corporate platforms undermine solidarity and what to build instead"
author: "Author name"
author: "CommunityRule"
date: "2025-08-18"
related:
- "operational-security-mutual-aid"
@@ -1,7 +1,7 @@
---
title: "How Chaos Concentrates Control"
description: "How to limit informal hierarchies inevitably emerging in horizontal groups"
author: "Author name"
author: "CommunityRule"
date: "2025-08-15"
related:
- "making-decisions-without-hierarchy"
@@ -1,7 +1,7 @@
---
title: "Integrating New Members Without Dilution"
description: "How to Bring New People In Without Everything Falling Apart"
author: "Author name"
author: "CommunityRule"
date: "2025-08-05"
related:
- "making-decisions-without-hierarchy"
@@ -1,7 +1,7 @@
---
title: "Knowledge Management and Institutional Amnesia"
description: "Preserving what we learn without surveillance infrastructure"
author: "Author name"
author: "CommunityRule"
date: "2025-08-20"
related:
- "integrating-new-members-without-dilution"
@@ -1,7 +1,7 @@
---
title: "Making decisions without hierarchy"
description: "A brief guide to collaborative nonhierarchical decision making"
author: "Author name"
author: "CommunityRule"
date: "2025-08-01"
related:
- "resolving-active-conflicts"
@@ -1,7 +1,7 @@
---
title: "Operational Security for Mutual Aid"
description: "Why protecting information isn't paranoia: it's care work in a hostile world"
author: "Author name"
author: "CommunityRule"
date: "2025-08-10"
related:
- "resolving-active-conflicts"
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: "Resolving Active Conflicts"
description: "Practical steps for resolving conflicts while maintaining trust, cooperation, and shared goals"
author: "Author name"
author: "CommunityRule"
date: "2025-04-15"
related:
- "operational-security-mutual-aid"
+1
View File
@@ -30,6 +30,7 @@ These will be deleted once the backend services are stood up:
- [guides/backend-linear-tickets.md](./guides/backend-linear-tickets.md)
- [guides/template-recommendation-matrix.md](./guides/template-recommendation-matrix.md)
- [guides/ops-backend-deploy.md](./guides/ops-backend-deploy.md) — technical deploy handoff + cutover plan (Cloudron, env vars, health checks, follow-up tickets).
- [guides/ops-runbook.md](./guides/ops-runbook.md) — steady-state operator runbook: deploy, rollback, restore drill, single-instance limits.
## Cursor rules
+15 -15
View File
@@ -632,9 +632,9 @@ _Section B — Final Review screen `+` button per category:_
**Depends on:** Tickets 18 complete enough to deploy a vertical slice.
**Server / admin:** Cloudron admin access on `my.medlab.host` granted. Scope of this ticket is the **handoff doc + cutover plan** — exactly what's in place, what the side-by-side cutover looks like, and what open product/infra questions remain. The steady-state operator runbook is split out into [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) (we write it after we've done the work).
**Server / admin:** Cloudron admin access on `my.medlab.host` granted. Scope of this ticket is the **handoff doc + cutover plan** — exactly what's in place, what the side-by-side cutover looks like, and closed product/infra decisions. The steady-state operator runbook is [`ops-runbook.md`](ops-runbook.md) ([CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) **Done**).
**Goal:** Short doc that captures (a) granted access + auto-injected vs. manually-set env vars + platform settings, (b) the side-by-side → apex cutover plan with the legacy `communityrule.info` service, and (c) the remaining open questions (apex vs. permanent-subdomain final URL, legacy `rules` data communication, container registry choice).
**Goal:** Short doc that captures (a) granted access + auto-injected vs. manually-set env vars + platform settings, (b) the side-by-side → apex cutover plan with the legacy `communityrule.info` service, and (c) closed product/infra decisions (final URL, legacy rules archive, container registry).
**Platform context:** Target is **Cloudron at MEDLab** (`my.medlab.host`). The legacy `communityrule.info` is a single Cloudron **LAMP** app (`lamp.cloudronapp.php74@5.1.2`, 512 MiB at apex) hosting **three things stuffed into one container** under `/app/data/public/`: the static marketing site, the Express/MySQL backend at [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (kept alive by a 30-min `run.sh` watchdog on port 3000; MySQL is the LAMP package's bundled MySQL, not a Cloudron addon), and the Flask chatbot at [`CommunityRule/CommunityRuleChatBot`](https://git.medlab.host/CommunityRule/CommunityRuleChatBot) (currently crash-looping with `ModuleNotFoundError`, last touched May 2024). New app is a properly packaged Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons) and replaces all three — **no data migration**. Cloudron's container supervisor replaces the watchdog.
@@ -646,7 +646,7 @@ _Section B — Final Review screen `+` button per category:_
- **§3 Env vars** split into Cloudron auto-injected (`CLOUDRON_POSTGRESQL_URL`, `CLOUDRON_MAIL_SMTP_*`) vs. manually-set (`SESSION_SECRET`, `SMTP_FROM`, `NEXT_PUBLIC_ENABLE_BACKEND_SYNC`). Notes that addons are manifest-declared, not platform-enabled, and that platform mail is SES-relayed on `communityrule.info` with custom-from allowed.
- **§4 Platform settings** (`httpPort: 3000`, `healthCheckPath: /api/health`, 512 MiB to start, automatic backups already on).
- **§5 Cutover plan** — staging at `staging.communityrule.info`, soft-launch, apex cutover at scheduled low-traffic window (~515 min downtime).
- **§6 Open questions** — apex vs. permanent subdomain final URL; legacy `rules` data communication; container registry choice.
- **§6 Decisions** — final URL (`communityrule.info` apex); legacy `rules` export to Gitea archive (§6.1); container registry (Gitea, done).
- **§7 Old vs new deltas** (LAMP-package detail, watchdog, OTP→magic link, sender, API surface, chatbot).
- **§8 Follow-up tickets** (the six tickets below).
2. Cross-links: [`docs/guides/backend-roadmap.md`](backend-roadmap.md) §11 (environments — names Cloudron at MEDLab) and §8 (migrations policy — never rewrite applied migrations).
@@ -656,7 +656,7 @@ _Section B — Final Review screen `+` button per category:_
- [x] Admin handoff covers exactly the access that was needed (most self-serve via Cloudron admin login).
- [x] Cutover plan is side-by-side and explicitly avoids in-place apex replacement.
- [x] Six follow-up tickets enumerated and linked, with CR-99 + CR-101 scope corrected to reflect that legacy is one LAMP slot containing marketing + backend + chatbot (all retire together).
- [x] Open product/infra questions surfaced rather than assumed.
- [x] Closed product/infra decisions documented (§6 + §6.1).
**Files:** [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md), [`docs/guides/backend-roadmap.md`](backend-roadmap.md), [`docs/README.md`](../README.md), [`CONTRIBUTING.md`](../../CONTRIBUTING.md).
@@ -672,9 +672,9 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
| 2 | [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) | `[Backend] Container image registry: choose, build, push` | **Done** — first image `0.1.0` verified |
| 3 | [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) | `[Backend] Cloudron staging install + smoke` | Cloudron CLI token (§2) — **next** |
| 4 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | `[Backend] Cloudron production install + apex cutover` | CR-98 green for the agreed overlap window |
| 5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | `[Backend] Steady-state operator runbook` | CR-98 (write what we actually did) |
| 5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | `[Backend] Steady-state operator runbook` | **Done** — [ops-runbook.md](ops-runbook.md) |
| 6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | `[Backend] Decommission legacy CommunityRule LAMP app` | CR-99 + sign-off window |
| 7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | `[Backend] Decide fate of legacy rules table (read-only export?)` | must resolve before CR-99 maintenance window |
| 7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | `[Backend] Legacy rules archive export` | execute during CR-99 window (§6.1) |
### PR plan (CR-96 CR-102)
@@ -684,16 +684,16 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
| ----- | ------ | ---------------- | ---- | ------ | ---------- |
| 1 | [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) | `adilallo/Backend/BridgeCloudronEnv`*[Backend] Cloudron-native environment variables* | repo | **Done** | — |
| 2 | [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) | Container registry packaging + `docker-release.sh` | repo | **Done** | — |
| — | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | TBD — optional repo PR if export tooling/docs needed | product / repo | **Parallel** | row count from legacy MySQL (preCR-99 backup) |
| — | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | — (ops during CR-99; [ops-backend-deploy.md §6.1](ops-backend-deploy.md#61-legacy-rules-archive-cr-102)) | ops | **Parallel** | CR-99 window |
| 3 | [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) | — (ops checklist; [ops-backend-deploy.md §10](ops-backend-deploy.md#10-staging-install--smoke-cr-98)) | ops | **Next** | Cloudron CLI token only |
| 4 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | TBD — `docs/guides/ops-runbook.md` | docs | Backlog | CR-98 (write what we actually did) |
| 5 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | — (ops; maintenance window) | ops | Backlog | CR-98 green + CR-102 resolved |
| 4 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | [`ops-runbook.md`](ops-runbook.md) | docs | **Done** | — |
| 5 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | — (ops; maintenance window + §6.1 export) | ops | Backlog | CR-98 green |
| 6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | — (ops; uninstall LAMP slot) | ops | Backlog | CR-99 + sign-off window |
**What's next:** **CR-98** — staging install + smoke at `staging.communityrule.info`
([ops-backend-deploy.md §10](ops-backend-deploy.md#10-staging-install--smoke-cr-98)).
Start **CR-102** product decision in parallel so it is resolved before the CR-99
cutover window.
**CR-102** (legacy rules Gitea export) runs during the CR-99 cutover window
([§6.1](ops-backend-deploy.md#61-legacy-rules-archive-cr-102)).
**Per-ticket detail:**
@@ -705,7 +705,7 @@ cutover window.
- **Configure:** `SESSION_SECRET`, `SMTP_FROM`, `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`, `UPLOAD_ROOT=/app/data/uploads`.
- **Acceptance:** `GET /api/health``{"ok":true,"database":"connected"}`; magic-link sign-in end-to-end; publish a rule succeeds.
4. **Cloudron production install + DNS cutover.** Acceptance: production subdomain resolves to the new app; old subdomain still works during overlap; sign-in + publish succeed against production; backups confirmed.
5. **Steady-state operator runbook.** Lives at `docs/guides/ops-runbook.md` (sibling to the handoff). Covers deploy a new version, rollback, restore drill cadence, multi-instance limitations from [`backend-roadmap.md`](backend-roadmap.md) §5/§7. Acceptance: a fresh reader can deploy + roll back using only this doc.
5. **Steady-state operator runbook.** **Done** — [`docs/guides/ops-runbook.md`](ops-runbook.md). Covers deploy, rollback, restore drill, single-instance limits.
6. **Decommission legacy Express/MySQL backend.** Acceptance: old Cloudron app stopped + uninstalled; old MySQL addon backed up once and removed; legacy Gitea repo README updated to point at this app. Priority: Low.
---
@@ -848,10 +848,10 @@ Tickets **1011** can be deferred without blocking the core “auth + drafts +
| 12.1 | [CR-96](https://linear.app/community-rule/issue/CR-96/backend-bridge-cloudron-env-vars-to-canonical-names) | Cloudron-native env vars | **Done** |
| 12.2 | [CR-97](https://linear.app/community-rule/issue/CR-97/backend-container-image-registry-choose-build-push) | Container image registry + CI | **Done** — image `0.1.0` |
| 12.3 | [CR-98](https://linear.app/community-rule/issue/CR-98/backend-cloudron-staging-install-smoke) | Cloudron staging install + smoke | **Next** — [ops-backend-deploy.md §10](ops-backend-deploy.md#10-staging-install--smoke-cr-98) |
| 12.4 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | Production install + apex cutover | Ops — after CR-98 + CR-102 |
| 12.5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | Steady-state operator runbook | Docs PR — after CR-98 |
| 12.4 | [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover) | Production install + apex cutover | Ops — after CR-98; includes §6.1 export |
| 12.5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | Steady-state operator runbook | **Done** — [ops-runbook.md](ops-runbook.md) |
| 12.6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app) | Decommission legacy LAMP app | Ops — after CR-99 + sign-off |
| 12.7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | Legacy `rules` table fate / export | **Parallel** — before CR-99 |
| 12.7 | [CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export) | Legacy rules Gitea archive export | Ops — during CR-99 window (§6.1) |
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging | — |
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup **Done** | — |
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) | — |
+1 -1
View File
@@ -221,7 +221,7 @@ npm run dev
**Optional QA:** Run automated tests against an **ephemeral** database in CI instead of maintaining a fourth long-lived server.
**Target platform:** **Cloudron at MEDLab** — same host as the legacy [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (Express + MySQL). The new app is packaged as a proper Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons). Cloudron's container supervisor replaces the legacy 30-min `run.sh` watchdog. Admin handoff (access, env vars, platform settings, open decisions): [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md). The app reads Cloudron-injected `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*` via [`lib/server/env.ts`](../../lib/server/env.ts) (CR-96).
**Target platform:** **Cloudron at MEDLab** — same host as the legacy [`CommunityRule/CommunityRuleBackend`](https://git.medlab.host/CommunityRule/CommunityRuleBackend) (Express + MySQL). The new app is packaged as a proper Cloudron app (Docker image + `CloudronManifest.json`, **postgresql + sendmail + localstorage** addons). Cloudron's container supervisor replaces the legacy 30-min `run.sh` watchdog. First-time install and cutover: [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md). Steady-state deploy, rollback, and restore drill: [`docs/guides/ops-runbook.md`](ops-runbook.md). The app reads Cloudron-injected `CLOUDRON_POSTGRESQL_URL` and `CLOUDRON_MAIL_SMTP_*` via [`lib/server/env.ts`](../../lib/server/env.ts) (CR-96).
**Admin / infra (coordinate with whoever runs the server):**
+64 -15
View File
@@ -131,11 +131,13 @@ apex.
only step with brief downtime (~515 min). Sequence:
1. Take one final manual backup of the legacy LAMP app (Cloudron
*Backups* tab → *Backup now*).
2. `cloudron uninstall` the legacy app at `communityrule.info`.
3. `cloudron configure --location communityrule.info` to move the
2. Export legacy `rules` + `version_history` to the Gitea archive
per [§6.1](#61-legacy-rules-archive-cr-102).
3. `cloudron uninstall` the legacy app at `communityrule.info`.
4. `cloudron configure --location communityrule.info` to move the
validated staging install to the apex (or `cloudron install`
fresh at apex if cleaner).
4. Re-run `prisma migrate deploy`, re-set production env vars if
5. Re-run `prisma migrate deploy`, re-set production env vars if
not preserved by the move, smoke again.
4. **Decommission** — see [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-expressmysql-backend).
Hold the final LAMP backup ≥ 90 days for safety.
@@ -155,11 +157,11 @@ Product decisions (closed):
1. **Final URL — `communityrule.info` apex.** New app fully replaces
the legacy site, including the marketing surface. Brief cutover
downtime (~515 min) is accepted.
2. **Legacy `rules` data — not migrated.** No data moves into the new
app's Postgres. A pre-cutover **read-only export** of the
`rules` + `version_history` MySQL tables is under consideration;
approach depends on the actual row count, which we'll pull as
part of the CR-99 pre-cutover backup. Tracked in
2. **Legacy `rules` data — not migrated; exported to Gitea.** No data
moves into the new app's Postgres. Before CR-99 uninstalls the
legacy MySQL, operators export the `rules` + `version_history`
tables to a new read-only Gitea repo on `git.medlab.host` (see
[§6.1](#61-legacy-rules-archive-cr-102)). Tracked in
[CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export).
Infra decision closed:
@@ -184,6 +186,50 @@ Infra decision closed:
Container Registry** app and re-tag against its hostname; no other changes
required.
### 6.1 Legacy rules archive (CR-102)
The legacy Express backend stores published rules in bundled MySQL
tables `rules` and `version_history` (soft-delete via a `deleted`
column). These do not map to the new app's Postgres schema and are
**not imported**. Instead, a one-time export preserves the library for
posterity and operator lookup.
**Archive repo:** create
[`CommunityRule/legacy-rules-archive`](https://git.medlab.host/CommunityRule/legacy-rules-archive)
on `git.medlab.host` (same org as the other CommunityRule repos).
Mark the repo **archived** after the cutover push.
**When:** during the [CR-99](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover)
maintenance window — after the final Cloudron backup (§5 phase 3 step
1) and **before** `cloudron uninstall` (step 3).
**Export steps (operator):**
1. From the legacy LAMP container or a restored backup, pull row counts
for `rules` and `version_history` (include non-deleted vs soft-deleted
if useful for the README summary).
2. `mysqldump` both tables to SQL files (`rules.sql`,
`version_history.sql`).
3. Derive human-readable exports (JSON and/or CSV) from the dump for
anyone browsing the archive without MySQL tooling.
4. Commit artifacts + a `README.md` to the archive repo. The README
should record:
- cutover date;
- row counts and a brief activity summary;
- a short field glossary (`deleted`, version rows, etc.);
- a pointer to the new app at `communityrule.info`.
5. Tag the commit (e.g. `legacy-rules-YYYY-MM-DD`) and archive the
Gitea repo.
**Safety net:** the final Cloudron LAMP backup is retained ≥ 90 days
([CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app))
for operator recovery if manual lookup from the export is ever needed.
**User discoverability:** link to the archive repo (or a release
download) from the new app — footer, help page, or a static
`/legacy-archive` page — so users looking for pre-cutover rules can
find it without knowing Gitea exists.
## 7. Old vs new deltas
So nothing surprises anyone at cutover:
@@ -231,19 +277,20 @@ All filed in Linear, titled `[Backend] …`, assigned to me, in the
4. [**CR-99**](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover)
— `[Backend] Cloudron production install + apex cutover`.
Side-by-side cutover at scheduled low-traffic window per §5.
Blocked by CR-98 green + CR-102 resolved.
Blocked by CR-98 green. Includes legacy rules export (§6.1) before
uninstall.
5. [**CR-100**](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook)
— `[Backend] Steady-state operator runbook`. Blocked by CR-98
(write what we actually did).
— `[Backend] Steady-state operator runbook` (**Done** —
[`ops-runbook.md`](ops-runbook.md)).
6. [**CR-101**](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app)
— `[Backend] Decommission legacy CommunityRule LAMP app`.
Uninstall the entire LAMP slot (marketing + Express backend +
chatbot in one go); preserve final backup ≥ 90 days. Blocked by
CR-99 + sign-off window. Priority: Low.
7. [**CR-102**](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export)
— `[Backend] Decide fate of legacy rules table (read-only export?)`.
Count rows + decide whether to publish a static archive before
CR-99 uninstalls the legacy MySQL. Priority: Low.
— `[Backend] Legacy rules archive export`. Decision: export to Gitea
(§6.1). Execute during the CR-99 maintenance window before
uninstall. Priority: Low.
## 9. Build and push image workflow
@@ -440,10 +487,12 @@ apex cutover.
The app uses an **in-memory** rate limiter in [`lib/server/rateLimit.ts`](../../lib/server/rateLimit.ts) (magic-link requests, organizer inquiry, etc.). This is sufficient for the current **single Cloudron container** per environment.
**Before horizontal scale-out** (multiple app instances behind a load balancer), replace or back the limiter with a shared store (e.g. Redis) so per-IP / per-user windows apply across instances. Until then, document expected limits in the steady-state runbook ([CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook)).
**Before horizontal scale-out** (multiple app instances behind a load balancer), replace or back the limiter with a shared store (e.g. Redis) so per-IP / per-user windows apply across instances. Until then, see [`ops-runbook.md` §6](ops-runbook.md#6-single-instance-limitations).
## 12. Related docs
- [`docs/guides/ops-runbook.md`](ops-runbook.md) — steady-state deploy,
rollback, restore drill, single-instance limits ([CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook)).
- [`docs/guides/backend-roadmap.md`](backend-roadmap.md) §11
(environments) and §8 (Prisma migrations policy).
- [`docs/guides/backend-linear-tickets.md`](backend-linear-tickets.md)
+274
View File
@@ -0,0 +1,274 @@
# Steady-state operator runbook
Day-to-day deploy, rollback, and recovery for CommunityRule on MEDLab
Cloudron. Assumes staging or production is already installed and smoke-tested.
> **First-time install, apex cutover, and legacy decommission** live in
> [`ops-backend-deploy.md`](ops-backend-deploy.md). Use this doc once an
> environment is already running.
## 1. Quick reference
| Item | Value |
| ---- | ----- |
| Cloudron dashboard | `https://my.medlab.host` |
| Cloudron CLI login | `cloudron login my.medlab.host` |
| Staging app | `staging.communityrule.info` |
| Production app | `communityrule.info` (after apex cutover) |
| Container image | `git.medlab.host/communityrule/community-rule:<tag>` |
| Health check | `GET /api/health``200 {"ok":true,"database":"connected"}` |
| Manifest version | [`CloudronManifest.json`](../../CloudronManifest.json) `version` field (must increase for each release) |
| Current manifest | `0.1.8` at time of writing — always read the file before deploying |
Replace `<app>` below with the Cloudron location (`staging.communityrule.info`
or `communityrule.info`).
## 2. Prerequisites (one-time per operator)
1. **Cloudron access** — admin login on `my.medlab.host` and a CLI API token
(*Profile → API Tokens* on the dashboard). Save the token in 1Password.
2. **Cloudron CLI** — logged in:
```bash
cloudron login my.medlab.host
```
3. **Docker + buildx** — Docker Desktop or equivalent with `docker buildx`.
4. **Gitea registry auth** — personal access token on `git.medlab.host` with
`read:package` + `write:package`; then:
```bash
docker login git.medlab.host
```
5. **Repo checkout** — clone
[`CommunityRule/community-rule`](https://git.medlab.host/CommunityRule/community-rule)
and work from a clean commit that matches the release you intend to ship.
Images are **`linux/amd64` only** (Cloudron host is x86_64). On Apple
Silicon, the release script still builds amd64 via buildx; a bare
`docker pull` without `--platform linux/amd64` failing on arm64 is expected.
## 3. Deploy a new version
Typical release flow: bump manifest → build/push image → `cloudron update`
→ smoke.
### 3.1 Build and push
1. Check out the commit to release (`main` or a release branch).
2. **Bump** [`CloudronManifest.json`](../../CloudronManifest.json) `version`
(e.g. `0.1.8``0.1.9`). Cloudron requires the manifest version to
**increase** for `cloudron update --image` to be accepted.
3. From the repo root, build and push (tag should match the manifest version
for sanity):
```bash
TAG=0.1.9 ./scripts/docker-release.sh
# equivalent:
TAG=0.1.9 npm run docker:release
```
Omit `TAG=` to push `git rev-parse --short HEAD` instead — only do that
for ad-hoc staging experiments; production releases should use semver tags
aligned with the manifest.
4. **Verify anonymous pull** (simulates Cloudron):
```bash
docker logout git.medlab.host
docker pull --platform linux/amd64 \
git.medlab.host/communityrule/community-rule:0.1.9
```
5. **Commit the manifest bump** in git alongside the code that shipped in
this build.
Registry details and one-time Gitea setup: [`ops-backend-deploy.md` §9](ops-backend-deploy.md#9-build-and-push-image-workflow).
### 3.2 Update Cloudron
```bash
cloudron update --app staging.communityrule.info \
--image git.medlab.host/communityrule/community-rule:0.1.9
```
Use `communityrule.info` for production. Cloudron pulls the image (no registry
credentials on the host), restarts the container, and runs
[`scripts/start.sh`](../../scripts/start.sh), which:
1. `chown`s `/app/data` (localstorage mount),
2. runs **`prisma migrate deploy`**,
3. execs the Next.js standalone server.
Watch the app **Logs** tab in the Cloudron dashboard for a clean migration and
`Listening on port 3000`.
### 3.3 Migrations
**Normal case:** migrations apply automatically on container start — no
separate step.
**Manual re-run** (only if debugging a failed deploy or verifying before
traffic):
```bash
cloudron exec --app staging.communityrule.info -- npm run db:deploy
```
(`npm run db:deploy``prisma migrate deploy`.)
**Policy:** never run `prisma migrate reset` against staging or production.
Never edit migration files already applied to a shared database. Fix schema
drift by adding a **new** migration locally (`prisma migrate dev`) and
deploying a new image. See [`backend-roadmap.md` §8](backend-roadmap.md#8-prisma-migrations-policy).
### 3.4 Seed data (not every deploy)
Template + facet seed (`MethodFacet` rows for create-flow “Recommended” tags)
is **not** applied at boot. Run once per environment after first install, or
when recommendations return all-zero scores:
```bash
cloudron exec --app staging.communityrule.info -- \
node prisma/seed.bundle.cjs
```
Re-running is safe (idempotent upserts). JSON lives at `/app/seed-data/` in
the image — not under `/app/data` (Cloudron localstorage overwrites that
mount).
### 3.5 Smoke after deploy
**Automated** (from your laptop, repo root):
```bash
./scripts/staging-smoke.sh staging.communityrule.info
# production:
./scripts/staging-smoke.sh communityrule.info
# optional — exercises magic-link request (check inbox manually):
EMAIL=you@example.com ./scripts/staging-smoke.sh staging.communityrule.info
```
**Manual** (still required for full acceptance):
- Click a magic link → signed in → `GET /api/auth/session` returns a user.
- Publish a rule end-to-end → public detail page loads.
- Optional: Save & Exit draft sync; upload with `UPLOAD_ROOT` set.
Full checklist and failure table: [`ops-backend-deploy.md` §10](ops-backend-deploy.md).
## 4. Roll back (code-only)
To revert application code without touching the database:
```bash
cloudron update --app staging.communityrule.info \
--image git.medlab.host/communityrule/community-rule:<previous-tag>
```
Pick a tag you know was healthy (previous manifest version or git tag recorded
at last good deploy).
**Database implications:**
- Rolling back the **image** does **not** undo migrations already applied.
- If the bad release added a migration, rolling back to an older image may
leave the DB schema **ahead** of what that code expects — usually safe if
the migration was additive (new nullable columns, new tables).
- If the bad release broke because of a **destructive or incompatible**
migration, do **not** reset production. Restore from a Cloudron backup
(§5) or fix forward with a corrective migration.
**Never** `prisma migrate reset` on staging or production.
## 5. Restore drill (quarterly)
Verify Cloudron backups are restorable without touching the live app.
**Cadence:** at least once per quarter, or after any backup-policy change.
**Steps:**
1. In the Cloudron dashboard, pick a recent automatic backup of
`<app>` (*Backups* tab).
2. **Restore to a scratch location** — e.g.
`restore-drill-YYYYMMDD.communityrule.info` — not over the live app.
3. After restore completes, confirm the container starts and migrations are
current:
```bash
curl -sS "https://restore-drill-YYYYMMDD.communityrule.info/api/health"
```
Expect `200` with `"database":"connected"`.
4. Optional: `cloudron exec --app restore-drill-YYYYMMDD.communityrule.info -- npm run db:deploy`
if logs show pending migrations on an older snapshot.
5. Run `./scripts/staging-smoke.sh restore-drill-YYYYMMDD.communityrule.info`.
6. **Uninstall** the scratch app when done.
Record the drill date and outcome in your ops notes. Cloudron retains
automatic backups per platform defaults; confirm retention in the dashboard.
## 6. Single-instance limitations
The current Cloudron deploy runs **one container per environment**. Do not
scale to multiple app instances without addressing these per-process limits:
### 6.1 In-memory rate limiter
[`lib/server/rateLimit.ts`](../../lib/server/rateLimit.ts) stores windows in
process memory. Limits apply **per container**, not globally across instances.
| Route / action | Key | Min interval |
| -------------- | --- | ------------ |
| Magic-link request | per email | 60 s |
| Magic-link request | per IP | 20 s |
| Email change request | per email / IP / user | 60 s |
| Organizer inquiry | per email / IP | 60 s / 20 s |
| Publish with stakeholder invites | per IP | 60 s |
| Stakeholder add / resend | per IP / invite | 60 s |
| File upload | per user | 5 s |
Before horizontal scale-out, replace with a shared store (e.g. Redis) or edge
rate limits. See [`backend-roadmap.md` §5](backend-roadmap.md#5-session-and-authentication-v1).
### 6.2 Web vitals storage
Production defaults to **`external`** mode: vitals are structured log lines, not
written to Postgres or local files. Setting `WEB_VITALS_STORAGE=local` uses a
**per-process** file store under `.next/web-vitals` — suitable for dev/admin
only, not multi-instance. See [`backend-roadmap.md` §7](backend-roadmap.md#7-api-responses-errors-and-observability).
## 7. Environment variables (steady-state)
Cloudron **auto-injects** addon vars (`CLOUDRON_POSTGRESQL_URL`,
`CLOUDRON_MAIL_SMTP_*`). Operators set these manually once per app; they
persist across image updates unless changed:
| Variable | Purpose |
| -------- | ------- |
| `SESSION_SECRET` | Session cookie signing (≥ 16 chars). Rotating logs everyone out. |
| `SMTP_FROM` | Visible From on sign-in emails (e.g. `Community Rule <hello@communityrule.info>`). |
| `NEXT_PUBLIC_ENABLE_BACKEND_SYNC` | `true` in staging/production — Postgres draft persistence. |
| `UPLOAD_ROOT` | `/app/data/uploads` on Cloudron — required for file uploads. |
Full detail: [`ops-backend-deploy.md` §3](ops-backend-deploy.md#3-environment-variables).
## 8. Troubleshooting
| Symptom | Likely cause | Action |
| ------- | ------------ | ------ |
| Image pull error on update | Private repo, wrong tag, or amd64 manifest missing | Confirm repo is public; verify pull with `--platform linux/amd64` (§3.1) |
| Health `503` / `database: disconnected` | Postgres addon or `CLOUDRON_POSTGRESQL_URL` missing | Cloudron app → Environment |
| Container crash on start | Migration failure | App logs around `prisma migrate deploy`; fix forward with new migration |
| Magic link not sent | Mail addon or `SMTP_FROM` | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*` vars |
| Upload `server_misconfigured` | `UPLOAD_ROOT` unset | `cloudron env set --app <app> UPLOAD_ROOT=/app/data/uploads` |
| No “Recommended” on method cards | Seed not run | §3.4 — `node prisma/seed.bundle.cjs` |
| Rate limit too aggressive after deploy | Expected per §6.1 | Single instance only; limits reset on container restart |
App logs: Cloudron dashboard → *Logs* tab, or `cloudron logs --app <app> -f`.
## 9. Related docs
- [`ops-backend-deploy.md`](ops-backend-deploy.md) — first install, cutover
plan, legacy rules archive, build/push deep dive.
- [`backend-roadmap.md`](backend-roadmap.md) — migrations policy (§8),
rate limiting (§5), environments (§11).
- [`../relaunch-brief.md`](../relaunch-brief.md) — plain-language summary
for MEDLab admin.
- [`../../CONTRIBUTING.md`](../../CONTRIBUTING.md) — local dev setup.
+5 -2
View File
@@ -41,8 +41,11 @@ Do not duplicate the same glyph in both places unless migrating between systems.
- **Blog art** stays under `public/content/blog/` with
`{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`.
- **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in
`app/layout.tsx` metadata). Do not place `favicon.ico` or other static
binaries under `app/` — keep `app/` for routes, layouts, and styles only
`app/layout.tsx` metadata) plus generated root binaries for Safari/iOS:
`public/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, and
`apple-touch-icon.png`. Regenerate after logo changes with
`npm run generate:favicons`. Do not place other static binaries under
`app/` — keep `app/` for routes, layouts, and styles only
(`globals.css`, `tailwind.css`).
## PNG files and `.gitignore`
+208
View File
@@ -0,0 +1,208 @@
# Next 16 substrate evaluation (Phase 3)
Evaluation of `experimental.cacheComponents` (formerly `experimental.ppr`)
and React Compiler against this repo on Next.js 16.2.6. Originally written
as a canary report when both flags were deferred; updated when both shipped
as follow-up work (see "Outcome" sections below).
## TL;DR
| Flag | Recommendation | Status |
| --- | --- | --- |
| `cacheComponents` (PPR successor) | **Ship** | **Shipped.** `force-dynamic` removed from `(app)` and `(admin)` layouts; `<ConditionalNavigation />` (and `<MarketingNavigation />`) wrapped in `<Suspense fallback={null}>`. `(app)`/`(admin)` routes are now `◐ Partial Prerender` instead of `ƒ Dynamic`. `/` static shell dropped from 45 KB → 11.7 KB gzipped. |
| React Compiler | **Ship (annotation mode)** | **Shipped (plumbing only).** `babel-plugin-react-compiler` + `eslint-plugin-react-compiler` installed. `reactCompiler: { compilationMode: "annotation" }` enabled in `next.config.mjs`. ESLint rule wired in at "warn" — found 31 latent warnings across 8 files (none introduced by this change). Migrating containers to `"use memo"` is a future task. |
Both flags now ship in `main`. The findings below describe what changed in
Next 16, what work each required, and the outcomes.
## Repo baseline (Next 16.2.6, Turbopack, no experimental flags)
- Build status: clean (`npx next build`)
- Static routes: `/`, `/_not-found`, `/about`, `/blog`, `/components-preview`,
`/how-it-works`, `/learn`, `/templates`, `/use-cases`
- SSG routes: `/blog/[slug]`, `/use-cases/[slug]`, `/use-cases/[slug]/rule`
- Dynamic routes: all `/api/*`, `/create`, `/create/[screenId]`,
`/create/review-template/[slug]`, `/login`, `/monitor`, `/profile`,
`/rules/[id]`
- `.next/static` total: **3.6 MB** (uncompressed)
Note: Next 16 with Turbopack no longer prints per-route first-load JS sizes
in the build summary. Bundle analyzer (`ANALYZE=true`) is the canonical
source for size data — see Phase 4a.
## 3a. `cacheComponents` (PPR) — SHIPPED
### What changed in Next 16
`experimental.ppr` has been merged into `experimental.cacheComponents`:
```
Error: experimental.ppr has been merged into cacheComponents. The Partial
Prerendering feature is still available, but is now enabled via cacheComponents.
```
Crucially, the per-route incremental opt-in is gone:
```
cacheComponents: invalid type: string "incremental", expected a boolean
```
So `cacheComponents: true` flips PPR semantics on globally for every route.
### Blocker
With `cacheComponents: true`, the build fails:
```
./app/(admin)/layout.tsx:6:14
Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`.
Please remove it.
./app/(app)/layout.tsx:8:14
Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`.
Please remove it.
```
Both layouts use `export const dynamic = "force-dynamic"` to render
session-aware chrome (set in Phase 4b of the prior plan). `cacheComponents`
requires expressing that dynamism via `<Suspense>` boundaries plus
`unstable_noStore()`/`unstable_cache()` instead of route-segment `dynamic`.
### Work performed
1. Removed `export const dynamic = "force-dynamic"` from
[app/(app)/layout.tsx](../../app/(app)/layout.tsx) and
[app/(admin)/layout.tsx](../../app/(admin)/layout.tsx).
2. Wrapped `<ConditionalNavigation />` (server component reading
`getNavAuthSignedIn()``cookies()`) in `<Suspense fallback={null}>` in
both layouts.
3. Same change for `<MarketingNavigation />` in
[app/(marketing)/layout.tsx](../../app/(marketing)/layout.tsx) — the
marketing nav reads `usePathname()` (uncached per request) and would
otherwise block the static shell at routes like `/rules/[id]`.
4. Enabled `experimental.cacheComponents: true` in
[next.config.mjs](../../next.config.mjs).
`unstable_noStore()` already sits inside `getNavAuthSignedIn()`; no
additional cache primitives were needed.
### Why `fallback={null}` and not a placeholder
Any non-null fallback would also need to live in the static shell. The
existing `ConditionalNavigationClient` reads `usePathname()` to decide
chromeless paths (`/create/*`, `/login`), which is uncached data —
disallowed in the static shell under `cacheComponents`. A truly static
placeholder is possible but would cause layout shift on routes that
ultimately render no nav. Trade-off accepted: brief blank-nav while the
dynamic island streams in.
### Outcome (measured against `npx next build`)
| Route group | Before | After |
| --- | --- | --- |
| `/`, `/about`, `/blog`, `/components-preview`, `/how-it-works`, `/learn` | `○ Static` | `○ Static` |
| `/create`, `/create/[screenId]`, `/profile`, `/monitor`, `/login`, `/rules/[id]` | `ƒ Dynamic` | `◐ Partial Prerender` |
| `/templates`, `/use-cases`, `/use-cases/[slug]`, `/blog/[slug]` | `○ Static` / `◐ SSG` | `◐ Partial Prerender` |
`/` static shell: 45 KB gzipped → 11.7 KB gzipped (74% reduction). All
196 test files / 1251 tests pass.
## 3b. React Compiler — SHIPPED (annotation mode, plumbing only)
### What changed in Next 16
`experimental.reactCompiler` moved to the top-level `reactCompiler` key:
```
`experimental.reactCompiler` has been moved to `reactCompiler`. Please
update your next.config.mjs file accordingly.
```
And requires the babel plugin to be installed:
```
Failed to resolve package babel-plugin-react-compiler while attempting to
resolve React Compiler. We attempted to resolve React Compiler relative
to the next package. Is babel-plugin-react-compiler installed in your
node_modules directory?
```
### Work performed
1. `npm install --save-dev babel-plugin-react-compiler eslint-plugin-react-compiler`.
2. Added top-level `reactCompiler: { compilationMode: "annotation" }` to
[next.config.mjs](../../next.config.mjs).
3. Wired `react-compiler/react-compiler` as `"warn"` in
[eslint.config.mjs](../../eslint.config.mjs) (both JS and TS plugin blocks).
4. No `"use memo"` directives added in this pass — the goal is plumbing,
not migration. The compiler is a no-op until components opt in.
### Why annotation mode first (unchanged from original plan)
We have many hand-rolled memoized containers. The risk of `compilationMode: "all"`
on day one is that the compiler bails on a critical component in a way that
changes render counts. Annotation mode lets us migrate one component at a
time with eslint enforcement.
### ESLint audit results
`npx eslint app lib` after wiring the rule found **31 react-compiler warnings
across 8 files**:
| Category | Count |
| --- | --- |
| "Hooks may not be referenced as normal values" (passing hook references as values) | 25 |
| "Writing to a variable defined outside a component or hook" (module-level mutation) | 2 |
| "Hooks must always be called in a consistent order" (conditional hooks) | 2 |
| "React Compiler skipped optimizing" (file has React rules disabled) | 2 |
Files flagged:
- `app/(app)/create/context/CreateFlowContext.tsx`
- `app/(app)/create/hooks/useCompletedRuleShareExport.ts`
- `app/(marketing)/use-cases/[slug]/page.tsx`
- `app/(marketing)/use-cases/page.tsx`
- `app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts`
- `app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx`
- `app/components/controls/SelectInput/SelectInput.container.tsx`
- `app/components/sections/RelatedArticles/RelatedArticles.view.tsx`
All warnings are latent (not introduced by this change). The compiler runs
in annotation mode, so these files are not affected at runtime until they
opt in. Not fixed in this commit — these are the migration targets to
address before flipping to `compilationMode: "all"`.
### Verification
- Test suite green: 196 files / 1251 tests pass.
- Build green: `npx next build` clean with the new config.
- TSC clean.
- Bundle size delta minimal — no `"use memo"` annotations means no compiler
runtime calls are emitted in user code yet.
## Impact on Phase 4 (MessagesProvider)
Phase 4 (route-scoped `MessagesProvider`) shipped before this work. With
`cacheComponents` now enabled, the marketing routes' messages dictionary
lives in the static shell — cached at the CDN with no per-request cost.
The route-scoping is still a win for the dynamic islands' RSC payload, but
the static-shell win is now structural, not bundle-size dependent.
## Follow-up work
`cacheComponents` and React Compiler annotation mode now ship. Remaining
work (file separately when scheduled):
1. **Migrate top React Compiler bail sites.** Fix the 31 latent warnings
(especially the hook-reference patterns in `CreateFlowContext` and the
conditional hooks in `SelectInput.container`) so those files become
compiler-eligible.
2. **Annotate high-render containers with `"use memo"`.** Targets:
`CreateFlowProvider`, `AuthModalProvider`, list-heavy views. Measure
render counts before/after with React DevTools profiler.
3. **Flip React Compiler from `annotation` to `all`** once the bail list is
green and a critical mass of containers are annotated. Remove
hand-written `useMemo`/`useCallback` the compiler subsumes.
4. **Audit `<Suspense fallback={null}>` UX on (app)/(admin) routes.** If
blank-nav flash becomes noticeable on slow connections, replace the
`null` fallback with a static placeholder that doesn't read `usePathname`
(e.g. a `min-h` div sized to the nav).
+4 -3
View File
@@ -23,7 +23,7 @@ All three retire together when the new app goes live. The chatbot is **not** bei
## What does NOT carry over
- **No user accounts.** New sign-ins start fresh.
- **No published rules from the old database.** We'll count the existing `rules` table before cutover and decide whether to publish a read-only archive (CSV/JSON) somewhere for anyone looking for their old work.
- **No published rules from the old database.** Pre-cutover rules are exported to a read-only Gitea archive (`CommunityRule/legacy-rules-archive` on `git.medlab.host`); they are not imported into the new app. See [`docs/guides/ops-backend-deploy.md`](guides/ops-backend-deploy.md) §6.1.
- **No chatbot.**
## How the cutover will work
@@ -35,7 +35,7 @@ until the new one is verified.
`staging.communityrule.info` (auto-provisioned by Cloudron). Legacy app at the apex is not touched. Quiet testing within MEDLab/stakeholders.
2. **Cutover phase.** When staging is green and we're ready, schedule a low-traffic window. During the window (roughly 515 minutes of apex downtime):
- Take a final backup of the legacy app (Cloudron one-click).
- Pull a copy of the legacy `rules` table if we decided to publish an archive.
- Export the legacy `rules` + `version_history` tables to the Gitea archive (see ops-backend-deploy §6.1).
- Uninstall the legacy app at the apex `communityrule.info`.
- Move the new app to the apex.
- Smoke-test, confirm backups are on, done.
@@ -53,6 +53,7 @@ Roughly this order:
3. **Install at staging** subdomain, smoke test, soft launch (CR-98).
4. **Apex cutover window** — the brief downtime above.
5. **Uninstall legacy**, archive legacy repos.
6. **Write the steady-state runbook** based on what actually worked.
6. ~~**Write the steady-state runbook** based on what actually worked
([`ops-runbook.md`](guides/ops-runbook.md), CR-100).~~ **Done.**
Staging should be ready to deploy in 1-2 weeks, and we can go from there.
+9
View File
@@ -9,6 +9,7 @@ import nextPlugin from "@next/eslint-plugin-next";
import globals from "globals";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactCompiler from "eslint-plugin-react-compiler";
const eslintConfig = [
// Base JavaScript recommended rules
@@ -51,6 +52,7 @@ const eslintConfig = [
plugins: {
react,
"react-hooks": reactHooks,
"react-compiler": reactCompiler,
},
settings: {
react: {
@@ -62,6 +64,9 @@ const eslintConfig = [
...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off", // React 19 doesn't require React import
"react/prop-types": "off", // Using TypeScript for prop validation
// Surface code the React Compiler would bail on. We run in annotation
// mode, so the rule is "warn" — informational, not a build blocker.
"react-compiler/react-compiler": "warn",
},
},
// TypeScript files configuration
@@ -90,6 +95,7 @@ const eslintConfig = [
"@next/next": nextPlugin,
react,
"react-hooks": reactHooks,
"react-compiler": reactCompiler,
},
settings: {
react: {
@@ -101,6 +107,9 @@ const eslintConfig = [
...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off", // React 19 doesn't require React import
"react/prop-types": "off", // Using TypeScript for prop validation
// Surface code the React Compiler would bail on. We run in annotation
// mode, so the rule is "warn" — informational, not a build blocker.
"react-compiler/react-compiler": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
+2 -1
View File
@@ -209,7 +209,8 @@ export const ASSETS = {
// Social media
BLUESKY_LOGO: "assets/logos/bluesky.svg",
GITLAB_ICON: "assets/logos/gitlab.svg",
GITEA_ICON: "assets/logos/gitea.svg",
MASTODON_LOGO: "assets/logos/mastodon.svg",
// Content page decorative shapes
CONTENT_SHAPE_1: "assets/shapes/content-shape-1.svg",
+76
View File
@@ -0,0 +1,76 @@
import type { MethodFacetApiSectionId } from "./customRuleFacets";
export type FacetScoresBySlug = Record<string, number>;
const EMPTY_SCORES: FacetScoresBySlug = {};
const cache = new Map<string, FacetScoresBySlug>();
const inFlight = new Map<string, Promise<FacetScoresBySlug>>();
export function buildFacetRecommendationRequestKey(
section: MethodFacetApiSectionId,
queryString: string,
): string {
return `${section}?${queryString}`;
}
export function getCachedFacetScores(
requestKey: string,
): FacetScoresBySlug | undefined {
return cache.get(requestKey);
}
function parseScoresFromMethodsJson(json: {
methods?: { slug: string; matches?: { score?: number } }[];
}): FacetScoresBySlug {
const scoresBySlug: FacetScoresBySlug = {};
for (const m of json.methods ?? []) {
if (typeof m.slug === "string") {
scoresBySlug[m.slug] = m.matches?.score ?? 0;
}
}
return scoresBySlug;
}
async function fetchFacetScoresFromApi(
section: MethodFacetApiSectionId,
queryString: string,
): Promise<FacetScoresBySlug> {
const res = await fetch(
`/api/create-flow/methods?section=${section}&${queryString}`,
{ credentials: "include" },
);
if (!res.ok) throw new Error(`status ${res.status}`);
const json = (await res.json()) as {
methods?: { slug: string; matches?: { score?: number } }[];
};
return parseScoresFromMethodsJson(json);
}
/**
* Loads facet recommendation scores for one method deck. Results are cached
* and in-flight requests are deduped so prefetch + screen hooks share work.
*/
export function loadFacetScores(
section: MethodFacetApiSectionId,
queryString: string,
): Promise<FacetScoresBySlug> {
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
const cached = cache.get(requestKey);
if (cached) return Promise.resolve(cached);
let pending = inFlight.get(requestKey);
if (!pending) {
pending = fetchFacetScoresFromApi(section, queryString)
.then((scores) => {
cache.set(requestKey, scores);
return scores;
})
.catch(() => EMPTY_SCORES)
.finally(() => {
inFlight.delete(requestKey);
});
inFlight.set(requestKey, pending);
}
return pending;
}
+2 -1
View File
@@ -11,7 +11,8 @@
},
"ariaLabels": {
"followBluesky": "Follow us on Bluesky",
"followGitlab": "Follow us on GitLab",
"viewSourceGitea": "View source on Gitea",
"followMastodon": "Follow us on Mastodon",
"featureToolsAndServices": "Feature tools and services",
"askOrganizerContact": "Ask an organizer - Contact an organizer for help"
}
+11 -6
View File
@@ -7,14 +7,19 @@
},
"social": {
"bluesky": {
"handle": "medlabboulder",
"label": "Bluesky",
"ariaLabel": "Follow us on Bluesky",
"url": "https://bsky.app/profile/medlabboulder"
"url": "https://bsky.app/profile/medlabboulder.bsky.social"
},
"gitlab": {
"handle": "medlabboulder",
"ariaLabel": "Follow us on GitLab",
"url": "https://gitlab.com/medlabboulder"
"gitea": {
"label": "Gitea",
"ariaLabel": "View source on Gitea",
"url": "https://git.medlab.host/CommunityRule/community-rule"
},
"mastodon": {
"label": "Mastodon",
"ariaLabel": "Follow us on Mastodon",
"url": "https://social.medlab.host/@medlab"
}
},
"navigation": {
@@ -3,7 +3,7 @@
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
"placeholder": "email@domain.com",
"characterCountTemplate": "{current}/{max}",
"magicLinkSuccessTitle": "Check your email to log in!",
"magicLinkSuccessDescription": "Your account is created, now just check your email for a magic link",
"magicLinkSuccessTitle": "Check your email to log in",
"magicLinkSuccessDescription": "Your account has been created. A login link has been emailed to you.",
"magicLinkErrorTitle": "Could not send link"
}
@@ -16,7 +16,7 @@
"addButtonText": "Add maturity"
},
"organizationTypes": [
{ "label": "Workers coop" },
{ "label": "Worker cooperative" },
{ "label": "Mutual aid" },
{ "label": "Open source project" },
{ "label": "Nonprofit" },
+103
View File
@@ -0,0 +1,103 @@
/**
* Marketing-scoped message bundle: every namespace from `./index` EXCEPT the
* `create.*` subtree. The `create` namespace is ~41 KB gzipped the largest
* single contributor to per-route HTML size and is only used inside
* `(app)/create/*`. Excluding it from the `(marketing)` group's
* `MessagesProvider` removes the embed from every marketing HTML response.
*
* The type stays compatible with `typeof import("./index").default` because
* we satisfy the same key shape (modulo `create`); marketing client
* components only read keys that exist here. If a future change reaches into
* `messages.create.*` from a marketing surface, `getTranslation` will return
* the dotted key as the fallback visible immediately at runtime.
*
* Keep this in sync with new entries added to `./index` (excluding `create/`).
* See `docs/perf/next16-eval.md` for measurement context.
*/
import common from "./common.json";
import heroBanner from "./components/heroBanner.json";
import cardSteps from "./components/cardSteps.json";
import askOrganizer from "./components/askOrganizer.json";
import featureGrid from "./components/featureGrid.json";
import footer from "./components/footer.json";
import header from "./components/header.json";
import homeHeader from "./components/homeHeader.json";
import languageSwitcher from "./components/languageSwitcher.json";
import menu from "./components/menu.json";
import quoteBlock from "./components/quoteBlock.json";
import ruleCard from "./components/ruleCard.json";
import ruleStack from "./components/ruleStack.json";
import webVitalsDashboard from "./components/webVitalsDashboard.json";
import controlsChrome from "./components/controlsChrome.json";
import logoWall from "./components/logoWall.json";
import topNav from "./components/topNav.json";
import home from "./pages/home.json";
import templates from "./pages/templates.json";
import learn from "./pages/learn.json";
import about from "./pages/about.json";
import useCases from "./pages/useCases.json";
import useCasesDetail from "./pages/useCasesDetail.json";
import useCasesCompletedRules from "./pages/useCasesCompletedRules.json";
import useCasesCompletedRule from "./pages/useCasesCompletedRule.json";
import howItWorks from "./pages/howItWorks.json";
import monitor from "./pages/monitor.json";
import login from "./pages/login.json";
import profile from "./pages/profile.json";
import notFoundPage from "./pages/notFoundPage.json";
import ruleDetail from "./pages/ruleDetail.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import modalsShare from "./modals/share.json";
import modalsPopoverExport from "./modals/popoverExport.json";
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
import type messages from "./index";
const marketingMessages = {
common,
heroBanner,
cardSteps,
askOrganizer,
featureGrid,
footer,
header,
homeHeader,
languageSwitcher,
menu,
quoteBlock,
ruleCard,
ruleStack,
webVitalsDashboard,
controlsChrome,
logoWall,
topNav,
pages: {
home,
templates,
learn,
about,
useCases,
useCasesDetail,
useCasesCompletedRules,
useCasesCompletedRule,
howItWorks,
monitor,
login,
profile,
notFoundPage,
ruleDetail,
},
navigation,
metadata,
modals: {
share: modalsShare,
popoverExport: modalsPopoverExport,
askOrganizerInquiry: modalsAskOrganizerInquiry,
},
};
// Cast to the full shape so it satisfies `typeof import("./index").default`
// at the MessagesProvider boundary. Reads of `messages.create.*` from a
// marketing surface are a code smell and will return the dotted key (the
// runtime `getTranslation` fallback) — visible immediately.
export default marketingMessages as typeof messages;
+3 -3
View File
@@ -29,7 +29,7 @@
"title": "Who is this for?",
"items": [
{
"title": "Worker's cooperatives",
"title": "Worker cooperatives",
"description": "Employee-owned businesses often need to clarify how power is shared, decisions are made, and how processes operate within their organizations."
},
{
@@ -49,7 +49,7 @@
"tripleStep": {
"heading": "Get recommendations that will make organizing easier",
"ctaText": "Create Rule",
"ctaHref": "/create",
"ctaHref": "/create/informational",
"steps": [
{
"title": "Get your stakeholders together",
@@ -68,7 +68,7 @@
"tripleTextBlock": {
"title": "Why Horizontal groups need CommunityRule",
"ctaText": "Setup your community",
"ctaHref": "/create",
"ctaHref": "/create/informational",
"columns": [
{
"title": "Share Leadership and Prevent Burnout",
+58 -3
View File
@@ -1,10 +1,41 @@
import createMDX from "@next/mdx";
/* eslint-env node */
/** Keep viewBox and unique clip/mask IDs when multiple SVGR icons share a page. */
const svgrLoaderOptions = {
svgoConfig: {
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeViewBox: false,
},
},
},
{
name: "prefixIds",
params: {
prefixClassNames: false,
},
},
],
},
};
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["@prisma/client"],
// React Compiler — annotation mode: opt-in via the `"use memo"` directive at
// the top of a component/hook. With no annotations in the codebase yet, this
// is plumbing only (no behavior change). Migrate hand-written `useMemo`/
// `useCallback` containers incrementally and rely on `eslint-plugin-react-
// compiler` to surface any code that the compiler bails on.
reactCompiler: {
compilationMode: "annotation",
},
/**
* `next dev --turbopack` does not use `webpack()`; without this, `.svg`
* imports resolve as asset URLs and {@link app/components/asset/icon/Icon.tsx}
@@ -14,7 +45,12 @@ const nextConfig = {
rules: {
"*.svg": {
condition: { not: "foreign" },
loaders: ["@svgr/webpack"],
loaders: [
{
loader: "@svgr/webpack",
options: svgrLoaderOptions,
},
],
as: "*.js",
},
},
@@ -23,13 +59,20 @@ const nextConfig = {
experimental: {
optimizeCss: true,
optimizePackageImports: ["react", "react-dom"],
// Cache Components (the Next 16 successor to `experimental.ppr`) — components
// without `"use cache"` are dynamic by default, and any cookies/headers
// access outside a `<Suspense>` boundary becomes a build-time error. The
// `(app)` and `(admin)` layouts wrap `<ConditionalNavigation />` in
// `<Suspense>` so the static shell prerenders while the session-aware nav
// streams in. Replaces the prior `force-dynamic` route-segment exports.
cacheComponents: true,
},
// Compression
compress: true,
// Image optimization
images: {
formats: ["image/webp", "image/avif"],
minimumCacheTTL: 60,
minimumCacheTTL: 31536000,
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
remotePatterns: [
@@ -70,6 +113,18 @@ const nextConfig = {
},
],
},
{
// Long-cache static marketing art (avatars, vectors, case-study, etc.)
// since the file content is hashed into the URL by Next at request time
// through the image optimizer for raster, and changes require a deploy.
source: "/assets/:path*\\.(svg|png|webp|avif|jpg|jpeg)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
];
},
webpack(config, { dev, isServer }) {
@@ -77,7 +132,7 @@ const nextConfig = {
config.module.rules.push({
test: /\.svg$/,
issuer: /\.[jt]sx?$/,
use: ["@svgr/webpack"],
use: [{ loader: "@svgr/webpack", options: svgrLoaderOptions }],
});
// Bundle analysis - only in production builds
+111
View File
@@ -8,6 +8,7 @@
"name": "community-rule",
"version": "0.1.0",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
@@ -44,8 +45,10 @@
"@typescript-eslint/parser": "^8.41.0",
"@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9",
"eslint-config-next": "^16.0.0",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-storybook": "^10.4.1",
"globals": "^17.1.0",
"jest-axe": "^10.0.0",
@@ -53,6 +56,7 @@
"knip": "^5.50.0",
"msw": "^2.10.5",
"playwright": "^1.55.0",
"png-to-ico": "^3.0.1",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prisma": "^6.19.0",
@@ -643,6 +647,24 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/plugin-proposal-private-methods": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
"integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
"deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-class-features-plugin": "^7.18.6",
"@babel/helper-plugin-utils": "^7.18.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-proposal-private-property-in-object": {
"version": "7.21.0-placeholder-for-preset-env.2",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
@@ -9162,6 +9184,16 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
}
},
"node_modules/bail": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -12198,6 +12230,40 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
}
},
"node_modules/eslint-plugin-react-compiler": {
"version": "19.1.0-rc.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz",
"integrity": "sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"hermes-parser": "^0.25.1",
"zod": "^3.22.4",
"zod-validation-error": "^3.0.3"
},
"engines": {
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
},
"peerDependencies": {
"eslint": ">=7"
}
},
"node_modules/eslint-plugin-react-compiler/node_modules/zod-validation-error": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz",
"integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.24.4"
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
@@ -19175,6 +19241,51 @@
"node": ">=18"
}
},
"node_modules/png-to-ico": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/png-to-ico/-/png-to-ico-3.0.1.tgz",
"integrity": "sha512-S8BOAoaGd9gT5uaemQ62arIY3Jzco7Uc7LwUTqRyqJDTsKqOAiyfyN4dSdT0D+Zf8XvgztgpRbM5wnQd7EgYwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^22.10.3",
"minimist": "^1.2.8",
"pngjs": "^7.0.0"
},
"bin": {
"png-to-ico": "bin/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/png-to-ico/node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/png-to-ico/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
+6 -2
View File
@@ -45,7 +45,8 @@
"bundle:analyze": "node scripts/bundle-analyzer.js",
"db:deploy": "prisma migrate deploy",
"migrate:smoke": "./scripts/migrate-smoke-local.sh",
"docker:release": "./scripts/docker-release.sh"
"docker:release": "./scripts/docker-release.sh",
"generate:favicons": "node scripts/generate-favicons.mjs"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
@@ -83,15 +84,18 @@
"@typescript-eslint/parser": "^8.41.0",
"@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9",
"eslint-config-next": "^16.0.0",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-storybook": "^10.4.1",
"globals": "^17.1.0",
"jest-axe": "^10.0.0",
"jsdom": "^26.1.0",
"msw": "^2.10.5",
"knip": "^5.50.0",
"msw": "^2.10.5",
"playwright": "^1.55.0",
"png-to-ico": "^3.0.1",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prisma": "^6.19.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<style>
.st1{fill:#fff}
</style>
<g id="Icon">
<circle cx="512" cy="512" r="512" style="fill:#609926"/>
<path class="st1" d="M762.2 350.3c-100.9 5.3-160.7 8-212 8.5v114.1l-16-7.9-.1-106.1c-58.9 0-110.7-3.1-209.1-8.6-12.3-.1-29.5-2.4-47.9-2.5-47.1-.1-110.2 33.5-106.7 118C175.8 597.6 296 609.9 344 610.9c5.3 24.7 61.8 110.1 103.6 114.6H631c109.9-8.2 192.3-373.8 131.2-375.2zm-546 117.3c-4.7-36.6 11.8-74.8 73.2-73.2C296.1 462 307 501.5 329 561.9c-56.2-7.4-104-25.7-112.8-94.3zm415.6 83.5-51.3 105.6c-6.5 13.4-22.7 19-36.2 12.5l-105.6-51.3c-13.4-6.5-19-22.7-12.5-36.2l51.3-105.6c6.5-13.4 22.7-19 36.2-12.5l105.6 51.3c13.4 6.6 19 22.8 12.5 36.2z"/>
<path class="st1" d="M555 609.9c.1-.2.2-.3.2-.5 17.2-35.2 24.3-49.8 19.8-62.4-3.9-11.1-15.5-16.6-36.7-26.6-.8-.4-1.7-.8-2.5-1.2.2-2.3-.1-4.7-1-7-.8-2.3-2.1-4.3-3.7-6l13.6-27.8-11.9-5.8-13.7 28.4c-2 0-4.1.3-6.2 1-8.9 3.2-13.5 13-10.3 21.9.7 1.9 1.7 3.5 2.8 5l-23.6 48.4c-1.9 0-3.8.3-5.7 1-8.9 3.2-13.5 13-10.3 21.9 3.2 8.9 13 13.5 21.9 10.3 8.9-3.2 13.5-13 10.3-21.9-.9-2.5-2.3-4.6-4-6.3l23-47.2c2.5.2 5 0 7.5-.9 2.1-.8 3.9-1.9 5.5-3.3.9.4 1.9.9 2.7 1.3 17.4 8.2 27.9 13.2 30 19.1 2.6 7.5-5.1 23.4-19.3 52.3-.1.2-.2.5-.4.7-2.2-.1-4.4.2-6.5 1-8.9 3.2-13.5 13-10.3 21.9 3.2 8.9 13 13.5 21.9 10.3 8.9-3.2 13.5-13 10.3-21.9-.6-2-1.9-4-3.4-5.7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="22" height="22" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill="#949494"
d="M44.9955 16.2709c0 -9.76202 -6.3993 -12.62323 -6.3993 -12.62323 -3.2287 -1.4803 -8.7692 -2.10373 -14.5254 -2.1506h-0.1415c-5.7562 0.04687 -11.293 0.6703 -14.51985 2.1506 0 0 -6.40027 2.86121 -6.40027 12.62323 0 2.2359 -0.04312 4.9078 0.02719 7.7427 0.2325 9.5455 1.75123 18.9551 10.58333 21.2913 4.0715 1.0772 7.5683 1.3022 10.3836 1.1475 5.1065 -0.2812 7.9687 -1.8206 7.9687 -1.8206l-0.1679 -3.7031s-3.6496 1.1503 -7.7474 1.0097c-4.0602 -0.1387 -8.3436 -0.4378 -8.9998 -5.4196 -0.0634 -0.4629 -0.0947 -0.9296 -0.0938 -1.3969 2.9714 0.6634 5.995 1.0664 9.0365 1.2047 3.089 0.1416 5.9849 -0.1809 8.9267 -0.5316 5.6418 -0.6731 10.5543 -4.1474 11.1711 -7.3208 0.9769 -5.0005 0.8981 -12.2033 0.8981 -12.2033ZM37.445 28.8483h-4.6874V17.3762c0 -2.4187 -1.0181 -3.6459 -3.0544 -3.6459 -2.2499 0 -3.3805 1.456 -3.3805 4.335v6.2812h-4.6556v-6.2812c0 -2.879 -1.125 -4.335 -3.3806 -4.335 -2.0362 0 -3.0543 1.2272 -3.0543 3.6459v11.4721h-4.6875V17.0284c0 -2.4156 0.6172 -4.334 1.8516 -5.7552 1.275 -1.4203 2.9437 -2.14873 5.0165 -2.14873 2.3981 0 4.214 0.92063 5.414 2.76373l1.169 1.9546 1.1672 -1.9546c1.2009 -1.8431 3.0159 -2.76373 5.414 -2.76373 2.0728 0 3.7415 0.72843 5.0165 2.14873 1.2368 1.42 1.8539 3.3384 1.8515 5.7552v11.8199Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Regenerate root favicon binaries from `public/assets/logos/community-rule.svg`.
* Safari and iOS need PNG/ICO fallbacks; SVG alone shows a letter fallback in Safari.
*
* Cream mark (#FFFDD2) on a transparent canvas matches the brand SVG.
*
* Run: npm run generate:favicons
*/
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import pngToIco from "png-to-ico";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PUBLIC = path.join(ROOT, "public");
const SVG_PATH = path.join(PUBLIC, "assets/logos/community-rule.svg");
async function readLogoSvg() {
return fs.readFile(SVG_PATH, "utf8");
}
/** Resize the logo SVG to a PNG with alpha (transparent background). */
async function creamMarkTransparent(svg, size) {
return sharp(Buffer.from(svg))
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
}
async function main() {
const svg = await readLogoSvg();
const png16 = await creamMarkTransparent(svg, 16);
const png32 = await creamMarkTransparent(svg, 32);
const appleTouch = await creamMarkTransparent(svg, 180);
const faviconIco = await pngToIco([png16, png32]);
await Promise.all([
fs.writeFile(path.join(PUBLIC, "favicon-16x16.png"), png16),
fs.writeFile(path.join(PUBLIC, "favicon-32x32.png"), png32),
fs.writeFile(path.join(PUBLIC, "apple-touch-icon.png"), appleTouch),
fs.writeFile(path.join(PUBLIC, "favicon.ico"), faviconIco),
]);
console.log("Wrote public/favicon.ico");
console.log("Wrote public/favicon-16x16.png");
console.log("Wrote public/favicon-32x32.png");
console.log("Wrote public/apple-touch-icon.png");
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

Some files were not shown because too many files have changed in this diff Show More