Backend / staging cleanup, performance substrate, and create-flow polish #60
@@ -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
@@ -28,7 +28,7 @@ npm-cache/
|
|||||||
/lhci-results/
|
/lhci-results/
|
||||||
/.lighthouseci/
|
/.lighthouseci/
|
||||||
|
|
||||||
# Ignore other image files (but not visual regression snapshots)
|
# Ignore other image files (but not visual regression snapshots or favicons)
|
||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
@@ -39,6 +39,11 @@ npm-cache/
|
|||||||
*.avi
|
*.avi
|
||||||
*.mkv
|
*.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)
|
# Visual regression snapshots (allow these)
|
||||||
!tests/e2e/visual-regression.spec.ts-snapshots/
|
!tests/e2e/visual-regression.spec.ts-snapshots/
|
||||||
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
||||||
|
|||||||
+19
-2
@@ -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
|
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
|
||||||
// public marketing footer. Auth/access is enforced upstream.
|
// public marketing footer. Auth/access is enforced upstream.
|
||||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||||
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||||
|
import { usePrefetchMethodFacetRecommendations } from "./hooks/usePrefetchMethodFacetRecommendations";
|
||||||
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
||||||
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
||||||
import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport";
|
import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport";
|
||||||
@@ -167,6 +168,7 @@ function CreateFlowLayoutContent({
|
|||||||
replaceState,
|
replaceState,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
} = useCreateFlow();
|
} = useCreateFlow();
|
||||||
|
usePrefetchMethodFacetRecommendations();
|
||||||
const manageStakeholdersIntent =
|
const manageStakeholdersIntent =
|
||||||
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
|
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
|
||||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
|
||||||
@@ -692,7 +694,7 @@ function CreateFlowLayoutContent({
|
|||||||
}`.trim()}
|
}`.trim()}
|
||||||
/>
|
/>
|
||||||
<main
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useTranslation } from "../../contexts/MessagesContext";
|
import CreateFlowLayoutClient from "./CreateFlowLayoutClient";
|
||||||
|
|
||||||
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 />,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
export default function CreateFlowLayoutGate({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
isValidStep,
|
isValidStep,
|
||||||
parseCreateFlowScreenFromPathname,
|
parseCreateFlowScreenFromPathname,
|
||||||
} from "./utils/flowSteps";
|
} from "./utils/flowSteps";
|
||||||
|
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
|
||||||
|
|
||||||
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
|
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
|
||||||
|
|
||||||
@@ -52,6 +53,22 @@ export function SignedInDraftHydration({
|
|||||||
|
|
||||||
const [loadingHydration, setLoadingHydration] = useState(false);
|
const [loadingHydration, setLoadingHydration] = useState(false);
|
||||||
const finishedUserIdRef = useRef<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isBackendSyncEnabled()) return;
|
if (!isBackendSyncEnabled()) return;
|
||||||
@@ -68,6 +85,10 @@ export function SignedInDraftHydration({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (freshEntryPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Local draft wins over server: no fetch, no replaceState. The provider
|
// Local draft wins over server: no fetch, no replaceState. The provider
|
||||||
// already hydrated from localStorage at mount, so the user sees their
|
// already hydrated from localStorage at mount, so the user sees their
|
||||||
// unsaved keystrokes immediately.
|
// unsaved keystrokes immediately.
|
||||||
@@ -122,6 +143,7 @@ export function SignedInDraftHydration({
|
|||||||
replaceState,
|
replaceState,
|
||||||
pathname,
|
pathname,
|
||||||
router,
|
router,
|
||||||
|
freshEntryPending,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!loadingHydration) return null;
|
if (!loadingHydration) return null;
|
||||||
|
|||||||
@@ -56,29 +56,49 @@ export function CreateFlowProvider({
|
|||||||
initialStep = null,
|
initialStep = null,
|
||||||
enableLocalDraftMirroring = false,
|
enableLocalDraftMirroring = false,
|
||||||
}: CreateFlowProviderProps) {
|
}: CreateFlowProviderProps) {
|
||||||
const [state, setState] = useState<CreateFlowState>(() => {
|
// Initializer must NOT touch `localStorage`: this provider runs through SSR
|
||||||
const base = enableLocalDraftMirroring
|
// now (CreateFlowLayoutGate dropped `ssr: false`), and a server `{}` followed
|
||||||
? readAnonymousCreateFlowState()
|
// by a client read of stored data would be a hydration mismatch. The
|
||||||
: {};
|
// `mount-once` effect below replays the read on the client.
|
||||||
const storedDetails = readCoreValueDetailsFromLocalStorage();
|
const [state, setState] = useState<CreateFlowState>({});
|
||||||
if (Object.keys(storedDetails).length === 0) return base;
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
coreValueDetailsByChipId: {
|
|
||||||
...storedDetails,
|
|
||||||
...(base.coreValueDetailsByChipId ?? {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const [interactionTouched, setInteractionTouched] = useState(false);
|
const [interactionTouched, setInteractionTouched] = useState(false);
|
||||||
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
|
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
|
||||||
const prevPersistRef = useRef(enableLocalDraftMirroring);
|
const prevPersistRef = useRef(enableLocalDraftMirroring);
|
||||||
const persistWriteSkipRef = useRef(true);
|
const persistWriteSkipRef = useRef(true);
|
||||||
|
const initialHydrateDoneRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearLegacyCreateFlowKeysOnce();
|
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
|
// Session resolved after initial paint: hydrate from localStorage, merging
|
||||||
// with anything already in state. We can't bail on `prev` being non-empty:
|
// with anything already in state. We can't bail on `prev` being non-empty:
|
||||||
// the initializer pre-populates `coreValueDetailsByChipId` from a separate
|
// the initializer pre-populates `coreValueDetailsByChipId` from a separate
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
|
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
|
||||||
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
|
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
|
||||||
|
import {
|
||||||
|
buildFacetRecommendationRequestKey,
|
||||||
|
getCachedFacetScores,
|
||||||
|
loadFacetScores,
|
||||||
|
} from "../../../../lib/create/facetRecommendationsLoad";
|
||||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +30,34 @@ export type FacetRecommendationsResult = {
|
|||||||
|
|
||||||
const EMPTY_SCORES: Record<string, number> = {};
|
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
|
* Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the
|
||||||
* card-deck step `section` and returns a `slug → score` map for re-ranking
|
* 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 hasAnyFacets = queryString.length > 0;
|
||||||
|
|
||||||
const [result, setResult] = useState<FacetRecommendationsResult>({
|
const [result, setResult] = useState<FacetRecommendationsResult>(() =>
|
||||||
isReady: !hasAnyFacets,
|
initialFacetRecommendationsResult(section, queryString),
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAnyFacets) {
|
if (!hasAnyFacets) {
|
||||||
@@ -62,51 +90,34 @@ export function useFacetRecommendations(
|
|||||||
scoresBySlug: EMPTY_SCORES,
|
scoresBySlug: EMPTY_SCORES,
|
||||||
hasAnyFacets: false,
|
hasAnyFacets: false,
|
||||||
});
|
});
|
||||||
lastQueryRef.current = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestKey = `${section}?${queryString}`;
|
|
||||||
if (lastQueryRef.current === requestKey) return;
|
|
||||||
lastQueryRef.current = requestKey;
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
|
||||||
setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true }));
|
const cached = getCachedFacetScores(requestKey);
|
||||||
fetch(`/api/create-flow/methods?section=${section}&${queryString}`, {
|
if (cached) {
|
||||||
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({
|
setResult({
|
||||||
isReady: true,
|
isReady: true,
|
||||||
scoresBySlug: EMPTY_SCORES,
|
scoresBySlug: cached,
|
||||||
hasAnyFacets: true,
|
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 () => {
|
return () => {
|
||||||
ctrl.abort();
|
cancelled = true;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [section, queryString, hasAnyFacets]);
|
}, [section, queryString, hasAnyFacets]);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export function useMethodCardDeckOrdering(
|
|||||||
methods: readonly MethodEntry[],
|
methods: readonly MethodEntry[],
|
||||||
selectedIds: readonly string[],
|
selectedIds: readonly string[],
|
||||||
) {
|
) {
|
||||||
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
|
const { scoresBySlug, hasAnyFacets, isReady } =
|
||||||
|
useFacetRecommendations(section);
|
||||||
|
const recommendationsReady = !hasAnyFacets || isReady;
|
||||||
|
|
||||||
const rankedMethods = useMemo(
|
const rankedMethods = useMemo(
|
||||||
() => rankMethodsByScore(methods, scoresBySlug),
|
() => rankMethodsByScore(methods, scoresBySlug),
|
||||||
@@ -90,5 +92,6 @@ export function useMethodCardDeckOrdering(
|
|||||||
recommendedIds,
|
recommendedIds,
|
||||||
sampleCards,
|
sampleCards,
|
||||||
methodById,
|
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]);
|
||||||
|
}
|
||||||
@@ -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],
|
[comm.methods, selectedIds, state.customMethodCardMetaById],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
|
||||||
|
useMethodCardDeckOrdering(
|
||||||
"communication",
|
"communication",
|
||||||
mergedMethods,
|
mergedMethods,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -735,7 +736,11 @@ export function CommunicationMethodsScreen() {
|
|||||||
justification="center"
|
justification="center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
<div
|
||||||
|
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
||||||
|
aria-busy={!recommendationsReady}
|
||||||
|
>
|
||||||
|
{recommendationsReady && (
|
||||||
<CardStack
|
<CardStack
|
||||||
cards={sampleCards}
|
cards={sampleCards}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
@@ -752,6 +757,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
compactDesktopLayout="flexWrap"
|
compactDesktopLayout="flexWrap"
|
||||||
headerLockupSize={mdUp ? "L" : "M"}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ export function ConflictManagementScreen() {
|
|||||||
[cm.methods, selectedIds, state.customMethodCardMetaById],
|
[cm.methods, selectedIds, state.customMethodCardMetaById],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
|
||||||
|
useMethodCardDeckOrdering(
|
||||||
"conflictManagement",
|
"conflictManagement",
|
||||||
mergedMethods,
|
mergedMethods,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -734,7 +735,11 @@ export function ConflictManagementScreen() {
|
|||||||
justification="center"
|
justification="center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
<div
|
||||||
|
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
||||||
|
aria-busy={!recommendationsReady}
|
||||||
|
>
|
||||||
|
{recommendationsReady && (
|
||||||
<CardStack
|
<CardStack
|
||||||
cards={sampleCards}
|
cards={sampleCards}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
@@ -751,6 +756,7 @@ export function ConflictManagementScreen() {
|
|||||||
compactDesktopLayout="pyramidFive"
|
compactDesktopLayout="pyramidFive"
|
||||||
headerLockupSize={mdUp ? "L" : "M"}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export function MembershipMethodsScreen() {
|
|||||||
[mem.methods, selectedIds, state.customMethodCardMetaById],
|
[mem.methods, selectedIds, state.customMethodCardMetaById],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
|
||||||
|
useMethodCardDeckOrdering(
|
||||||
"membership",
|
"membership",
|
||||||
mergedMethods,
|
mergedMethods,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -727,7 +728,11 @@ export function MembershipMethodsScreen() {
|
|||||||
justification="center"
|
justification="center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
<div
|
||||||
|
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
||||||
|
aria-busy={!recommendationsReady}
|
||||||
|
>
|
||||||
|
{recommendationsReady && (
|
||||||
<CardStack
|
<CardStack
|
||||||
cards={sampleCards}
|
cards={sampleCards}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
@@ -744,6 +749,7 @@ export function MembershipMethodsScreen() {
|
|||||||
compactDesktopLayout="pyramidFive"
|
compactDesktopLayout="pyramidFive"
|
||||||
headerLockupSize={mdUp ? "L" : "M"}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,108 @@
|
|||||||
* Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView}
|
* Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView}
|
||||||
* thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so
|
* thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so
|
||||||
* new steps do not drift.
|
* 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 { ReactNode } from "react";
|
||||||
import type { CreateFlowStep } from "../types";
|
import type { CreateFlowStep } from "../types";
|
||||||
import { InformationalScreen } from "./informational/InformationalScreen";
|
import { InformationalScreen } from "./informational/InformationalScreen";
|
||||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
|
||||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
const CreateFlowTextFieldScreen = dynamic(
|
||||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
() =>
|
||||||
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
|
import("./text/CreateFlowTextFieldScreen").then((m) => ({
|
||||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
default: m.CreateFlowTextFieldScreen,
|
||||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
})),
|
||||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
{ loading: () => null },
|
||||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
);
|
||||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
const CommunitySizeSelectScreen = dynamic(
|
||||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
() =>
|
||||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
import("./select/CommunitySizeSelectScreen").then((m) => ({
|
||||||
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
default: m.CommunitySizeSelectScreen,
|
||||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
})),
|
||||||
|
{ 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 {
|
export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
||||||
switch (screenId) {
|
switch (screenId) {
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export function DecisionApproachesScreen() {
|
|||||||
[da.methods, selectedIds, state.customMethodCardMetaById],
|
[da.methods, selectedIds, state.customMethodCardMetaById],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
|
||||||
|
useMethodCardDeckOrdering(
|
||||||
"decisionApproaches",
|
"decisionApproaches",
|
||||||
mergedMethods,
|
mergedMethods,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -761,7 +762,11 @@ export function DecisionApproachesScreen() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
|
<div
|
||||||
|
className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0"
|
||||||
|
aria-busy={!recommendationsReady}
|
||||||
|
>
|
||||||
|
{recommendationsReady && (
|
||||||
<CardStack
|
<CardStack
|
||||||
cards={sampleCards}
|
cards={sampleCards}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
@@ -791,6 +796,7 @@ export function DecisionApproachesScreen() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
headerLockupSize={mdUp ? "L" : "M"}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Create
|
<Create
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import type { CreateFlowStep } from "../types";
|
|||||||
import {
|
import {
|
||||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||||
|
FIRST_STEP,
|
||||||
} from "./flowSteps";
|
} from "./flowSteps";
|
||||||
|
|
||||||
export const CREATE_ROUTES = {
|
export const CREATE_ROUTES = {
|
||||||
root: "/",
|
root: "/",
|
||||||
createRoot: "/create",
|
createRoot: "/create",
|
||||||
/** First step resolves via redirect from `/create`. */
|
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
|
||||||
createFirstStep: "/create",
|
createFirstStep: `/create/${FIRST_STEP}`,
|
||||||
review: "/create/review",
|
review: "/create/review",
|
||||||
finalReview: "/create/final-review",
|
finalReview: "/create/final-review",
|
||||||
completed: "/create/completed",
|
completed: "/create/completed",
|
||||||
|
|||||||
@@ -4,19 +4,92 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag
|
|||||||
|
|
||||||
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
|
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”
|
* Call **before** navigating into `/create` from marketing or profile “new rule”
|
||||||
* entry points so signed-in + sync matches an anonymous fresh start: wipe
|
* entry points so signed-in + sync matches an anonymous fresh start: wipe
|
||||||
* `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`.
|
* `localStorage` draft keys and, when sync is on and the user is signed in,
|
||||||
* Anonymous `DELETE` is harmless (401). Await ensures the server draft is gone
|
* `DELETE /api/drafts/me`.
|
||||||
* before mount so {@link SignedInDraftHydration} does not rehydrate stale work.
|
*
|
||||||
|
* 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.
|
* 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();
|
clearAnonymousCreateFlowStorage();
|
||||||
clearCoreValueDetailsLocalStorage();
|
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();
|
await deleteServerDraft();
|
||||||
|
} finally {
|
||||||
|
clearFreshEntryPending();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-2
@@ -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
|
// Signed-in product surfaces (`/create/*`, `/login`) run without the marketing
|
||||||
// footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g.
|
// footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g.
|
||||||
// CreateFlow) is composed in nested layouts.
|
// CreateFlow) is composed in nested layouts.
|
||||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import type { CreateFlowStep } from "../create/types";
|
import type { CreateFlowStep } from "../create/types";
|
||||||
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
|
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
|
||||||
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
|
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
|
||||||
import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry";
|
import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||||
import {
|
import {
|
||||||
ProfilePageSignedOutView,
|
ProfilePageSignedOutView,
|
||||||
@@ -253,10 +253,8 @@ export default function ProfilePageClient() {
|
|||||||
}, [draft, router]);
|
}, [draft, router]);
|
||||||
|
|
||||||
const handleStartNewCustomRule = useCallback(() => {
|
const handleStartNewCustomRule = useCallback(() => {
|
||||||
void (async () => {
|
prepareFreshCreateFlowEntrySync({ signedIn: true });
|
||||||
await prepareFreshCreateFlowEntry();
|
router.push("/create/informational");
|
||||||
router.push("/create");
|
|
||||||
})();
|
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleRequestDeleteDraft = useCallback(() => {
|
const handleRequestDeleteDraft = useCallback(() => {
|
||||||
|
|||||||
+10
-1
@@ -1,10 +1,19 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { notFound } from "next/navigation";
|
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.
|
// Development-only previews (e.g. `/components-preview`) — no public chrome.
|
||||||
export default function DevLayout({ children }: { children: ReactNode }) {
|
export default function DevLayout({ children }: { children: ReactNode }) {
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
return <main className="flex-1">{children}</main>;
|
return (
|
||||||
|
<MessagesProvider messages={messages}>
|
||||||
|
<AuthModalProvider>
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
</AuthModalProvider>
|
||||||
|
</MessagesProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|||||||
headline: post.frontmatter.title,
|
headline: post.frontmatter.title,
|
||||||
description: post.frontmatter.description,
|
description: post.frontmatter.description,
|
||||||
author: {
|
author: {
|
||||||
"@type": "Person",
|
"@type": "Organization",
|
||||||
name: post.frontmatter.author,
|
name: post.frontmatter.author,
|
||||||
},
|
},
|
||||||
publisher: {
|
publisher: {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import dynamic from "next/dynamic";
|
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
|
// Site footer is part of the public marketing chrome only — not rendered for
|
||||||
// signed-in product surfaces, admin dashboards, or dev previews. See
|
// 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 }) {
|
export default function MarketingLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<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>
|
<main className="flex-1">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</AuthModalProvider>
|
||||||
|
</MessagesProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,22 +29,18 @@ export default function LearnPage() {
|
|||||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||||
<ContentLockup {...contentLockupData} />
|
<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) => (
|
{allPosts.map((post) => (
|
||||||
<ContentThumbnailTemplate
|
<ContentThumbnailTemplate
|
||||||
key={`${post.slug}-horizontal`}
|
key={post.slug}
|
||||||
post={post}
|
post={post}
|
||||||
variant="horizontal"
|
variant="responsive"
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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"
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ export default function Page() {
|
|||||||
description: t("pages.home.heroBanner.description"),
|
description: t("pages.home.heroBanner.description"),
|
||||||
ctaText: t("pages.home.heroBanner.ctaText"),
|
ctaText: t("pages.home.heroBanner.ctaText"),
|
||||||
ctaHref: t("pages.home.heroBanner.ctaHref"),
|
ctaHref: t("pages.home.heroBanner.ctaHref"),
|
||||||
|
imageAlt: t("heroBanner.imageAlt"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardStepsData = {
|
const cardStepsData = {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||||
import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
import { prepareFreshCreateFlowEntrySync } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||||
import {
|
import {
|
||||||
buildTemplateReviewHref,
|
buildTemplateReviewHref,
|
||||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||||
@@ -102,13 +102,7 @@ function TemplatesGrid({
|
|||||||
entries={entries}
|
entries={entries}
|
||||||
onTemplateClick={(slug) => {
|
onTemplateClick={(slug) => {
|
||||||
if (!fromFlow) {
|
if (!fromFlow) {
|
||||||
void (async () => {
|
prepareFreshCreateFlowEntrySync();
|
||||||
await prepareFreshCreateFlowEntry();
|
|
||||||
router.push(
|
|
||||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
router.push(
|
router.push(
|
||||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import TripleTextBlock from "../../components/type/TripleTextBlock";
|
|||||||
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
|
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
|
||||||
import AskOrganizer from "../../components/sections/AskOrganizer";
|
import AskOrganizer from "../../components/sections/AskOrganizer";
|
||||||
import { MarketingRuleStackSection } from "../_components/MarketingRuleStackSection";
|
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(
|
const RelatedArticles = dynamic(
|
||||||
() => import("../../components/sections/RelatedArticles"),
|
() => import("../../components/sections/RelatedArticles"),
|
||||||
@@ -41,12 +44,12 @@ const CASE_STUDY_LINK_CLASS = [
|
|||||||
"active:scale-[0.98]",
|
"active:scale-[0.98]",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
/** Matches `pages.useCases.groups.items` order ↔ `public/assets/vector/*.svg`. */
|
/** Matches `pages.useCases.groups.items` order ↔ inlined vector mark components. */
|
||||||
const USE_CASES_GROUP_VECTOR_SLUGS = [
|
const USE_CASES_GROUP_MARKS = [
|
||||||
"worker-coop",
|
WorkerCoopMark,
|
||||||
"mutual-aid",
|
MutualAidMark,
|
||||||
"open-source",
|
OpenSourceMark,
|
||||||
"dao",
|
DaoMark,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__";
|
const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__";
|
||||||
@@ -77,24 +80,20 @@ export default function UseCasesPage() {
|
|||||||
page.groups.items,
|
page.groups.items,
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => ({
|
const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => {
|
||||||
|
const Mark = USE_CASES_GROUP_MARKS[index] ?? USE_CASES_GROUP_MARKS[0];
|
||||||
|
return {
|
||||||
...item,
|
...item,
|
||||||
icon: (
|
icon: (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element -- small vector marks from `public/assets/vector` */
|
<Mark
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="block size-9 shrink-0 object-contain"
|
className="block size-9 shrink-0"
|
||||||
height={36}
|
|
||||||
src={getAssetPath(
|
|
||||||
vectorMarkPath(
|
|
||||||
USE_CASES_GROUP_VECTOR_SLUGS[index] ?? USE_CASES_GROUP_VECTOR_SLUGS[0],
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
width={36}
|
width={36}
|
||||||
|
height={36}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const askOrganizerData = {
|
const askOrganizerData = {
|
||||||
title: page.askOrganizer.title,
|
title: page.askOrganizer.title,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
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. */
|
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
|
||||||
export default function MarketingCaseStudyLayout({
|
export default function MarketingCaseStudyLayout({
|
||||||
@@ -7,8 +10,12 @@ export default function MarketingCaseStudyLayout({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
<MessagesProvider messages={marketingMessages}>
|
||||||
|
<AuthModalProvider>
|
||||||
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
|
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
</AuthModalProvider>
|
||||||
|
</MessagesProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ const Avatar = memo<AvatarProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */
|
/* 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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ const Button = memo<ButtonProps>(
|
|||||||
// Note: State prop is informational for Figma alignment - actual state is handled by CSS pseudo-classes
|
// 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
|
// 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 combinedStyles = `${baseStyles} ${className}`;
|
||||||
|
|
||||||
const sharedA11y = {
|
const sharedA11y = {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import { memo } from "react";
|
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";
|
import type { CaseStudyProps } from "./CaseStudy.types";
|
||||||
|
|
||||||
const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
|
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)]",
|
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> = {
|
* Inline SVGR components avoid the network round-trip the prior `next/image`
|
||||||
lavender: getAssetPath(caseStudyVisualPath("lavender")),
|
* version required, so the illustration paints with the colored tile shell.
|
||||||
neutral: getAssetPath(caseStudyVisualPath("neutral")),
|
*/
|
||||||
rose: getAssetPath(caseStudyVisualPath("rose")),
|
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). */
|
/** Figma: ~23px corner (“Card / CaseStudy” shells). */
|
||||||
@@ -27,23 +41,26 @@ function CaseStudyView({
|
|||||||
visual,
|
visual,
|
||||||
className = "",
|
className = "",
|
||||||
}: CaseStudyProps) {
|
}: CaseStudyProps) {
|
||||||
|
const Art = SURFACE_ART[surface];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-figma-node="21993-32352"
|
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 ? (
|
{visual ? (
|
||||||
<div className="flex size-full items-center justify-center p-2">{visual}</div>
|
<div className="flex size-full items-center justify-center p-2">{visual}</div>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<div className="absolute inset-0">
|
||||||
src={SURFACE_ART[surface]}
|
<Art
|
||||||
alt={imageAlt}
|
role="img"
|
||||||
width={305}
|
aria-label={imageAlt}
|
||||||
height={305}
|
data-case-study-art={SURFACE_ART_DATA_KEY[surface]}
|
||||||
unoptimized
|
width="100%"
|
||||||
className="pointer-events-none size-full select-none object-contain object-center"
|
height="100%"
|
||||||
draggable={false}
|
className="pointer-events-none block select-none"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ const MiniContainer = memo<MiniProps>(
|
|||||||
return {
|
return {
|
||||||
wrapperElement: "div" as const,
|
wrapperElement: "div" as const,
|
||||||
wrapperProps: {
|
wrapperProps: {
|
||||||
...baseProps,
|
|
||||||
className: "block",
|
className: "block",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-5
@@ -23,14 +23,13 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
|||||||
}) => {
|
}) => {
|
||||||
const variant = variantProp;
|
const variant = variantProp;
|
||||||
const sizing = sizingProp;
|
const sizing = sizingProp;
|
||||||
// Get article-specific background image from frontmatter
|
|
||||||
const getBackgroundImage = (
|
const getBackgroundImage = (
|
||||||
post: ContentThumbnailTemplateProps["post"],
|
post: ContentThumbnailTemplateProps["post"],
|
||||||
variant: "vertical" | "horizontal",
|
orientation: "vertical" | "horizontal",
|
||||||
): string => {
|
): string => {
|
||||||
if (post.frontmatter?.thumbnail) {
|
if (post.frontmatter?.thumbnail) {
|
||||||
const imageName =
|
const imageName =
|
||||||
variant === "vertical"
|
orientation === "vertical"
|
||||||
? post.frontmatter.thumbnail.vertical
|
? post.frontmatter.thumbnail.vertical
|
||||||
: post.frontmatter.thumbnail.horizontal;
|
: post.frontmatter.thumbnail.horizontal;
|
||||||
|
|
||||||
@@ -47,12 +46,21 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
|||||||
? slug
|
? slug
|
||||||
: contentCatalogSlugForFallback(slug);
|
: contentCatalogSlugForFallback(slug);
|
||||||
|
|
||||||
return variant === "vertical"
|
return orientation === "vertical"
|
||||||
? contentBlogVerticalPath(resolvedSlug)
|
? contentBlogVerticalPath(resolvedSlug)
|
||||||
: contentBlogHorizontalPath(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 (
|
return (
|
||||||
<ContentThumbnailTemplateView
|
<ContentThumbnailTemplateView
|
||||||
@@ -61,6 +69,7 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
sizing={sizing}
|
sizing={sizing}
|
||||||
backgroundImage={backgroundImage}
|
backgroundImage={backgroundImage}
|
||||||
|
backgroundImageSmd={backgroundImageSmd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { BlogPost } from "../../../../lib/content";
|
import type { BlogPost } from "../../../../lib/content";
|
||||||
|
|
||||||
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
|
export type ContentThumbnailTemplateVariantValue =
|
||||||
|
| "vertical"
|
||||||
|
| "horizontal"
|
||||||
|
| "responsive";
|
||||||
|
|
||||||
export type ContentThumbnailTemplateSizingValue = "fluid" | "fixed";
|
export type ContentThumbnailTemplateSizingValue = "fluid" | "fixed";
|
||||||
|
|
||||||
@@ -8,7 +11,8 @@ export interface ContentThumbnailTemplateProps {
|
|||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
className?: string;
|
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;
|
variant?: ContentThumbnailTemplateVariantValue;
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +25,9 @@ export interface ContentThumbnailTemplateProps {
|
|||||||
export interface ContentThumbnailTemplateViewProps {
|
export interface ContentThumbnailTemplateViewProps {
|
||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
className: string;
|
className: string;
|
||||||
variant: "vertical" | "horizontal";
|
variant: ContentThumbnailTemplateVariantValue;
|
||||||
sizing: ContentThumbnailTemplateSizingValue;
|
sizing: ContentThumbnailTemplateSizingValue;
|
||||||
backgroundImage: string;
|
backgroundImage: string;
|
||||||
|
/** Wide-viewport image source for variant="responsive" (≥smd). */
|
||||||
|
backgroundImageSmd?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,41 @@ function ContentThumbnailTemplateView({
|
|||||||
variant,
|
variant,
|
||||||
sizing,
|
sizing,
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
|
backgroundImageSmd,
|
||||||
}: ContentThumbnailTemplateViewProps) {
|
}: 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 (variant === "vertical") {
|
||||||
if (sizing === "fixed") {
|
if (sizing === "fixed") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ function ChipView({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
if (e.target instanceof HTMLInputElement) return;
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleClick(e as unknown as React.MouseEvent<HTMLButtonElement>);
|
handleClick(e as unknown as React.MouseEvent<HTMLButtonElement>);
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
const sizeStyles =
|
const sizeStyles =
|
||||||
inputSize === "small"
|
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",
|
label: "text-[12px] leading-[16px] font-medium",
|
||||||
container: "gap-[6px]",
|
container: "gap-[6px]",
|
||||||
radius: "var(--measures-radius-200,8px)",
|
radius: "var(--measures-radius-200,8px)",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
|
|||||||
const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
|
const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
|
||||||
({ isOpen, onClose }) => {
|
({ isOpen, onClose }) => {
|
||||||
const t = useTranslation("modals.askOrganizerInquiry");
|
const t = useTranslation("modals.askOrganizerInquiry");
|
||||||
|
const tLogin = useTranslation("pages.login");
|
||||||
const copy = useMemo(
|
const copy = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
@@ -28,8 +29,9 @@ const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
|
|||||||
successDescription: t("successDescription"),
|
successDescription: t("successDescription"),
|
||||||
ariaDialog: t("ariaDialog"),
|
ariaDialog: t("ariaDialog"),
|
||||||
honeypotLabel: t("honeypotLabel"),
|
honeypotLabel: t("honeypotLabel"),
|
||||||
|
backToHome: tLogin("backToHome"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t, tLogin],
|
||||||
);
|
);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface AskOrganizerInquiryModalCopy {
|
|||||||
successDescription: string;
|
successDescription: string;
|
||||||
ariaDialog: string;
|
ariaDialog: string;
|
||||||
honeypotLabel: string;
|
honeypotLabel: string;
|
||||||
|
backToHome: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AskOrganizerInquiryModalViewProps
|
export interface AskOrganizerInquiryModalViewProps
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import Create from "../Create";
|
import Create from "../Create";
|
||||||
import TextInput from "../../controls/TextInput";
|
import TextInput from "../../controls/TextInput";
|
||||||
import TextArea from "../../controls/TextArea";
|
import TextArea from "../../controls/TextArea";
|
||||||
@@ -72,6 +73,15 @@ export function AskOrganizerInquiryModalView({
|
|||||||
ariaLabel={copy.ariaDialog}
|
ariaLabel={copy.ariaDialog}
|
||||||
footerContent={footer}
|
footerContent={footer}
|
||||||
footerClassName="!h-auto min-h-[112px] shrink-0 flex flex-col justify-end pb-8 pt-3 px-4"
|
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 ? (
|
{success ? (
|
||||||
<div className="flex flex-col gap-3 py-2">
|
<div className="flex flex-col gap-3 py-2">
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const CreateContainer = memo<CreateProps>(
|
|||||||
kebabTriggerAriaLabel,
|
kebabTriggerAriaLabel,
|
||||||
kebabMenuAriaLabel,
|
kebabMenuAriaLabel,
|
||||||
kebabMenuItems,
|
kebabMenuItems,
|
||||||
|
belowCard,
|
||||||
}) => {
|
}) => {
|
||||||
const createRef = useRef<HTMLDivElement>(null);
|
const createRef = useRef<HTMLDivElement>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -72,6 +73,7 @@ const CreateContainer = memo<CreateProps>(
|
|||||||
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
|
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
|
||||||
kebabMenuAriaLabel={kebabMenuAriaLabel}
|
kebabMenuAriaLabel={kebabMenuAriaLabel}
|
||||||
kebabMenuItems={kebabMenuItems}
|
kebabMenuItems={kebabMenuItems}
|
||||||
|
belowCard={belowCard}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface CreateProps {
|
|||||||
kebabTriggerAriaLabel?: string;
|
kebabTriggerAriaLabel?: string;
|
||||||
kebabMenuAriaLabel?: string;
|
kebabMenuAriaLabel?: string;
|
||||||
kebabMenuItems?: ModalHeaderMenuItem[];
|
kebabMenuItems?: ModalHeaderMenuItem[];
|
||||||
|
/** Rendered below the dialog card on the backdrop (e.g. “Back to home”). */
|
||||||
|
belowCard?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateViewProps {
|
export interface CreateViewProps {
|
||||||
@@ -73,4 +75,5 @@ export interface CreateViewProps {
|
|||||||
kebabTriggerAriaLabel?: string;
|
kebabTriggerAriaLabel?: string;
|
||||||
kebabMenuAriaLabel?: string;
|
kebabMenuAriaLabel?: string;
|
||||||
kebabMenuItems?: ModalHeaderMenuItem[];
|
kebabMenuItems?: ModalHeaderMenuItem[];
|
||||||
|
belowCard?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function CreateView({
|
|||||||
kebabTriggerAriaLabel,
|
kebabTriggerAriaLabel,
|
||||||
kebabMenuAriaLabel,
|
kebabMenuAriaLabel,
|
||||||
kebabMenuItems,
|
kebabMenuItems,
|
||||||
|
belowCard,
|
||||||
}: CreateViewProps) {
|
}: CreateViewProps) {
|
||||||
return (
|
return (
|
||||||
<CreateModalFrameView
|
<CreateModalFrameView
|
||||||
@@ -45,6 +46,7 @@ export function CreateView({
|
|||||||
ariaLabelledBy={ariaLabelledBy}
|
ariaLabelledBy={ariaLabelledBy}
|
||||||
overlayRef={overlayRef}
|
overlayRef={overlayRef}
|
||||||
dialogRef={createRef}
|
dialogRef={createRef}
|
||||||
|
belowCard={belowCard}
|
||||||
>
|
>
|
||||||
<ModalHeader
|
<ModalHeader
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type CreateModalFrameViewProps = {
|
|||||||
overlayRef: RefObject<HTMLDivElement | null>;
|
overlayRef: RefObject<HTMLDivElement | null>;
|
||||||
dialogRef: RefObject<HTMLDivElement | null>;
|
dialogRef: RefObject<HTMLDivElement | null>;
|
||||||
children: ReactNode;
|
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,
|
overlayRef,
|
||||||
dialogRef,
|
dialogRef,
|
||||||
children,
|
children,
|
||||||
|
belowCard,
|
||||||
}: CreateModalFrameViewProps) {
|
}: CreateModalFrameViewProps) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className={backdropOverlayClasses[backdropVariant]}
|
className={`${backdropOverlayClasses[backdropVariant]} flex flex-col items-center justify-center gap-6 overflow-y-auto px-4 py-8`}
|
||||||
onClick={onOverlayClick}
|
onClick={onOverlayClick}
|
||||||
aria-hidden="true"
|
role="presentation"
|
||||||
/>
|
>
|
||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-labelledby={ariaLabelledBy}
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</>
|
{belowCard ? (
|
||||||
|
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{belowCard}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const Footer = memo(() => {
|
|||||||
const tChrome = useTranslation("controlsChrome");
|
const tChrome = useTranslation("controlsChrome");
|
||||||
|
|
||||||
const linkFocusClass =
|
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 =
|
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";
|
"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`. */
|
/** Figma 18411:62925 (1024+): org name is one line, `w-full whitespace-nowrap`. */
|
||||||
const orgNameClass = `${bodyTextClass} lg: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. */
|
/** 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
|
// Schema markup for organization information
|
||||||
const schemaData = {
|
const schemaData = {
|
||||||
@@ -37,7 +37,11 @@ const Footer = memo(() => {
|
|||||||
name: t("organization.name"),
|
name: t("organization.name"),
|
||||||
email: t("organization.email"),
|
email: t("organization.email"),
|
||||||
url: t("organization.url"),
|
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 (
|
return (
|
||||||
@@ -86,7 +90,7 @@ const Footer = memo(() => {
|
|||||||
<div className={orgNameClass}>{t("organization.name")}</div>
|
<div className={orgNameClass}>{t("organization.name")}</div>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${t("organization.email")}`}
|
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")}
|
{t("organization.email")}
|
||||||
</a>
|
</a>
|
||||||
@@ -98,33 +102,48 @@ const Footer = memo(() => {
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={t("social.bluesky.url")}
|
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")}
|
aria-label={t("social.bluesky.ariaLabel")}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- social logo */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- social logo */}
|
||||||
<img
|
<img
|
||||||
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
|
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
|
||||||
alt="Bluesky"
|
alt=""
|
||||||
width={24}
|
width={24}
|
||||||
height={22}
|
height={22}
|
||||||
className="h-[21px] w-[24px] flex-shrink-0 transition-transform group-hover:scale-110"
|
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>
|
||||||
<a
|
<a
|
||||||
href={t("social.gitlab.url")}
|
href={t("social.gitea.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.gitlab.ariaLabel")}
|
aria-label={t("social.gitea.ariaLabel")}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
|
||||||
<img
|
<img
|
||||||
src={getAssetPath(ASSETS.GITLAB_ICON)}
|
src={getAssetPath(ASSETS.GITEA_ICON)}
|
||||||
alt="GitLab"
|
alt=""
|
||||||
width={22}
|
width={22}
|
||||||
height={22}
|
height={22}
|
||||||
className="h-5 w-[22px] flex-shrink-0 grayscale transition-transform group-hover:scale-110"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,19 +158,19 @@ const Footer = memo(() => {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/use-cases"
|
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")}
|
{t("navigation.useCases")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/learn"
|
href="/learn"
|
||||||
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
|
className={`text-left ${primaryLinkClass} md:text-right`}
|
||||||
>
|
>
|
||||||
{t("navigation.learn")}
|
{t("navigation.learn")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href="/about"
|
||||||
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
|
className={`text-left ${primaryLinkClass} md:text-right`}
|
||||||
>
|
>
|
||||||
{t("navigation.about")}
|
{t("navigation.about")}
|
||||||
</Link>
|
</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)]",
|
"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> =
|
const inverseModeStyles: Record<"default" | "hover" | "selected", string> =
|
||||||
{
|
{
|
||||||
default:
|
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:
|
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:
|
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
|
// Get state styles based on mode
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Button from "../../buttons/Button";
|
|||||||
import AvatarContainer from "../../asset/AvatarContainer";
|
import AvatarContainer from "../../asset/AvatarContainer";
|
||||||
import Avatar from "../../asset/Avatar";
|
import Avatar from "../../asset/Avatar";
|
||||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
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 { TopView } from "./Top.view";
|
||||||
import type { TopProps, NavSize } from "./Top.types";
|
import type { TopProps, NavSize } from "./Top.types";
|
||||||
|
|
||||||
@@ -51,16 +51,15 @@ const TopContainer = memo<TopProps>(
|
|||||||
/**
|
/**
|
||||||
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
|
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
|
||||||
* this button is always clicked from outside the wizard. Clears anonymous
|
* this button is always clicked from outside the wizard. Clears anonymous
|
||||||
* `localStorage` and, when backend sync is on, deletes the server draft
|
* `localStorage` synchronously and, when backend sync is on, fires the
|
||||||
* so signed-in users get the same fresh start as guests (see
|
* server `DELETE /api/drafts/me` in the background. `SignedInDraftHydration`
|
||||||
* {@link prepareFreshCreateFlowEntry}).
|
* reads the `create:fresh-entry-pending` sentinel and waits before fetching
|
||||||
|
* (see {@link prepareFreshCreateFlowEntrySync}).
|
||||||
*/
|
*/
|
||||||
const handleCreateRuleClick = useCallback(() => {
|
const handleCreateRuleClick = useCallback(() => {
|
||||||
void (async () => {
|
prepareFreshCreateFlowEntrySync({ signedIn: loggedIn });
|
||||||
await prepareFreshCreateFlowEntry();
|
router.push("/create/informational");
|
||||||
router.push("/create");
|
}, [loggedIn, router]);
|
||||||
})();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
// Schema markup for site navigation
|
// Schema markup for site navigation
|
||||||
const schemaData = {
|
const schemaData = {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const FeatureGridContainer = memo<FeatureGridProps>(
|
|||||||
panelContent: getAssetPath(featurePanelPath("support")),
|
panelContent: getAssetPath(featurePanelPath("support")),
|
||||||
...featurePanelLayout("support"),
|
...featurePanelLayout("support"),
|
||||||
ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"),
|
ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"),
|
||||||
href: "#decision-making",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[var(--color-surface-invert-brand-lime)]",
|
backgroundColor: "bg-[var(--color-surface-invert-brand-lime)]",
|
||||||
@@ -40,7 +39,6 @@ const FeatureGridContainer = memo<FeatureGridProps>(
|
|||||||
panelContent: getAssetPath(featurePanelPath("exercises")),
|
panelContent: getAssetPath(featurePanelPath("exercises")),
|
||||||
...featurePanelLayout("exercises"),
|
...featurePanelLayout("exercises"),
|
||||||
ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"),
|
ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"),
|
||||||
href: "#values-alignment",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[var(--color-surface-invert-brand-rust)]",
|
backgroundColor: "bg-[var(--color-surface-invert-brand-rust)]",
|
||||||
@@ -53,7 +51,6 @@ const FeatureGridContainer = memo<FeatureGridProps>(
|
|||||||
panelContent: getAssetPath(featurePanelPath("guidance")),
|
panelContent: getAssetPath(featurePanelPath("guidance")),
|
||||||
...featurePanelLayout("guidance"),
|
...featurePanelLayout("guidance"),
|
||||||
ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"),
|
ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"),
|
||||||
href: "#membership-guidance",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||||
@@ -66,7 +63,6 @@ const FeatureGridContainer = memo<FeatureGridProps>(
|
|||||||
panelContent: getAssetPath(featurePanelPath("tools")),
|
panelContent: getAssetPath(featurePanelPath("tools")),
|
||||||
...featurePanelLayout("tools"),
|
...featurePanelLayout("tools"),
|
||||||
ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"),
|
ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"),
|
||||||
href: "#conflict-resolution",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export interface Feature {
|
|||||||
panelHeight: number;
|
panelHeight: number;
|
||||||
panelImageClassName?: string;
|
panelImageClassName?: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
href: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureGridViewProps extends FeatureGridProps {
|
export interface FeatureGridViewProps extends FeatureGridProps {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function FeatureGridView({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-figma-node="18847-22410"
|
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="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">
|
<div className="lg:min-w-0 lg:shrink">
|
||||||
@@ -52,7 +52,6 @@ function FeatureGridView({
|
|||||||
panelHeight={feature.panelHeight}
|
panelHeight={feature.panelHeight}
|
||||||
panelImageClassName={feature.panelImageClassName}
|
panelImageClassName={feature.panelImageClassName}
|
||||||
ariaLabel={feature.ariaLabel}
|
ariaLabel={feature.ariaLabel}
|
||||||
href={feature.href}
|
|
||||||
featureGridShell
|
featureGridShell
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,28 +1,39 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Figma: "Sections / Hero" (see registry)
|
* Figma: "Sections / Hero" (see registry)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import Image from "next/image";
|
||||||
import ContentLockup from "../../type/ContentLockup";
|
import ContentLockup from "../../type/ContentLockup";
|
||||||
import HeroDecor from "./HeroDecor";
|
import HeroDecor from "./HeroDecor";
|
||||||
import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
|
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 {
|
interface HeroBannerProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
ctaText?: string;
|
ctaText?: string;
|
||||||
ctaHref?: string;
|
ctaHref?: string;
|
||||||
|
imageAlt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeroBanner = memo<HeroBannerProps>(
|
const HeroBanner = memo<HeroBannerProps>(
|
||||||
({ title, subtitle, description, ctaText, ctaHref }) => {
|
({
|
||||||
const t = useTranslation();
|
title,
|
||||||
const imageAlt = t("heroBanner.imageAlt");
|
subtitle,
|
||||||
|
description,
|
||||||
|
ctaText,
|
||||||
|
ctaHref,
|
||||||
|
imageAlt = "Hero illustration",
|
||||||
|
}) => {
|
||||||
return (
|
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)]">
|
<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)]">
|
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
|
||||||
@@ -49,14 +60,15 @@ const HeroBanner = memo<HeroBannerProps>(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero Image Container */}
|
{/* Hero Image Container */}
|
||||||
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
|
<div className="relative z-10 flex w-full items-center justify-center overflow-hidden rounded-[8px] aspect-[16/10] md:flex-1">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- dynamic path from getAssetPath */}
|
<Image
|
||||||
<img
|
|
||||||
src={getAssetPath(ASSETS.HERO_IMAGE)}
|
src={getAssetPath(ASSETS.HERO_IMAGE)}
|
||||||
alt={imageAlt}
|
alt={imageAlt}
|
||||||
className="w-full h-auto"
|
width={HERO_IMAGE_WIDTH}
|
||||||
loading="eager"
|
height={HERO_IMAGE_HEIGHT}
|
||||||
fetchPriority="high"
|
priority
|
||||||
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
|
className="size-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo, useEffect, useState } from "react";
|
||||||
|
|
||||||
interface HeroDecorProps {
|
interface HeroDecorProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeroDecor = memo<HeroDecorProps>(({ className = "" }) => {
|
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 (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
|
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
|
||||||
@@ -59,7 +70,7 @@ const HeroDecor = memo<HeroDecorProps>(({ className = "" }) => {
|
|||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* apply filter only to the decoration paths */}
|
{/* 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="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" />
|
<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")),
|
src: getAssetPath(partnerLogoPath("food-not-bombs")),
|
||||||
alt: t("partners.foodNotBombs"),
|
alt: t("partners.foodNotBombs"),
|
||||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||||
order: "order-1 sm:order-4",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: getAssetPath(partnerLogoPath("start-coop")),
|
src: getAssetPath(partnerLogoPath("start-coop")),
|
||||||
alt: t("partners.startCoop"),
|
alt: t("partners.startCoop"),
|
||||||
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
||||||
order: "order-2 sm:order-2",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: getAssetPath(partnerLogoPath("metagov")),
|
src: getAssetPath(partnerLogoPath("metagov")),
|
||||||
alt: t("partners.metagov"),
|
alt: t("partners.metagov"),
|
||||||
size: "h-6 lg:h-8 xl:h-[41px]",
|
size: "h-6 lg:h-8 xl:h-[41px]",
|
||||||
order: "order-3 sm:order-1",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: getAssetPath(partnerLogoPath("open-civics")),
|
src: getAssetPath(partnerLogoPath("open-civics")),
|
||||||
alt: t("partners.openCivics"),
|
alt: t("partners.openCivics"),
|
||||||
size: "h-8 lg:h-10 xl:h-[50px]",
|
size: "h-8 lg:h-10 xl:h-[50px]",
|
||||||
order: "order-4 sm:order-5 md:order-6",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: getAssetPath(partnerLogoPath("mutual-aid-co")),
|
src: getAssetPath(partnerLogoPath("mutual-aid-co")),
|
||||||
alt: t("partners.mutualAidCo"),
|
alt: t("partners.mutualAidCo"),
|
||||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||||
order: "order-5 sm:order-6 md:order-5",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: getAssetPath(partnerLogoPath("cu-boulder")),
|
src: getAssetPath(partnerLogoPath("cu-boulder")),
|
||||||
alt: t("partners.cuBoulder"),
|
alt: t("partners.cuBoulder"),
|
||||||
size: "h-10 lg:h-12 xl:h-[60px]",
|
size: "h-10 lg:h-12 xl:h-[60px]",
|
||||||
order: "order-6 sm:order-3",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t],
|
[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}`}
|
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)]">
|
<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
|
<div
|
||||||
className={`transition-opacity duration-500 ${
|
className={`transition-opacity duration-500 ${
|
||||||
isVisible ? "opacity-60" : "opacity-0"
|
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) => (
|
{displayLogos.map((logo, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
|||||||
onError,
|
onError,
|
||||||
}) => {
|
}) => {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [imageLoading, setImageLoading] = useState(true);
|
|
||||||
|
|
||||||
// Variant configurations
|
// Variant configurations
|
||||||
const variants: Record<string, VariantConfig> = {
|
const variants: Record<string, VariantConfig> = {
|
||||||
@@ -97,16 +96,13 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
|||||||
const quoteId = `${baseId}-content`;
|
const quoteId = `${baseId}-content`;
|
||||||
const authorId = `${baseId}-author`;
|
const authorId = `${baseId}-author`;
|
||||||
|
|
||||||
// Error handling functions
|
|
||||||
const handleImageError = (error: unknown) => {
|
const handleImageError = (error: unknown) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
setImageError(true);
|
setImageError(true);
|
||||||
setImageLoading(false);
|
|
||||||
|
|
||||||
// Call error callback if provided
|
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError({
|
onError({
|
||||||
type: "image_load_error",
|
type: "image_load_error",
|
||||||
@@ -118,11 +114,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageLoad = () => {
|
|
||||||
setImageLoading(false);
|
|
||||||
setImageError(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required props
|
// Validate required props
|
||||||
if (variantProp === "statement") {
|
if (variantProp === "statement") {
|
||||||
if (!quote?.trim() || !quoteSecondary?.trim()) {
|
if (!quote?.trim() || !quoteSecondary?.trim()) {
|
||||||
@@ -166,9 +157,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
|||||||
authorId={authorId}
|
authorId={authorId}
|
||||||
config={config}
|
config={config}
|
||||||
imageError={imageError}
|
imageError={imageError}
|
||||||
imageLoading={imageLoading}
|
|
||||||
currentAvatarSrc={currentAvatarSrc}
|
currentAvatarSrc={currentAvatarSrc}
|
||||||
onImageLoad={handleImageLoad}
|
|
||||||
onImageError={handleImageError}
|
onImageError={handleImageError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ export interface QuoteBlockViewProps {
|
|||||||
authorId: string;
|
authorId: string;
|
||||||
config: VariantConfig;
|
config: VariantConfig;
|
||||||
imageError: boolean;
|
imageError: boolean;
|
||||||
imageLoading: boolean;
|
|
||||||
currentAvatarSrc: string;
|
currentAvatarSrc: string;
|
||||||
onImageLoad: () => void;
|
|
||||||
onImageError: (_error: unknown) => void;
|
onImageError: (_error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ function QuoteBlockView({
|
|||||||
authorId,
|
authorId,
|
||||||
config,
|
config,
|
||||||
imageError,
|
imageError,
|
||||||
imageLoading,
|
|
||||||
currentAvatarSrc,
|
currentAvatarSrc,
|
||||||
onImageLoad,
|
|
||||||
onImageError,
|
onImageError,
|
||||||
}: QuoteBlockViewProps) {
|
}: QuoteBlockViewProps) {
|
||||||
const t = useTranslation("quoteBlock");
|
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.gap} relative z-10`}>
|
||||||
<div className={`flex flex-col ${config.avatarGap}`}>
|
<div className={`flex flex-col ${config.avatarGap}`}>
|
||||||
{/* Avatar with error handling */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{!imageError ? (
|
{!imageError ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -97,26 +94,12 @@ function QuoteBlockView({
|
|||||||
alt={avatarAlt}
|
alt={avatarAlt}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className={`filter sepia ${
|
className={`filter sepia ${config.avatar}`}
|
||||||
config.avatar
|
loading="eager"
|
||||||
} transition-opacity duration-300 ${
|
priority
|
||||||
imageLoading ? "opacity-0" : "opacity-100"
|
|
||||||
}`}
|
|
||||||
loading="lazy"
|
|
||||||
onError={onImageError}
|
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
|
<div
|
||||||
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
|
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 { useRouter } from "next/navigation";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import { logger } from "../../../../lib/logger";
|
import { logger } from "../../../../lib/logger";
|
||||||
import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||||
import {
|
import {
|
||||||
fetchTemplates,
|
fetchTemplates,
|
||||||
isTemplatesFetchAborted,
|
isTemplatesFetchAborted,
|
||||||
@@ -99,10 +99,8 @@ const RuleStackContainer = memo<RuleStackProps>(
|
|||||||
logger.debug(`${slug} template clicked`);
|
logger.debug(`${slug} template clicked`);
|
||||||
// Marketing home “Popular templates”: same fresh start as Top “Create rule”
|
// Marketing home “Popular templates”: same fresh start as Top “Create rule”
|
||||||
// (local + server draft when sync) so stale state cannot break template apply.
|
// (local + server draft when sync) so stale state cannot break template apply.
|
||||||
void (async () => {
|
prepareFreshCreateFlowEntrySync();
|
||||||
await prepareFreshCreateFlowEntry();
|
|
||||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
||||||
})();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ function columnUsesLargeBreakpointCopy(column: TripleTextBlockColumn): boolean {
|
|||||||
return column.lgTitle !== undefined || column.lgDescription !== undefined;
|
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 }) {
|
function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn }) {
|
||||||
return (
|
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)]">
|
<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({
|
function TripleTextBlockColumnLockup({
|
||||||
column,
|
column,
|
||||||
layoutPreset,
|
layoutPreset,
|
||||||
@@ -38,7 +69,7 @@ function TripleTextBlockColumnLockup({
|
|||||||
layoutPreset: "default" | "useCases";
|
layoutPreset: "default" | "useCases";
|
||||||
}) {
|
}) {
|
||||||
if (layoutPreset === "useCases") {
|
if (layoutPreset === "useCases") {
|
||||||
return <TripleTextUseCasesColumn column={column} />;
|
return <TripleTextStackedColumn column={column} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dual = columnUsesLargeBreakpointCopy(column);
|
const dual = columnUsesLargeBreakpointCopy(column);
|
||||||
@@ -47,24 +78,26 @@ function TripleTextBlockColumnLockup({
|
|||||||
|
|
||||||
if (!dual) {
|
if (!dual) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<TripleTextStackedColumn column={column} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:block">
|
||||||
<ContentLockup
|
<ContentLockup
|
||||||
variant="about"
|
variant="about"
|
||||||
alignment="left"
|
alignment="left"
|
||||||
subtitle={column.title}
|
subtitle={column.title}
|
||||||
description={column.description}
|
description={column.description}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<ContentLockup
|
<TripleTextStackedColumn column={column} />
|
||||||
variant="about"
|
|
||||||
alignment="left"
|
|
||||||
subtitle={column.title}
|
|
||||||
description={column.description}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<ContentLockup
|
<ContentLockup
|
||||||
@@ -105,7 +138,7 @@ function TripleTextBlockView({
|
|||||||
className={`bg-black py-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] ${
|
className={`bg-black py-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] ${
|
||||||
isUseCases
|
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-[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()}
|
} ${className}`.trim()}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -131,7 +164,7 @@ function TripleTextBlockView({
|
|||||||
className={
|
className={
|
||||||
isUseCases
|
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-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) => (
|
{columns.map((column, index) => (
|
||||||
|
|||||||
+54
-15
@@ -1,15 +1,19 @@
|
|||||||
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AuthModalProvider } from "./contexts/AuthModalContext";
|
|
||||||
import { MessagesProvider } from "./contexts/MessagesContext";
|
|
||||||
import messages from "../messages/en/index";
|
import messages from "../messages/en/index";
|
||||||
import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import ConditionalNavigation from "./components/navigation/ConditionalNavigation";
|
|
||||||
|
|
||||||
/** Header reads `cr_session` via Server Components; must not use prerendered guest HTML. */
|
// `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx`
|
||||||
export const dynamic = "force-dynamic";
|
// (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({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -34,7 +38,9 @@ const spaceGrotesk = Space_Grotesk({
|
|||||||
weight: ["400", "500", "700"],
|
weight: ["400", "500", "700"],
|
||||||
variable: "--font-space-grotesk",
|
variable: "--font-space-grotesk",
|
||||||
display: "swap",
|
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"],
|
fallback: ["system-ui", "arial"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +66,27 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
metadataBase: new URL("https://communityrule.com"),
|
metadataBase: new URL("https://communityrule.com"),
|
||||||
icons: {
|
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: {
|
alternates: {
|
||||||
canonical: "/",
|
canonical: "/",
|
||||||
@@ -96,17 +122,30 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="font-sans">
|
<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
|
<body
|
||||||
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
|
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
|
||||||
>
|
>
|
||||||
<MessagesProvider messages={messages}>
|
<div className="min-h-screen flex flex-col">{children}</div>
|
||||||
<AuthModalProvider>
|
|
||||||
<div className="min-h-screen flex flex-col">
|
|
||||||
<ConditionalNavigation />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</AuthModalProvider>
|
|
||||||
</MessagesProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Your Article Title Here"
|
title: "Your Article Title Here"
|
||||||
description: "A brief, compelling description of what this article covers"
|
description: "A brief, compelling description of what this article covers"
|
||||||
author: "Author Name"
|
author: "CommunityRule"
|
||||||
date: "2025-01-15"
|
date: "2025-01-15"
|
||||||
related: ["slug-of-related-article-1", "slug-of-related-article-2"]
|
related: ["slug-of-related-article-1", "slug-of-related-article-2"]
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Avoiding Burnout: Sustainability in the Ruins"
|
title: "Avoiding Burnout: Sustainability in the Ruins"
|
||||||
description: "Building a practice of resistance that doesn't consume you"
|
description: "Building a practice of resistance that doesn't consume you"
|
||||||
author: "Author name"
|
author: "CommunityRule"
|
||||||
date: "2025-08-12"
|
date: "2025-08-12"
|
||||||
related:
|
related:
|
||||||
- "resolving-active-conflicts"
|
- "resolving-active-conflicts"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Digital Mediation and the Death of Nuance"
|
title: "Digital Mediation and the Death of Nuance"
|
||||||
description: "How corporate platforms undermine solidarity and what to build instead"
|
description: "How corporate platforms undermine solidarity and what to build instead"
|
||||||
author: "Author name"
|
author: "CommunityRule"
|
||||||
date: "2025-08-18"
|
date: "2025-08-18"
|
||||||
related:
|
related:
|
||||||
- "operational-security-mutual-aid"
|
- "operational-security-mutual-aid"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "How Chaos Concentrates Control"
|
title: "How Chaos Concentrates Control"
|
||||||
description: "How to limit informal hierarchies inevitably emerging in horizontal groups"
|
description: "How to limit informal hierarchies inevitably emerging in horizontal groups"
|
||||||
author: "Author name"
|
author: "CommunityRule"
|
||||||
date: "2025-08-15"
|
date: "2025-08-15"
|
||||||
related:
|
related:
|
||||||
- "making-decisions-without-hierarchy"
|
- "making-decisions-without-hierarchy"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Integrating New Members Without Dilution"
|
title: "Integrating New Members Without Dilution"
|
||||||
description: "How to Bring New People In Without Everything Falling Apart"
|
description: "How to Bring New People In Without Everything Falling Apart"
|
||||||
author: "Author name"
|
author: "CommunityRule"
|
||||||
date: "2025-08-05"
|
date: "2025-08-05"
|
||||||
related:
|
related:
|
||||||
- "making-decisions-without-hierarchy"
|
- "making-decisions-without-hierarchy"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Knowledge Management and Institutional Amnesia"
|
title: "Knowledge Management and Institutional Amnesia"
|
||||||
description: "Preserving what we learn without surveillance infrastructure"
|
description: "Preserving what we learn without surveillance infrastructure"
|
||||||
author: "Author name"
|
author: "CommunityRule"
|
||||||
date: "2025-08-20"
|
date: "2025-08-20"
|
||||||
related:
|
related:
|
||||||
- "integrating-new-members-without-dilution"
|
- "integrating-new-members-without-dilution"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Making decisions without hierarchy"
|
title: "Making decisions without hierarchy"
|
||||||
description: "A brief guide to collaborative nonhierarchical decision making"
|
description: "A brief guide to collaborative nonhierarchical decision making"
|
||||||
author: "Author name"
|
author: "CommunityRule"
|
||||||
date: "2025-08-01"
|
date: "2025-08-01"
|
||||||
related:
|
related:
|
||||||
- "resolving-active-conflicts"
|
- "resolving-active-conflicts"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Operational Security for Mutual Aid"
|
title: "Operational Security for Mutual Aid"
|
||||||
description: "Why protecting information isn't paranoia: it's care work in a hostile world"
|
description: "Why protecting information isn't paranoia: it's care work in a hostile world"
|
||||||
author: "Author name"
|
author: "CommunityRule"
|
||||||
date: "2025-08-10"
|
date: "2025-08-10"
|
||||||
related:
|
related:
|
||||||
- "resolving-active-conflicts"
|
- "resolving-active-conflicts"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Resolving Active Conflicts"
|
title: "Resolving Active Conflicts"
|
||||||
description: "Practical steps for resolving conflicts while maintaining trust, cooperation, and shared goals"
|
description: "Practical steps for resolving conflicts while maintaining trust, cooperation, and shared goals"
|
||||||
author: "Author name"
|
author: "CommunityRule"
|
||||||
date: "2025-04-15"
|
date: "2025-04-15"
|
||||||
related:
|
related:
|
||||||
- "operational-security-mutual-aid"
|
- "operational-security-mutual-aid"
|
||||||
|
|||||||
@@ -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/backend-linear-tickets.md](./guides/backend-linear-tickets.md)
|
||||||
- [guides/template-recommendation-matrix.md](./guides/template-recommendation-matrix.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-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
|
## Cursor rules
|
||||||
|
|
||||||
|
|||||||
@@ -632,9 +632,9 @@ _Section B — Final Review screen `+` button per category:_
|
|||||||
|
|
||||||
**Depends on:** Tickets 1–8 complete enough to deploy a vertical slice.
|
**Depends on:** Tickets 1–8 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.
|
**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.
|
- **§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).
|
- **§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 (~5–15 min downtime).
|
- **§5 Cutover plan** — staging at `staging.communityrule.info`, soft-launch, apex cutover at scheduled low-traffic window (~5–15 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).
|
- **§7 Old vs new deltas** (LAMP-package detail, watchdog, OTP→magic link, sender, API surface, chatbot).
|
||||||
- **§8 Follow-up tickets** (the six tickets below).
|
- **§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).
|
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] 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] 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] 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).
|
**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 |
|
| 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** |
|
| 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 |
|
| 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 |
|
| 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)
|
### 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** | — |
|
| 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** | — |
|
| 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 (pre–CR-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 |
|
| 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) |
|
| 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) | ops | Backlog | CR-98 green + CR-102 resolved |
|
| 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 |
|
| 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`
|
**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)).
|
([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
|
**CR-102** (legacy rules Gitea export) runs during the CR-99 cutover window
|
||||||
cutover window.
|
([§6.1](ops-backend-deploy.md#61-legacy-rules-archive-cr-102)).
|
||||||
|
|
||||||
**Per-ticket detail:**
|
**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`.
|
- **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.
|
- **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.
|
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.
|
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 **10–11** 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.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.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.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.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 | Docs PR — after CR-98 |
|
| 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.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 | — |
|
| 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** | — |
|
| 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) | — |
|
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) | — |
|
||||||
|
|||||||
@@ -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.
|
**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):**
|
**Admin / infra (coordinate with whoever runs the server):**
|
||||||
|
|
||||||
|
|||||||
@@ -131,11 +131,13 @@ apex.
|
|||||||
only step with brief downtime (~5–15 min). Sequence:
|
only step with brief downtime (~5–15 min). Sequence:
|
||||||
1. Take one final manual backup of the legacy LAMP app (Cloudron
|
1. Take one final manual backup of the legacy LAMP app (Cloudron
|
||||||
*Backups* tab → *Backup now*).
|
*Backups* tab → *Backup now*).
|
||||||
2. `cloudron uninstall` the legacy app at `communityrule.info`.
|
2. Export legacy `rules` + `version_history` to the Gitea archive
|
||||||
3. `cloudron configure --location communityrule.info` to move the
|
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`
|
validated staging install to the apex (or `cloudron install`
|
||||||
fresh at apex if cleaner).
|
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.
|
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).
|
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.
|
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
|
1. **Final URL — `communityrule.info` apex.** New app fully replaces
|
||||||
the legacy site, including the marketing surface. Brief cutover
|
the legacy site, including the marketing surface. Brief cutover
|
||||||
downtime (~5–15 min) is accepted.
|
downtime (~5–15 min) is accepted.
|
||||||
2. **Legacy `rules` data — not migrated.** No data moves into the new
|
2. **Legacy `rules` data — not migrated; exported to Gitea.** No data
|
||||||
app's Postgres. A pre-cutover **read-only export** of the
|
moves into the new app's Postgres. Before CR-99 uninstalls the
|
||||||
`rules` + `version_history` MySQL tables is under consideration;
|
legacy MySQL, operators export the `rules` + `version_history`
|
||||||
approach depends on the actual row count, which we'll pull as
|
tables to a new read-only Gitea repo on `git.medlab.host` (see
|
||||||
part of the CR-99 pre-cutover backup. Tracked in
|
[§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).
|
[CR-102](https://linear.app/community-rule/issue/CR-102/backend-decide-fate-of-legacy-rules-table-read-only-export).
|
||||||
|
|
||||||
Infra decision closed:
|
Infra decision closed:
|
||||||
@@ -184,6 +186,50 @@ Infra decision closed:
|
|||||||
Container Registry** app and re-tag against its hostname; no other changes
|
Container Registry** app and re-tag against its hostname; no other changes
|
||||||
required.
|
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
|
## 7. Old vs new deltas
|
||||||
|
|
||||||
So nothing surprises anyone at cutover:
|
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)
|
4. [**CR-99**](https://linear.app/community-rule/issue/CR-99/backend-cloudron-production-install-apex-cutover)
|
||||||
— `[Backend] Cloudron production install + apex cutover`.
|
— `[Backend] Cloudron production install + apex cutover`.
|
||||||
Side-by-side cutover at scheduled low-traffic window per §5.
|
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)
|
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
|
— `[Backend] Steady-state operator runbook` (**Done** —
|
||||||
(write what we actually did).
|
[`ops-runbook.md`](ops-runbook.md)).
|
||||||
6. [**CR-101**](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app)
|
6. [**CR-101**](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-communityrule-lamp-app)
|
||||||
— `[Backend] Decommission legacy CommunityRule LAMP app`.
|
— `[Backend] Decommission legacy CommunityRule LAMP app`.
|
||||||
Uninstall the entire LAMP slot (marketing + Express backend +
|
Uninstall the entire LAMP slot (marketing + Express backend +
|
||||||
chatbot in one go); preserve final backup ≥ 90 days. Blocked by
|
chatbot in one go); preserve final backup ≥ 90 days. Blocked by
|
||||||
CR-99 + sign-off window. Priority: Low.
|
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)
|
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?)`.
|
— `[Backend] Legacy rules archive export`. Decision: export to Gitea
|
||||||
Count rows + decide whether to publish a static archive before
|
(§6.1). Execute during the CR-99 maintenance window before
|
||||||
CR-99 uninstalls the legacy MySQL. Priority: Low.
|
uninstall. Priority: Low.
|
||||||
|
|
||||||
## 9. Build and push image workflow
|
## 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.
|
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
|
## 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
|
- [`docs/guides/backend-roadmap.md`](backend-roadmap.md) §11
|
||||||
(environments) and §8 (Prisma migrations policy).
|
(environments) and §8 (Prisma migrations policy).
|
||||||
- [`docs/guides/backend-linear-tickets.md`](backend-linear-tickets.md)
|
- [`docs/guides/backend-linear-tickets.md`](backend-linear-tickets.md)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
- **Blog art** stays under `public/content/blog/` with
|
||||||
`{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`.
|
`{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`.
|
||||||
- **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in
|
- **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in
|
||||||
`app/layout.tsx` metadata). Do not place `favicon.ico` or other static
|
`app/layout.tsx` metadata) plus generated root binaries for Safari/iOS:
|
||||||
binaries under `app/` — keep `app/` for routes, layouts, and styles only
|
`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`).
|
(`globals.css`, `tailwind.css`).
|
||||||
|
|
||||||
## PNG files and `.gitignore`
|
## PNG files and `.gitignore`
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -23,7 +23,7 @@ All three retire together when the new app goes live. The chatbot is **not** bei
|
|||||||
## What does NOT carry over
|
## What does NOT carry over
|
||||||
|
|
||||||
- **No user accounts.** New sign-ins start fresh.
|
- **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.**
|
- **No chatbot.**
|
||||||
|
|
||||||
## How the cutover will work
|
## 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.
|
`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 5–15 minutes of apex downtime):
|
2. **Cutover phase.** When staging is green and we're ready, schedule a low-traffic window. During the window (roughly 5–15 minutes of apex downtime):
|
||||||
- Take a final backup of the legacy app (Cloudron one-click).
|
- 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`.
|
- Uninstall the legacy app at the apex `communityrule.info`.
|
||||||
- Move the new app to the apex.
|
- Move the new app to the apex.
|
||||||
- Smoke-test, confirm backups are on, done.
|
- 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).
|
3. **Install at staging** subdomain, smoke test, soft launch (CR-98).
|
||||||
4. **Apex cutover window** — the brief downtime above.
|
4. **Apex cutover window** — the brief downtime above.
|
||||||
5. **Uninstall legacy**, archive legacy repos.
|
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.
|
Staging should be ready to deploy in 1-2 weeks, and we can go from there.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import nextPlugin from "@next/eslint-plugin-next";
|
|||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import react from "eslint-plugin-react";
|
import react from "eslint-plugin-react";
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactCompiler from "eslint-plugin-react-compiler";
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
// Base JavaScript recommended rules
|
// Base JavaScript recommended rules
|
||||||
@@ -51,6 +52,7 @@ const eslintConfig = [
|
|||||||
plugins: {
|
plugins: {
|
||||||
react,
|
react,
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
|
"react-compiler": reactCompiler,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
@@ -62,6 +64,9 @@ const eslintConfig = [
|
|||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"react/react-in-jsx-scope": "off", // React 19 doesn't require React import
|
"react/react-in-jsx-scope": "off", // React 19 doesn't require React import
|
||||||
"react/prop-types": "off", // Using TypeScript for prop validation
|
"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
|
// TypeScript files configuration
|
||||||
@@ -90,6 +95,7 @@ const eslintConfig = [
|
|||||||
"@next/next": nextPlugin,
|
"@next/next": nextPlugin,
|
||||||
react,
|
react,
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
|
"react-compiler": reactCompiler,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
@@ -101,6 +107,9 @@ const eslintConfig = [
|
|||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"react/react-in-jsx-scope": "off", // React 19 doesn't require React import
|
"react/react-in-jsx-scope": "off", // React 19 doesn't require React import
|
||||||
"react/prop-types": "off", // Using TypeScript for prop validation
|
"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": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-1
@@ -209,7 +209,8 @@ export const ASSETS = {
|
|||||||
|
|
||||||
// Social media
|
// Social media
|
||||||
BLUESKY_LOGO: "assets/logos/bluesky.svg",
|
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 page decorative shapes
|
||||||
CONTENT_SHAPE_1: "assets/shapes/content-shape-1.svg",
|
CONTENT_SHAPE_1: "assets/shapes/content-shape-1.svg",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"ariaLabels": {
|
"ariaLabels": {
|
||||||
"followBluesky": "Follow us on Bluesky",
|
"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",
|
"featureToolsAndServices": "Feature tools and services",
|
||||||
"askOrganizerContact": "Ask an organizer - Contact an organizer for help"
|
"askOrganizerContact": "Ask an organizer - Contact an organizer for help"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,19 @@
|
|||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
"bluesky": {
|
"bluesky": {
|
||||||
"handle": "medlabboulder",
|
"label": "Bluesky",
|
||||||
"ariaLabel": "Follow us on Bluesky",
|
"ariaLabel": "Follow us on Bluesky",
|
||||||
"url": "https://bsky.app/profile/medlabboulder"
|
"url": "https://bsky.app/profile/medlabboulder.bsky.social"
|
||||||
},
|
},
|
||||||
"gitlab": {
|
"gitea": {
|
||||||
"handle": "medlabboulder",
|
"label": "Gitea",
|
||||||
"ariaLabel": "Follow us on GitLab",
|
"ariaLabel": "View source on Gitea",
|
||||||
"url": "https://gitlab.com/medlabboulder"
|
"url": "https://git.medlab.host/CommunityRule/community-rule"
|
||||||
|
},
|
||||||
|
"mastodon": {
|
||||||
|
"label": "Mastodon",
|
||||||
|
"ariaLabel": "Follow us on Mastodon",
|
||||||
|
"url": "https://social.medlab.host/@medlab"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
|
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
|
||||||
"placeholder": "email@domain.com",
|
"placeholder": "email@domain.com",
|
||||||
"characterCountTemplate": "{current}/{max}",
|
"characterCountTemplate": "{current}/{max}",
|
||||||
"magicLinkSuccessTitle": "Check your email to log in!",
|
"magicLinkSuccessTitle": "Check your email to log in",
|
||||||
"magicLinkSuccessDescription": "Your account is created, now just check your email for a magic link",
|
"magicLinkSuccessDescription": "Your account has been created. A login link has been emailed to you.",
|
||||||
"magicLinkErrorTitle": "Could not send link"
|
"magicLinkErrorTitle": "Could not send link"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"addButtonText": "Add maturity"
|
"addButtonText": "Add maturity"
|
||||||
},
|
},
|
||||||
"organizationTypes": [
|
"organizationTypes": [
|
||||||
{ "label": "Worker’s coop" },
|
{ "label": "Worker cooperative" },
|
||||||
{ "label": "Mutual aid" },
|
{ "label": "Mutual aid" },
|
||||||
{ "label": "Open source project" },
|
{ "label": "Open source project" },
|
||||||
{ "label": "Nonprofit" },
|
{ "label": "Nonprofit" },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"title": "Who is this for?",
|
"title": "Who is this for?",
|
||||||
"items": [
|
"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."
|
"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": {
|
"tripleStep": {
|
||||||
"heading": "Get recommendations that will make organizing easier",
|
"heading": "Get recommendations that will make organizing easier",
|
||||||
"ctaText": "Create Rule",
|
"ctaText": "Create Rule",
|
||||||
"ctaHref": "/create",
|
"ctaHref": "/create/informational",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"title": "Get your stakeholders together",
|
"title": "Get your stakeholders together",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"tripleTextBlock": {
|
"tripleTextBlock": {
|
||||||
"title": "Why Horizontal groups need CommunityRule",
|
"title": "Why Horizontal groups need CommunityRule",
|
||||||
"ctaText": "Setup your community",
|
"ctaText": "Setup your community",
|
||||||
"ctaHref": "/create",
|
"ctaHref": "/create/informational",
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"title": "Share Leadership and Prevent Burnout",
|
"title": "Share Leadership and Prevent Burnout",
|
||||||
|
|||||||
+58
-3
@@ -1,10 +1,41 @@
|
|||||||
import createMDX from "@next/mdx";
|
import createMDX from "@next/mdx";
|
||||||
|
|
||||||
/* eslint-env node */
|
/* 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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
serverExternalPackages: ["@prisma/client"],
|
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`
|
* `next dev --turbopack` does not use `webpack()`; without this, `.svg`
|
||||||
* imports resolve as asset URLs and {@link app/components/asset/icon/Icon.tsx}
|
* imports resolve as asset URLs and {@link app/components/asset/icon/Icon.tsx}
|
||||||
@@ -14,7 +45,12 @@ const nextConfig = {
|
|||||||
rules: {
|
rules: {
|
||||||
"*.svg": {
|
"*.svg": {
|
||||||
condition: { not: "foreign" },
|
condition: { not: "foreign" },
|
||||||
loaders: ["@svgr/webpack"],
|
loaders: [
|
||||||
|
{
|
||||||
|
loader: "@svgr/webpack",
|
||||||
|
options: svgrLoaderOptions,
|
||||||
|
},
|
||||||
|
],
|
||||||
as: "*.js",
|
as: "*.js",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -23,13 +59,20 @@ const nextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
optimizeCss: true,
|
optimizeCss: true,
|
||||||
optimizePackageImports: ["react", "react-dom"],
|
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
|
// Compression
|
||||||
compress: true,
|
compress: true,
|
||||||
// Image optimization
|
// Image optimization
|
||||||
images: {
|
images: {
|
||||||
formats: ["image/webp", "image/avif"],
|
formats: ["image/webp", "image/avif"],
|
||||||
minimumCacheTTL: 60,
|
minimumCacheTTL: 31536000,
|
||||||
dangerouslyAllowSVG: true,
|
dangerouslyAllowSVG: true,
|
||||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||||
remotePatterns: [
|
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 }) {
|
webpack(config, { dev, isServer }) {
|
||||||
@@ -77,7 +132,7 @@ const nextConfig = {
|
|||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
issuer: /\.[jt]sx?$/,
|
issuer: /\.[jt]sx?$/,
|
||||||
use: ["@svgr/webpack"],
|
use: [{ loader: "@svgr/webpack", options: svgrLoaderOptions }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bundle analysis - only in production builds
|
// Bundle analysis - only in production builds
|
||||||
|
|||||||
Generated
+111
@@ -8,6 +8,7 @@
|
|||||||
"name": "community-rule",
|
"name": "community-rule",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
@@ -44,8 +45,10 @@
|
|||||||
"@typescript-eslint/parser": "^8.41.0",
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.0.0",
|
"eslint-config-next": "^16.0.0",
|
||||||
|
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||||
"eslint-plugin-storybook": "^10.4.1",
|
"eslint-plugin-storybook": "^10.4.1",
|
||||||
"globals": "^17.1.0",
|
"globals": "^17.1.0",
|
||||||
"jest-axe": "^10.0.0",
|
"jest-axe": "^10.0.0",
|
||||||
@@ -53,6 +56,7 @@
|
|||||||
"knip": "^5.50.0",
|
"knip": "^5.50.0",
|
||||||
"msw": "^2.10.5",
|
"msw": "^2.10.5",
|
||||||
"playwright": "^1.55.0",
|
"playwright": "^1.55.0",
|
||||||
|
"png-to-ico": "^3.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^6.19.0",
|
||||||
@@ -643,6 +647,24 @@
|
|||||||
"@babel/core": "^7.0.0"
|
"@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": {
|
"node_modules/@babel/plugin-proposal-private-property-in-object": {
|
||||||
"version": "7.21.0-placeholder-for-preset-env.2",
|
"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",
|
"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"
|
"@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": {
|
"node_modules/bail": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||||
@@ -12198,6 +12230,40 @@
|
|||||||
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
|
"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": {
|
"node_modules/eslint-plugin-react-hooks": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
||||||
@@ -19175,6 +19241,51 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/po-parser": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
||||||
|
|||||||
+6
-2
@@ -45,7 +45,8 @@
|
|||||||
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
||||||
"db:deploy": "prisma migrate deploy",
|
"db:deploy": "prisma migrate deploy",
|
||||||
"migrate:smoke": "./scripts/migrate-smoke-local.sh",
|
"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": {
|
"dependencies": {
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
@@ -83,15 +84,18 @@
|
|||||||
"@typescript-eslint/parser": "^8.41.0",
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.0.0",
|
"eslint-config-next": "^16.0.0",
|
||||||
|
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||||
"eslint-plugin-storybook": "^10.4.1",
|
"eslint-plugin-storybook": "^10.4.1",
|
||||||
"globals": "^17.1.0",
|
"globals": "^17.1.0",
|
||||||
"jest-axe": "^10.0.0",
|
"jest-axe": "^10.0.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"msw": "^2.10.5",
|
|
||||||
"knip": "^5.50.0",
|
"knip": "^5.50.0",
|
||||||
|
"msw": "^2.10.5",
|
||||||
"playwright": "^1.55.0",
|
"playwright": "^1.55.0",
|
||||||
|
"png-to-ico": "^3.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^6.19.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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 |
@@ -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 |
@@ -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
Reference in New Issue
Block a user