From f14466730b7349a743392a72e4ab2c44e37970f1 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 01:13:34 -0400 Subject: [PATCH 01/31] Redesign the Learn section around a hub page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the Figma prototype (maple-learn-tab) into maple's stack: react-bootstrap and styled-components rather than the prototype's Tailwind, and @mui/icons-material rather than adding lucide-react. New: /learn hub page with four cards /learn/testimony consolidates the four testimony tabs into one page Reworked: /learn/legislative-process sticky journey rail + chapter accordion /why-use-maple/[slug] persona cards, hero and checklist; existing copy /learn/ai-tools header/breadcrumb only — MCP setup docs untouched The four retired tab slugs 301 to /learn/testimony. The navbar keeps its Learn dropdown, repointed at the new pages; /learn is reachable directly and via the breadcrumb. Notes: - IntroductionIcon had a cubic path command with four parameters instead of six, which Chrome rejected outright; the trailing L point is now the curve endpoint. - /why-use-maple persona switching is a shallow push: getStaticProps already loads all three namespaces, so the per-click data fetch was pure latency. - The process rail tracks the navbar's live bottom edge, because the navbar does not stay pinned at every breakpoint. - Fixed a 404 link to the MA legislative process document. - Deletes code superseded by the consolidation; Tabs.tsx last, once both of its importers were gone. Known: axe reports one serious color-contrast violation — a tinted stage colour on a closed chapter number cannot reach AA against white for green or orange. Left as designed pending a palette decision. --- components/AdditionalResources.tsx | 135 ++- .../CommunicatingWithLegislatorsContent.tsx | 90 -- components/Footer/Footer.tsx | 5 +- .../LearnComponents.tsx | 201 ----- .../RoleOfTestimony/RoleOfTestimonyCard.tsx | 45 - .../TestimonyCardComponents.tsx | 118 --- components/Legislative/Legislative.tsx | 61 -- components/Navbar.tsx | 10 +- components/NavbarComponents.tsx | 2 +- components/Tabs/Tabs.tsx | 70 -- components/learn/AiTools/AiTools.tsx | 464 +++++----- components/learn/Hub/LearnHub.tsx | 150 ++++ components/learn/LearnBreadcrumb.tsx | 62 ++ components/learn/LearnHeader.tsx | 72 ++ components/learn/LearnLayout.tsx | 41 + .../learn/Process/LegislativeProcess.tsx | 805 +++++++++++++++++ components/learn/Testimony/AboutTestimony.tsx | 456 ++++++++++ components/learn/WhyUseMaple/WhyUseMaple.tsx | 308 +++++++ components/learn/icons/Blobs.tsx | 181 ++++ components/learn/icons/ProcessStageIcons.tsx | 836 ++++++++++++++++++ components/learn/icons/WhyItMattersIcons.tsx | 74 ++ components/learn/icons/index.ts | 19 + components/learn/icons/svgPaths.ts | 33 + components/learn/palette.ts | 38 + components/publish/WriteTestimony.tsx | 2 +- next.config.js | 17 +- pages/learn/[slug].tsx | 108 --- pages/learn/ai-tools.tsx | 13 +- pages/learn/index.tsx | 23 +- pages/learn/legislative-process.tsx | 17 +- pages/learn/testimony.tsx | 15 + pages/why-use-maple/[slug].tsx | 75 +- public/locales/en/common.json | 4 +- public/locales/en/footer.json | 8 +- public/locales/en/learn.json | 202 +++++ public/locales/en/learnComponents.json | 133 --- .../CommunicatingWithLegislators.stories.tsx | 15 - styles/bootstrap.scss | 21 +- 38 files changed, 3726 insertions(+), 1203 deletions(-) delete mode 100644 components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent.tsx delete mode 100644 components/LearnTestimonyComponents/LearnComponents.tsx delete mode 100644 components/LearnTestimonyComponents/RoleOfTestimony/RoleOfTestimonyCard.tsx delete mode 100644 components/LearnTestimonyComponents/TestimonyCardComponents.tsx delete mode 100644 components/Legislative/Legislative.tsx delete mode 100644 components/Tabs/Tabs.tsx create mode 100644 components/learn/Hub/LearnHub.tsx create mode 100644 components/learn/LearnBreadcrumb.tsx create mode 100644 components/learn/LearnHeader.tsx create mode 100644 components/learn/LearnLayout.tsx create mode 100644 components/learn/Process/LegislativeProcess.tsx create mode 100644 components/learn/Testimony/AboutTestimony.tsx create mode 100644 components/learn/WhyUseMaple/WhyUseMaple.tsx create mode 100644 components/learn/icons/Blobs.tsx create mode 100644 components/learn/icons/ProcessStageIcons.tsx create mode 100644 components/learn/icons/WhyItMattersIcons.tsx create mode 100644 components/learn/icons/index.ts create mode 100644 components/learn/icons/svgPaths.ts create mode 100644 components/learn/palette.ts delete mode 100644 pages/learn/[slug].tsx create mode 100644 pages/learn/testimony.tsx create mode 100644 public/locales/en/learn.json delete mode 100644 stories_hold/pages/education/CommunicatingWithLegislators.stories.tsx diff --git a/components/AdditionalResources.tsx b/components/AdditionalResources.tsx index 1e6170ad8..cd7f1b9f9 100644 --- a/components/AdditionalResources.tsx +++ b/components/AdditionalResources.tsx @@ -1,48 +1,117 @@ -import { Container } from "react-bootstrap" import { useTranslation, Trans } from "next-i18next" -import * as links from "components/links" +import styled from "styled-components" -const AdditionalResources = () => { - const content = [ - { - i18nKey: "find_legislator", - href: "https://malegislature.gov/Search/FindMyLegislator" - }, - { - i18nKey: "legislative_doc", - href: "https://www.mass.gov/doc/the-legislative-process-0/download" - }, - { - i18nKey: "legal_services", - href: "https://www.masslegalservices.org/content/legislative-process-massachusetts-0" +const Section = styled.section` + margin-top: 3rem; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--bs-blue); + font-size: clamp(1.5rem, 4vw, 1.875rem); + margin-bottom: 0.75rem; + } + + .intro { + color: var(--maple-text-body); + line-height: 1.7; + margin-bottom: 1.5rem; + } + + ul { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; + } + + li { + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 1.25rem 1.5rem; + transition: box-shadow 0.2s ease; + + &:hover { + box-shadow: var(--maple-shadow-hover); + } + } + + p { + color: var(--maple-text-body); + line-height: 1.7; + margin: 0; + } + + a { + color: var(--bs-blue); + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + @media (prefers-reduced-motion: reduce) { + li { + transition: none; } - ] + } +` + +const content = [ + { + i18nKey: "find_legislator", + href: "https://malegislature.gov/Search/FindMyLegislator" + }, + { + i18nKey: "legislative_doc", + href: "https://www.mass.gov/doc/the-legislative-process" + }, + { + i18nKey: "legal_services", + href: "https://www.masslegalservices.org/content/legislative-process-massachusetts-0" + } +] + +const AdditionalResources = () => { const { t } = useTranslation("learnComponents") return ( - -

+
+ {/* h2, not h1: the page already has one, and this is a subsection of it. */} +

{t("legislative.additional_resources")} -

-

{t("legislative.resources_intro")}

- - {content.map(({ i18nKey, href }) => ( -
-
-

+

+

{t("legislative.resources_intro")}

+ + + ) } diff --git a/components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent.tsx b/components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent.tsx deleted file mode 100644 index cc667c3d4..000000000 --- a/components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Row, Col } from "../bootstrap" -import Image from "react-bootstrap/Image" -import styled from "styled-components" -import { useTranslation } from "next-i18next" - -const StyledImage = styled(Image)` - width: 12.5rem; - /* bootstrap: medium > 768px */ - @media (max-width: 48em) { - display: flex; - margin-left: auto; - margin-right: auto; - } - - /* bootstrap: small > 576px */ - @media (max-width: 36em) { - width: 10rem; - } - - /* bootstrap: xs < 576px - this break at 464px */ - @media (max-width: 26em) { - width: 8rem; - } -` - -const WritingContent = () => ( - - -

- {useTranslation("learnComponents").t( - "communicating.testifyInWriting.content" - )} -

- - - - -
-) - -const OralContent = () => ( - - - - - -

- {useTranslation("learnComponents").t( - "communicating.testifyOrally.content" - )} -

- -
-) - -const WriteOrCallContent = () => ( - - -

- {useTranslation("learnComponents").t( - "communicating.writeOrCall.content" - )} -

- - - - -
-) - -export { WritingContent, OralContent, WriteOrCallContent } diff --git a/components/Footer/Footer.tsx b/components/Footer/Footer.tsx index ce5063afa..98969fb18 100644 --- a/components/Footer/Footer.tsx +++ b/components/Footer/Footer.tsx @@ -189,7 +189,7 @@ const LearnLinks = () => { const { t } = useTranslation(["footer", "common"]) return ( <> - + {t("links.learnWriting")} @@ -198,6 +198,9 @@ const LearnLinks = () => { {t("links.learnWhy")} + + {t("links.learnAi")} + ) } diff --git a/components/LearnTestimonyComponents/LearnComponents.tsx b/components/LearnTestimonyComponents/LearnComponents.tsx deleted file mode 100644 index 0802524e8..000000000 --- a/components/LearnTestimonyComponents/LearnComponents.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { useTranslation } from "next-i18next" -import { Container, Card, Row, Col } from "../bootstrap" -import RoleOfTestimonyCard from "./RoleOfTestimony/RoleOfTestimonyCard" -import BasicsOfTestimonyCard from "./BasicsOfTestimony/BasicsOfTestimonyCard" -import { TestimonyCardList } from "./TestimonyCardComponents" -import styled from "styled-components" -import { - WritingContent, - OralContent, - WriteOrCallContent -} from "components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent" - -const StyledContainer = styled(Container)` - p { - letter-spacing: -0.625px; - } -` - -const StyledCardBody = styled(Card.Body)` - letter-spacing: -0.625px; - line-height: 2.05rem; -` - -const BasicsSrcAlt = [ - { - src: "who.svg", - alt: "who.title" - }, - { - src: "what.svg", - alt: "what.title" - }, - { - src: "when.svg", - alt: "when.title" - }, - { - src: "where.svg", - alt: "where.title" - }, - { - src: "why.svg", - alt: "Why" - } -] - -const RoleSrcAlt = [ - { - src: "speaker-with-thumbs.svg", - alt: "Speaker with thumbs" - }, - { - src: "speaker-with-leg.svg", - alt: "Speaker with documents" - }, - { - src: "speaker-with-pen.svg", - alt: "Speaker with pen" - } -] - -const WriteSrcAlt = [ - { - src: "leg-with-clock.svg", - alt: "" - }, - { - src: "leg-with-lightbulb.svg", - alt: "" - }, - { - src: "writing.svg", - alt: "" - }, - { - src: "opinions.svg", - alt: "" - }, - { - src: "respect-with-blob.svg", - alt: "" - } -] - -const Basics = () => { - const { t } = useTranslation("learnComponents") - return ( - -

{t("basics.title")}

-

{t("basics.intro")}

- {BasicsSrcAlt.map((value, index) => ( - - ))} -
- ) -} -const Role = () => { - const { t } = useTranslation("learnComponents") - return ( - -

{t("role.title")}

-

{t("role.intro")}

- {RoleSrcAlt.map((value, index) => ( - - ))} -
- ) -} - -const Write = () => { - interface WriteContentItem { - title: string - paragraphs: string[] - src: string - alt: string - } - const { t } = useTranslation("learnComponents") - const writeContent = t("write.content", { - returnObjects: true - }) as WriteContentItem[] - - const mergedContent = WriteSrcAlt.map((item, index) => ({ - ...item, - ...writeContent[index] - })) - - return ( - -

{t("write.title")}

-

{t("write.intro")}

- -
- ) -} - -const CommunicatingWithLegislators = () => { - const { t } = useTranslation("learnComponents") - - const CommWithLegCard = ({ - title, - children - }: { - title: string - children: JSX.Element - }): JSX.Element => { - return ( - - - {title} - - - {children} - - - ) - } - - return ( - - - -

- {t("communicating.title")} -

-

{t("communicating.intro")}

- - - - - - - - - - - - - -
-
- ) -} - -export { Basics, Role, Write, CommunicatingWithLegislators } diff --git a/components/LearnTestimonyComponents/RoleOfTestimony/RoleOfTestimonyCard.tsx b/components/LearnTestimonyComponents/RoleOfTestimony/RoleOfTestimonyCard.tsx deleted file mode 100644 index 8b8a09888..000000000 --- a/components/LearnTestimonyComponents/RoleOfTestimony/RoleOfTestimonyCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Col, Image, Row } from "../../bootstrap" -import clsx from "clsx" - -export type RoleOfTestimonyCardProps = { - title: string - index: number - alt: string - paragraph: string - src: string -} - -const RoleOfTestimonyCard = ({ - title, - index, - paragraph, - src -}: RoleOfTestimonyCardProps) => { - return ( - - - - - -

{title}

-

{paragraph}

- -
- ) -} - -export default RoleOfTestimonyCard diff --git a/components/LearnTestimonyComponents/TestimonyCardComponents.tsx b/components/LearnTestimonyComponents/TestimonyCardComponents.tsx deleted file mode 100644 index b2ac582a8..000000000 --- a/components/LearnTestimonyComponents/TestimonyCardComponents.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import styled from "styled-components" -import { Card, Col, Container, Image, Row } from "../bootstrap" - -export type TestimonyCardContent = { - title: string - paragraphs: string[] - src: string - alt: string -} - -export const TestimonyCardList = ({ - contents, - shouldAlternateImages = false -}: { - contents: TestimonyCardContent[] - shouldAlternateImages: boolean -}) => { - return ( -
- {contents.map((value, index) => ( - - ))} -
- ) -} - -export type TestimonyCardProps = { - content: TestimonyCardContent - shouldAlternateImage: boolean -} - -const StyledCard = styled(Card)` - margin: 8rem 5rem; - - @media (max-width: 48em) { - margin: 8rem 3rem; - } -` -const StyledHeader = styled(Card.Header)` - width: max-content; - transform: translate(-3rem, -40%); - - &:first-child { - border-radius: 0 5rem 5rem 0; - } - @media (max-width: 48em) { - transform: translate(-2rem, -40%); - } - @media (max-width: 36em) { - transform: translate(-1.5rem, -40%); - } -` -const TestimonyCard = ({ - content, - shouldAlternateImage -}: TestimonyCardProps) => { - return ( - - - {content.title} - - - - ) -} - -const StyledImage = styled(Image)` - @media (max-width: 36em) { - width: 12rem; - } - @media (max-width: 29em) { - width: 10rem; - } -` - -const TestimonyCardContent = ({ - content, - shouldAlternateImage -}: TestimonyCardProps) => { - return ( - - - - -
- -
- - - {content.paragraphs.map((paragraph, index) => ( -

- {paragraph} -

- ))} - -
-
-
- ) -} diff --git a/components/Legislative/Legislative.tsx b/components/Legislative/Legislative.tsx deleted file mode 100644 index 6430eca8e..000000000 --- a/components/Legislative/Legislative.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Container } from "react-bootstrap" -import { TestimonyCardList } from "components/LearnTestimonyComponents/TestimonyCardComponents" -import { useTranslation } from "next-i18next" - -const LegislativeContent = [ - { - src: "speaker-podium.svg", - alt: "" - }, - { - src: "mic-with-testify.svg", - alt: "" - }, - { - src: "doc-with-arrows-to-people.svg", - alt: "" - }, - { - src: "leg-with-lightbulb.svg", - alt: "" - }, - { - src: "speaker-with-leg.svg", - alt: "" - }, - { - src: "opinions.svg", - alt: "" - }, - { - src: "respect-with-blob.svg", - alt: "" - }, - { - src: "speaker-with-pen.svg", - alt: "" - } -] - -const Legislative = () => { - const { t } = useTranslation("learnComponents") - - return ( - -

- {t("legislative.title")} -

-

{t("legislative.intro")}

- ({ - ...value, - title: t(`legislative.content.${index}.title`), - paragraphs: [t(`legislative.content.${index}.paragraph`)] - }))} - shouldAlternateImages={false} - /> -
- ) -} - -export default Legislative diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 3c68cdd44..3657254a9 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -12,23 +12,23 @@ import { NavbarLinkBallotQuestions, DESKTOP_NAV_ITEM_CLASS, NavbarLinkAI, - NavbarLinkAiTools, NavbarLinkBills, + NavbarLinkAiTools, + NavbarLinkEffective, NavbarLinkHearings, + NavbarLinkProcess, + NavbarLinkWhyUse, NavbarLinkEditProfile, - NavbarLinkEffective, NavbarLinkFAQ, NavbarLinkGoals, NavbarLinkInTheNews, NavbarLinkLogo, NavbarLinkNewsfeed, - NavbarLinkProcess, NavbarLinkSignOut, NavbarLinkSupport, NavbarLinkTeam, NavbarLinkTestimony, - NavbarLinkViewProfile, - NavbarLinkWhyUse + NavbarLinkViewProfile } from "./NavbarComponents" const MobileCollapse = styled(Navbar.Collapse)` diff --git a/components/NavbarComponents.tsx b/components/NavbarComponents.tsx index a378acaed..59dce95b9 100644 --- a/components/NavbarComponents.tsx +++ b/components/NavbarComponents.tsx @@ -231,7 +231,7 @@ export const NavbarLinkEffective: React.FC< return ( diff --git a/components/Tabs/Tabs.tsx b/components/Tabs/Tabs.tsx deleted file mode 100644 index 8de01195e..000000000 --- a/components/Tabs/Tabs.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { FC } from "react" -import clsx from "clsx" - -type TabsProps = { - tabs: { - label: string - index: number - Component: FC> - }[] - selectedTab: number - onClick: (index: number) => void - orientation?: "horizontal" | "vertical" - className?: string -} - -/** - * Available Props - * @param className string - * @param tab Array of object - * @param selectedTab number - * @param onClick Function to set the active tab - * @param orientation Tab orientation Vertical | Horizontal - */ -const Tabs: FC> = ({ - className = "tabs-component", - tabs = [], - selectedTab = 0, - onClick, - orientation = "horizontal" -}) => { - const Panel = tabs && tabs.find(tab => tab.index === selectedTab) - - return ( -
-
- {tabs.map(tab => ( - - ))} -
-
- {Panel && } -
-
- ) -} -export default Tabs diff --git a/components/learn/AiTools/AiTools.tsx b/components/learn/AiTools/AiTools.tsx index 5c8d6fe6e..65cd4e907 100644 --- a/components/learn/AiTools/AiTools.tsx +++ b/components/learn/AiTools/AiTools.tsx @@ -4,12 +4,13 @@ import { Container, Row, Col } from "../../bootstrap" import { DescrContainer, Divider, - PageDescr, - PageTitle, SectionContainer, SectionTitle } from "../../shared/CommonComponents" import { Internal } from "../../links" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" const ExampleBox = styled.div` background: var(--maple-surface-raised); @@ -61,205 +62,209 @@ const ExampleLabel = styled.div` export const AiTools = () => { const { t } = useTranslation("aiTools") + const { t: tLearn } = useTranslation("learn") + // Header, subhead and breadcrumb follow the Learn section's styling. The body + // below is unchanged: it hosts the MCP setup instructions that + // mcp-server/auth.ts and functions/src/mcp/proxy.ts point users to. return ( - - - - {t("title")} - - - - - {t("description")} - - + + + + + {/* What is it */} + + + + {t("section1.title")} + + {t("section1.desc1")} + + + {t("section1.desc2Pre")}{" "} + +
+ {t("section1.desc2LinkText")} + + + {t("section1.desc2Post")} + + + + - {/* What is it */} - - - - {t("section1.title")} - - {t("section1.desc1")} - - - {t("section1.desc2Pre")}{" "} - - - {t("section1.desc2LinkText")} - - - {t("section1.desc2Post")} - - - - - - {/* What you can do */} - - - - {t("section2.title")} - - {t("section2.intro")} - - -
    -
  • - {t("section2.item1Bold")} {t("section2.item1Main")} -
  • -
  • - {t("section2.item2Bold")} {t("section2.item2Main")} -
  • -
  • - {t("section2.item3Bold")} {t("section2.item3Main")} -
  • -
  • - {t("section2.item4Bold")} {t("section2.item4Main")} -
  • -
  • - {t("section2.item5Bold")} {t("section2.item5Main")} -
  • -
  • - {t("section2.item6Bold")} {t("section2.item6Main")} -
  • -
  • - {t("section2.item7Bold")} {t("section2.item7Main")} -
  • -
-
-
- -
- - {/* Examples */} - - - - {t("section3.title")} - - {t("section3.intro")} - -
- {t("section3.example1Label")} - - “{t("section3.example1Text")}” - - - {t("section3.example2Label")} - - “{t("section3.example2Text")}” - - - {t("section3.example3Label")} - - “{t("section3.example3Text")}” - - - {t("section3.example4Label")} - - “{t("section3.example4Text")}” - -
-
- -
- - {/* How to get started */} - - - - {t("section4.title")} - - {t("section4.intro")} - - - 1 - - {t("section4.step1Bold")} {t("section4.step1Pre")}{" "} - {t("section4.step1LinkText")} - {t("section4.step1Post")} - - - - - 2 - - {t("section4.step2Bold")} {t("section4.step2Intro")} -
    -
  • - {t("section4.step2item1Bold")}{" "} - {t("section4.step2item1Tag")} {t("section4.step2item1Pre")}{" "} - - {t("section4.step2item1Link1")} - - {t("section4.step2item1Mid")}{" "} - - {t("section4.step2item1Link2")} - - {t("section4.step2item1Post")} + {/* What you can do */} + + + + {t("section2.title")} + + {t("section2.intro")} + + +
      +
    • + {t("section2.item1Bold")} {t("section2.item1Main")} +
    • +
    • + {t("section2.item2Bold")} {t("section2.item2Main")} +
    • +
    • + {t("section2.item3Bold")} {t("section2.item3Main")}
    • -
    • - {t("section4.step2item2Bold")}{" "} - {t("section4.step2item2Tag")} {t("section4.step2item2Pre")}{" "} - - {t("section4.step2item2LinkText")} - {" "} - {t("section4.step2item2Post")} +
    • + {t("section2.item4Bold")} {t("section2.item4Main")} +
    • +
    • + {t("section2.item5Bold")} {t("section2.item5Main")} +
    • +
    • + {t("section2.item6Bold")} {t("section2.item6Main")}
    • - {t("section4.step2item3Bold")}{" "} - {t("section4.step2item3Main")} + {t("section2.item7Bold")} {t("section2.item7Main")}
    - - - - - 3 - - {t("section4.step3Bold")} {t("section4.step3Pre")}{" "} - - {t("section4.step3LinkText")} - {" "} - {t("section4.step3Post")} - - - - - 4 - - {t("section4.step4Bold")} {t("section4.step4Pre")}{" "} - YOUR_TOKEN_HERE {t("section4.step4Post")} - - -
    -
    {`{
    +              
    +            
    +          
    +        
    +
    +        {/* Examples */}
    +        
    +          
    +            
    +              {t("section3.title")}
    +              
    +                {t("section3.intro")}
    +              
    +              
    + {t("section3.example1Label")} + + “{t("section3.example1Text")}” + + + {t("section3.example2Label")} + + “{t("section3.example2Text")}” + + + {t("section3.example3Label")} + + “{t("section3.example3Text")}” + + + {t("section3.example4Label")} + + “{t("section3.example4Text")}” + +
    +
    + +
    + + {/* How to get started */} + + + + {t("section4.title")} + + {t("section4.intro")} + + + 1 + + {t("section4.step1Bold")} {t("section4.step1Pre")}{" "} + + {t("section4.step1LinkText")} + + {t("section4.step1Post")} + + + + + 2 + + {t("section4.step2Bold")} {t("section4.step2Intro")} + + + + + + 3 + + {t("section4.step3Bold")} {t("section4.step3Pre")}{" "} + + {t("section4.step3LinkText")} + {" "} + {t("section4.step3Post")} + + + + + 4 + + {t("section4.step4Bold")} {t("section4.step4Pre")}{" "} + YOUR_TOKEN_HERE {t("section4.step4Post")} + + +
    +
    {`{
       "mcpServers": {
         "maple": {
           "type": "http",
    @@ -270,43 +275,44 @@ export const AiTools = () => {
         }
       }
     }`}
    -
    - - - 5 - - {t("section4.step5Bold")} {t("section4.step5Post")} - - - - {t("section4.needHelp")} {t("section4.needHelpPost")}{" "} - - info@mapletestimony.org - - . - -
    - -
    +
    + + + 5 + + {t("section4.step5Bold")} {t("section4.step5Post")} + + + + {t("section4.needHelp")} {t("section4.needHelpPost")}{" "} + + info@mapletestimony.org + + . + +
    + +
    - {/* Privacy */} - - - - {t("section5.title")} - - {t("section5.desc1")} - - - {t("section5.desc2Pre")}{" "} - - {t("section5.desc2LinkText")} - - - - - - + {/* Privacy */} + + + + {t("section5.title")} + + {t("section5.desc1")} + + + {t("section5.desc2Pre")}{" "} + + {t("section5.desc2LinkText")} + + + + + + + ) } diff --git a/components/learn/Hub/LearnHub.tsx b/components/learn/Hub/LearnHub.tsx new file mode 100644 index 000000000..a5705ce08 --- /dev/null +++ b/components/learn/Hub/LearnHub.tsx @@ -0,0 +1,150 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { Internal } from "../../links" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { + ArrowRightIcon, + BookOpenIcon, + BotIcon, + ScaleIcon, + UsersIcon +} from "../icons" +import { CRIMSON, GREEN, NAVY, ORANGE } from "../palette" + +type HubCard = { + id: string + title: string + desc: string + href: string +} + +const CARD_STYLE: Record = + { + testimony: { color: NAVY, Icon: BookOpenIcon }, + process: { color: GREEN, Icon: ScaleIcon }, + why: { color: ORANGE, Icon: UsersIcon }, + ai: { color: CRIMSON, Icon: BotIcon } + } + +const CardLink = styled(Internal)` + display: flex; + flex-direction: column; + gap: 1rem; + height: 100%; + padding: 2rem; + background: var(--maple-surface-base); + border: 1px solid var(--maple-surface-border); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + text-decoration: none; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + + &:hover { + box-shadow: var(--maple-shadow-hover); + border-color: transparent; + text-decoration: none; + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 3px; + } + + .glyph { + width: 3.5rem; + height: 3.5rem; + border-radius: var(--maple-radius-xl); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + font-size: 1.3125rem; + color: #1a1a2e; + margin: 0 0 0.5rem; + } + + .desc { + color: var(--maple-text-muted); + font-size: 0.9375rem; + line-height: 1.6; + margin: 0; + } + + .explore { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: auto; + padding-top: 0.5rem; + font-size: 0.9375rem; + font-weight: 700; + color: var(--bs-blue); + } + + .explore svg { + transition: transform 0.2s ease; + } + + &:hover .explore svg { + transform: translateX(4px); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + .explore svg { + transition: none; + transform: none; + } + } +` + +export const LearnHub = () => { + const { t } = useTranslation("learn") + const cards = t("hub.cards", { returnObjects: true }) as HubCard[] + + return ( + + + +
    + {cards.map(card => { + const { color, Icon } = CARD_STYLE[card.id] + return ( +
    + + + +
    +

    {card.title}

    +

    {card.desc}

    +
    + + {t("hub.explore")} + +
    +
    + ) + })} +
    +
    + ) +} + +export default LearnHub diff --git a/components/learn/LearnBreadcrumb.tsx b/components/learn/LearnBreadcrumb.tsx new file mode 100644 index 000000000..193f0dfce --- /dev/null +++ b/components/learn/LearnBreadcrumb.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { Internal } from "../links" +import { ChevronRightIcon } from "./icons" + +const Nav = styled.nav` + margin-bottom: 1rem; + + ol { + display: flex; + align-items: center; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.875rem; + } + + a { + /* --maple-text-muted fails AA on the tinted Learn surface. */ + color: var(--maple-text-body); + text-decoration: none; + + &:hover { + color: var(--bs-blue); + text-decoration: underline; + } + } + + .separator { + color: var(--maple-text-muted); + font-size: 1rem; + } + + .current { + color: var(--bs-blue); + font-weight: 700; + } +` + +/** "Learn > {section}" trail shown at the top of each Learn sub-page. */ +export const LearnBreadcrumb = ({ section }: { section: string }) => { + const { t } = useTranslation("learn") + + return ( + + ) +} + +export default LearnBreadcrumb diff --git a/components/learn/LearnHeader.tsx b/components/learn/LearnHeader.tsx new file mode 100644 index 000000000..50f8aa035 --- /dev/null +++ b/components/learn/LearnHeader.tsx @@ -0,0 +1,72 @@ +import styled from "styled-components" + +const Eyebrow = styled.p` + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + /* 50% failed AA against the tinted Learn surface. */ + color: color-mix(in srgb, var(--bs-blue) 78%, transparent); + margin-bottom: 0.5rem; +` + +const Title = styled.h1` + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--bs-blue); + line-height: 1.15; + margin-bottom: 0.75rem; + /* Scale down on narrow viewports rather than wrapping to four lines. */ + font-size: clamp(1.75rem, 6vw, var(--learn-title-size, 2.375rem)); +` + +const Subhead = styled.p` + color: var(--maple-text-body); + max-width: var(--learn-subhead-max-width, 34rem); + font-size: 1rem; + line-height: 1.6; + margin-bottom: 0; +` + +/** + * Eyebrow + display heading + subhead, shared by the Learn hub and its + * sub-pages. `eyebrow` is omitted on sub-pages, which show a breadcrumb instead. + * + * `subheadMaxWidth` defaults to a readable measure. Pass "none" to let the + * subhead run the full width of the page column. + */ +export const LearnHeader = ({ + eyebrow, + title, + subhead, + titleSize = "2.375rem", + subheadMaxWidth +}: { + eyebrow?: string + title: React.ReactNode + subhead?: React.ReactNode + titleSize?: string + subheadMaxWidth?: string +}) => ( +
    + {eyebrow && {eyebrow}} + + {title} + + {subhead && ( + + {subhead} + + )} +
    +) + +export default LearnHeader diff --git a/components/learn/LearnLayout.tsx b/components/learn/LearnLayout.tsx new file mode 100644 index 000000000..417808c76 --- /dev/null +++ b/components/learn/LearnLayout.tsx @@ -0,0 +1,41 @@ +import { PropsWithChildren } from "react" +import styled from "styled-components" + +type Width = "narrow" | "medium" | "wide" + +const maxWidths: Record = { + narrow: "48rem", // 768px — process page + medium: "56rem", // 896px — testimony page + wide: "64rem" // 1024px — hub +} + +const Page = styled.div` + background-color: var(--maple-surface-learn); + min-height: 100vh; +` + +const Inner = styled.div<{ $width: Width }>` + max-width: ${p => maxWidths[p.$width]}; + margin: 0 auto; + padding: 3.5rem 2rem; + + @media (max-width: 36rem) { + padding: 2rem 1rem; + } +` + +/** + * Page shell shared by every page in the Learn section: the section background + * and a width-constrained column. Maple's Navbar and Footer are applied + * separately by `applyLayout` in components/page.tsx. + */ +export const LearnLayout = ({ + width = "medium", + children +}: PropsWithChildren<{ width?: Width }>) => ( + + {children} + +) + +export default LearnLayout diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx new file mode 100644 index 000000000..c00469119 --- /dev/null +++ b/components/learn/Process/LegislativeProcess.tsx @@ -0,0 +1,805 @@ +import { useTranslation } from "next-i18next" +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState +} from "react" +import styled from "styled-components" +import AdditionalResources from "../../AdditionalResources" +import { Internal } from "../../links" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, + ExternalLinkIcon +} from "../icons" +import { STAGE_ICONS } from "../icons/ProcessStageIcons" +import { STAGE_COLORS, TINT, alpha } from "../palette" + +type Stat = { value: string; label: string; href?: string } +type Chapter = { + num: string + title: string + lead: string + body: string + stat?: Stat + callout?: string +} + +const prefersReducedMotion = () => + typeof window !== "undefined" && + !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + +const rowId = (i: number) => `learn-process-stage-${i}` +const panelId = (i: number) => `learn-process-panel-${i}` +const headerId = (i: number) => `learn-process-header-${i}` + +/* ── Sticky journey rail ─────────────────────────────────────── */ + +const RailWrapper = styled.div` + position: sticky; + /* Measured at runtime — see the effect in LegislativeProcess. Falls back to a + sensible constant before the first layout pass. */ + top: var(--maple-navbar-height, 6rem); + z-index: 2; + margin-inline: -2rem; + padding: 0.5rem 2rem 1rem; + background-color: var(--maple-surface-learn); + + @media (max-width: 36rem) { + margin-inline: -1rem; + padding: 0.5rem 1rem 0.75rem; + } +` + +const RailCard = styled.div` + position: relative; + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 1.25rem 1.5rem; + + /* The rail scrolls horizontally on narrow screens and its scrollbar is + hidden, so fade the trailing edge to signal there is more to see. */ + @media (max-width: 34rem) { + &::after { + content: ""; + position: absolute; + inset-block: 1px; + right: 1px; + width: 2.5rem; + pointer-events: none; + border-radius: 0 var(--maple-radius-xl) var(--maple-radius-xl) 0; + background: linear-gradient( + to right, + transparent, + var(--maple-surface-base) + ); + } + } +` + +const RailTrack = styled.ol` + position: relative; + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + + /* The connecting line sits behind the nodes, inset so it doesn't poke out. */ + &::before { + content: ""; + position: absolute; + top: 1.6875rem; + left: 1.5rem; + right: 1.5rem; + height: 2px; + background: var(--bs-gray-200, #f3f4f6); + z-index: 0; + } + + /* Six nodes don't fit below ~34rem. Scroll the journey rather than wrap it, + so the line keeps reading as a single sequence. */ + @media (max-width: 34rem) { + justify-content: flex-start; + overflow-x: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + &::before { + right: auto; + width: max(100%, 30rem); + } + } +` + +const RailNode = styled.li` + position: relative; + z-index: 1; + flex: 0 0 auto; + + button { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + width: 4.5rem; + padding: 0; + background: none; + border: 0; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 3px; + border-radius: var(--maple-radius-md); + } + } + + .node { + width: 3.375rem; + height: 3.375rem; + border-radius: var(--maple-radius-pill); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--maple-shadow-sm); + transition: background 0.2s ease, border-color 0.2s ease, + transform 0.2s ease; + } + + .label { + font-size: 0.6875rem; + font-weight: 700; + line-height: 1.2; + text-align: center; + transition: color 0.2s ease; + } + + @media (prefers-reduced-motion: reduce) { + .node { + transition: none; + transform: none !important; + } + } +` + +const HeaderAnchor = styled.div` + scroll-margin-top: calc(var(--maple-navbar-height) + 0.75rem); +` + +const ExpandAllButton = styled.button` + display: inline; + padding: 0; + margin: 0.5rem 0 0; + background: none; + border: 0; + color: var(--bs-blue); + font-weight: 400; + text-decoration: underline; + cursor: pointer; + + &:hover { + text-decoration: none; + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 2px; + border-radius: var(--maple-radius-sm); + } +` + +/* A `position: sticky` element can only stick within its containing block. + Scoping the rail to the chapters means it releases and scrolls away once the + last chapter passes, rather than hovering over Additional Resources. */ +const StickyScope = styled.div` + position: relative; +` + +/* ── Accordion ───────────────────────────────────────────────── */ + +const Row = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + + /* Smooth-scroll targets must clear the sticky navbar and the sticky rail + when they align to the top, and keep a little air when they align to the + bottom (scrollIntoView block: "nearest"). */ + scroll-margin-top: calc( + var(--maple-navbar-height) + var(--learn-rail-height, 10rem) + 1rem + ); + scroll-margin-bottom: 1rem; +` + +const RowHeader = styled.h3` + margin: 0; + + button { + width: 100%; + display: flex; + align-items: center; + gap: 1.25rem; + padding: 1.25rem 1.75rem; + text-align: left; + background: none; + border: 0; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: color-mix( + in srgb, + var(--bs-gray-100, #f9fafb) 60%, + transparent + ); + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } + } + + .num { + font-family: var(--maple-font-heading); + font-weight: 900; + font-size: 2.25rem; + line-height: 1; + flex-shrink: 0; + width: 3rem; + transition: color 0.2s ease; + } + + .spine { + width: 4px; + align-self: stretch; + border-radius: var(--maple-radius-pill); + flex-shrink: 0; + transition: background 0.2s ease; + } + + .text { + flex: 1; + min-width: 0; + } + + .title { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1.125rem; + line-height: 1.35; + margin: 0; + } + + .lead { + color: var(--maple-text-muted); + font-size: 0.875rem; + line-height: 1.35; + margin: 0.125rem 0 0; + } + + .chevron { + flex-shrink: 0; + width: 2rem; + height: 2rem; + border-radius: var(--maple-radius-pill); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + } + + @media (max-width: 36rem) { + button { + gap: 0.75rem; + padding: 1rem; + } + .num { + font-size: 1.75rem; + width: 2.25rem; + } + } +` + +const Panel = styled.div` + padding: 0 1.75rem 1.75rem; + + @keyframes learn-panel-in { + from { + opacity: 0; + transform: translateY(-0.375rem); + } + to { + opacity: 1; + transform: none; + } + } + + .inner { + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 1rem; + padding-left: 5.5rem; + animation: learn-panel-in 0.24s ease both; + animation-delay: var(--learn-panel-delay, 0ms); + } + + @media (prefers-reduced-motion: reduce) { + .inner { + animation: none; + } + } + + .body { + color: var(--maple-text-body); + line-height: 1.7; + white-space: pre-line; + margin: 0; + } + + .stat { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 0; + border-top: 1px solid var(--maple-surface-border); + } + + .stat-value { + font-family: var(--maple-font-heading); + font-weight: 700; + font-size: 1.875rem; + } + + .stat-label { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--maple-text-muted); + text-decoration: none; + + &:hover { + color: var(--bs-blue); + text-decoration: underline; + } + } + + .callout { + border-radius: var(--maple-radius-lg); + padding: 1rem 1.25rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; + + p { + font-weight: 600; + font-size: 0.875rem; + line-height: 1.7; + margin: 0; + } + } + + @media (max-width: 36rem) { + padding: 0 1rem 1.25rem; + .inner { + padding-left: 0; + } + } +` + +const StatLabel = ({ stat }: { stat: Stat }) => { + if (!stat.href) return {stat.label} + + if (stat.href.startsWith("/")) + return ( + + {stat.label} + + ) + + return ( + + {stat.label} + + + ) +} + +const AccordionRow = ({ + index, + chapter, + color, + isOpen, + isActive, + onToggle, + stagger = 0 +}: { + index: number + chapter: Chapter + color: string + isOpen: boolean + isActive: boolean + onToggle: () => void + /** Index used to stagger the open animation when several panels open at once. */ + stagger?: number +}) => ( + + + + + + + +) + +export const LegislativeProcess = () => { + const { t } = useTranslation("learn") + const chapters = t("process.chapters", { returnObjects: true }) as Chapter[] + const stages = t("process.stages", { returnObjects: true }) as string[] + + const [activeIdx, setActiveIdx] = useState(0) + const [openIdx, setOpenIdx] = useState(0) + // "Expand all" is a temporary override. Any interaction with a rail node or a + // chapter header drops back to the normal one-at-a-time accordion. + const [expandAll, setExpandAll] = useState(false) + + const isOpen = (i: number) => expandAll || openIdx === i + + const railRef = useRef(null) + const trackRef = useRef(null) + const scopeRef = useRef(null) + // Set when a rail node is clicked, so that expanding a row from its own + // header does not also yank the page around. + const pendingScrollRef = useRef(null) + // Set when "expand all" is pressed, so the subhead is brought to the top. + const pendingHeaderScrollRef = useRef(false) + const headerRef = useRef(null) + + // The rail must sit directly beneath whatever remains of the site navbar. + // + // A one-off height measurement is not enough: the navbar's `sticky-top` does + // not always keep it pinned (it scrolls away on the mobile navbar, and its + // stickiness can be defeated by an ancestor), which would leave the rail + // floating with a strip of page content visible above it. So the offset + // tracks the navbar's *current* bottom edge on scroll, clamped at zero, and + // the rail hugs the very top once the navbar has scrolled out of view. + useLayoutEffect(() => { + const scope = scopeRef.current + if (!scope) return + + let frame = 0 + const publishNavOffset = () => { + const nav = document.querySelector(".main-navbar") + const bottom = nav ? nav.getBoundingClientRect().bottom : 0 + const offset = Math.max(0, bottom) + scope.style.setProperty("--maple-navbar-height", `${offset}px`) + } + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(() => { + frame = 0 + publishNavOffset() + }) + } + + publishNavOffset() + window.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + + const nav = document.querySelector(".main-navbar") + const observer = new ResizeObserver(schedule) + if (nav) observer.observe(nav) + + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + observer.disconnect() + } + }, []) + + // Publish the rail's real height so rows can reserve scroll-margin beneath it. + // Measured rather than assumed, because the rail grows when stage labels wrap. + // It is written to the shared ancestor: custom properties inherit down the + // tree, and the rows are siblings of the rail, not its children. + useLayoutEffect(() => { + const rail = railRef.current + const scope = scopeRef.current + if (!rail || !scope) return + const publish = () => + scope.style.setProperty("--learn-rail-height", `${rail.offsetHeight}px`) + publish() + const observer = new ResizeObserver(publish) + observer.observe(rail) + return () => observer.disconnect() + }, []) + + useEffect(() => { + const target = pendingScrollRef.current + if (target === null) return + pendingScrollRef.current = null + + const el = document.getElementById(rowId(target)) + if (!el) return + + const reduceMotion = prefersReducedMotion() + // "nearest" scrolls the least distance that brings the whole card into + // view. A card below the fold rises just far enough to be fully visible + // rather than jumping to the top of the viewport; a card above the fold + // aligns to its top, where scroll-margin-top clears the navbar and rail. + el.scrollIntoView({ + behavior: reduceMotion ? "auto" : "smooth", + block: "nearest" + }) + }, [openIdx]) + + useEffect(() => { + if (!expandAll || !pendingHeaderScrollRef.current) return + pendingHeaderScrollRef.current = false + const el = headerRef.current + if (!el) return + el.scrollIntoView({ + behavior: prefersReducedMotion() ? "auto" : "smooth", + block: "start" + }) + }, [expandAll]) + + // On narrow screens the rail overflows horizontally. Keep the active node + // visible, whichever way the stage was selected. + useEffect(() => { + const track = trackRef.current + if (!track || track.scrollWidth <= track.clientWidth) return + + const node = track.children[activeIdx] as HTMLElement | undefined + if (!node) return + + const reduceMotion = prefersReducedMotion() + track.scrollTo({ + left: node.offsetLeft - (track.clientWidth - node.offsetWidth) / 2, + behavior: reduceMotion ? "auto" : "smooth" + }) + }, [activeIdx]) + + const selectStage = useCallback((i: number) => { + pendingScrollRef.current = i + setExpandAll(false) + setActiveIdx(i) + setOpenIdx(i) + }, []) + + const toggleChapter = useCallback( + (i: number) => { + if (expandAll) { + // Leaving expand-all: keep the chapter the reader just reached for. + setExpandAll(false) + setOpenIdx(i) + setActiveIdx(i) + return + } + setOpenIdx(prev => { + const next = prev === i ? null : i + if (next !== null) setActiveIdx(next) + return next + }) + }, + [expandAll] + ) + + // Expand-only. Any rail node or chapter header returns to the normal + // one-at-a-time accordion. + const expandAllSections = useCallback(() => { + setExpandAll(true) + pendingHeaderScrollRef.current = true + }, []) + + return ( + // "medium" rather than "narrow": the h1 wraps at 48rem. + +
    + + + + {t("process.subhead")}{" "} + panelId(i)).join(" ")} + > + {t("process.expandAll")} + + + } + /> + + + + + + + + + +
    + {chapters.map((chapter, i) => ( + toggleChapter(i)} + stagger={expandAll ? i : 0} + /> + ))} +
    +
    + + +
    +
    + ) +} + +export default LegislativeProcess diff --git a/components/learn/Testimony/AboutTestimony.tsx b/components/learn/Testimony/AboutTestimony.tsx new file mode 100644 index 000000000..3dd7e7c37 --- /dev/null +++ b/components/learn/Testimony/AboutTestimony.tsx @@ -0,0 +1,456 @@ +import { useTranslation } from "next-i18next" +import { useEffect, useRef, useState } from "react" +import styled from "styled-components" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { ChevronDownIcon, ChevronUpIcon } from "../icons" +import { + HowBlob, + WhatBlob, + WhenBlob, + WhereBlob, + WhoBlob, + WhyBlob +} from "../icons/Blobs" +import { WHY_ICONS } from "../icons/WhyItMattersIcons" + +type CardData = { badge: string; headline: string; body: string } +type Matter = { title: string; body: string } +type StepData = { + title: string + body: string + linkLabel?: string + linkText?: string + href?: string +} +type Tip = { label: string; body: string } + +const BADGES: Record JSX.Element> = { + who: WhoBlob, + what: WhatBlob, + when: WhenBlob, + where: WhereBlob +} + +const Card = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + height: 100%; + + .head { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1.5rem 1rem; + } + + h2 { + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1rem; + line-height: 1.2; + margin: 0; + } + + .body { + padding: 0 1.5rem 1.5rem; + color: var(--maple-text-muted); + line-height: 1.45; + margin: 0; + } +` + +const MatterRow = styled.div` + display: flex; + align-items: flex-start; + gap: 1.25rem; + padding: 1.125rem 1.5rem; + border: 1px solid var(--maple-surface-border); + border-radius: var(--maple-radius-lg); + + & + & { + margin-top: 0.5rem; + } + + .title { + font-weight: 600; + color: #1c2b3a; + line-height: 1.5; + margin: 0 0 0.25rem; + } + + .text { + color: var(--maple-text-muted); + line-height: 1.45; + margin: 0; + } +` + +const NumBadge = styled.span` + flex-shrink: 0; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--maple-radius-pill); + background: var(--maple-surface-learn); + border: 1px solid #cccfdd; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--maple-font-heading); + font-size: 1rem; + line-height: 1; + color: #000; +` + +const Step = styled.div` + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; + + &:last-child { + margin-bottom: 0; + } + + .title { + font-weight: 600; + color: #1c2b3a; + line-height: 1.5; + margin: 0 0 0.25rem; + } + + .text { + color: var(--maple-text-muted); + line-height: 1.45; + margin: 0 0 0.75rem; + } + + a { + color: var(--bs-blue); + text-decoration: underline; + } +` + +const TipsPanel = styled.div` + border: 1px solid rgba(28, 43, 58, 0.12); + border-radius: var(--maple-radius-lg); + overflow: hidden; + background-color: #fdfaf5; + + button { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + text-align: left; + background: none; + border: 0; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } + } + + .toggle-label { + font-weight: 600; + color: #1c2b3a; + font-size: 0.875rem; + line-height: 1.5; + margin: 0; + } + + .content { + padding: 0 1rem 0.75rem; + } + + .intro { + color: var(--maple-text-muted); + font-size: 0.875rem; + line-height: 1.6; + margin-bottom: 0.75rem; + } + + .tip { + padding: 0.5rem 0; + } + + .tip-label { + font-weight: 600; + color: #1c2b3a; + font-size: 0.875rem; + line-height: 1.5; + margin: 0; + } + + .tip-body { + color: var(--maple-text-muted); + font-size: 0.875rem; + line-height: 1.6; + margin: 0; + } +` + +const Panel = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + + .head { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1.5rem 1rem; + } + + h2 { + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1rem; + line-height: 1.2; + margin: 0; + } + + .body { + padding: 0 1.5rem 1.5rem; + } + + .lede { + color: var(--maple-text-muted); + line-height: 1.45; + } +` + +/* The HOW panel fades in as the reader reaches "Ready to get started?". + It is only ever hidden once JS has mounted and confirmed the reader has not + asked for reduced motion, so no-JS and reduced-motion visitors always see it + immediately and screen readers never lose it. */ +const Reveal = styled.div<{ $hidden: boolean }>` + opacity: ${p => (p.$hidden ? 0 : 1)}; + transform: ${p => (p.$hidden ? "translateY(2rem)" : "none")}; + transition: opacity 0.85s ease-out, transform 0.85s ease-out; + + @media (prefers-reduced-motion: reduce) { + opacity: 1; + transform: none; + transition: none; + } +` + +const ReadyToStart = styled.p` + font-weight: 700; + color: #2f3031; + font-size: 1.125rem; + text-align: center; + margin: 1.5rem 0; +` + +const Tips = ({ + toggle, + intro, + tips +}: { + toggle: string + intro: string + tips: Tip[] +}) => { + const [open, setOpen] = useState(false) + return ( + + + + + ) +} + +const useRevealOnScroll = () => { + const sentinelRef = useRef(null) + // Starts visible: only enhanced once we know JS is running. + const [hidden, setHidden] = useState(false) + + useEffect(() => { + const reduceMotion = window.matchMedia?.( + "(prefers-reduced-motion: reduce)" + ).matches + if (reduceMotion || typeof IntersectionObserver === "undefined") return + + const sentinel = sentinelRef.current + if (!sentinel) return + + // If the reader has already scrolled to the sentinel (deep link, refresh + // mid-page), leave the section visible rather than hiding it to animate. + const TRIGGER_POINT = 0.45 // sentinel must reach 45% up the viewport + if ( + sentinel.getBoundingClientRect().top < + window.innerHeight * TRIGGER_POINT + ) + return + + setHidden(true) + const observer = new IntersectionObserver( + entries => { + if (entries.some(e => e.isIntersecting)) { + setHidden(false) + observer.disconnect() + } + }, + // Negative bottom margin shrinks the observed area, so the reveal waits + // until the sentinel has travelled well up the viewport. + { rootMargin: "0px 0px -55% 0px" } + ) + observer.observe(sentinel) + return () => observer.disconnect() + }, []) + + return { sentinelRef, hidden } +} + +export const AboutTestimony = () => { + const { t } = useTranslation("learn") + const { sentinelRef, hidden } = useRevealOnScroll() + + const cards = t("testimony.cards", { returnObjects: true }) as CardData[] + const matters = t("testimony.why.matters", { + returnObjects: true + }) as Matter[] + const steps = t("testimony.how.steps", { returnObjects: true }) as StepData[] + const tips = t("testimony.tips", { returnObjects: true }) as Tip[] + + return ( + + + + + {/* WHO / WHAT / WHEN / WHERE */} +
    + {cards.map(card => { + const Badge = BADGES[card.badge] + return ( +
    + +
    + +

    {card.headline}

    +
    +

    {card.body}

    +
    +
    + ) + })} +
    + + {/* WHY */} + +
    + +

    {t("testimony.why.headline")}

    +
    +
    +

    {t("testimony.why.body")}

    +

    + {t("testimony.why.mattersHeading")} +

    + {matters.map((item, i) => { + const Icon = WHY_ICONS[i] + return ( + + +
    +

    {item.title}

    +

    {item.body}

    +
    +
    + ) + })} +
    +
    + + + {t("testimony.readyToStart")} + + + {/* HOW */} + + +
    + +

    {t("testimony.how.headline")}

    +
    +
    +

    {t("testimony.how.intro")}

    + + {steps.map((step, i) => ( + + +
    +

    {step.title}

    +

    {step.body}

    + + {step.href && ( +

    + {step.linkLabel}{" "} + + {step.linkText} + +

    + )} + + {i === 0 && ( + + )} +
    +
    + ))} +
    +
    +
    +
    + ) +} + +export default AboutTestimony diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx new file mode 100644 index 000000000..fe4017e99 --- /dev/null +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -0,0 +1,308 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { CheckIcon, MegaphoneIcon, ScaleIcon, UsersIcon } from "../icons" +import { GREEN, NAVY, ORANGE, alpha } from "../palette" + +/** + * "Who Uses MAPLE" from the Figma prototype: three persona cards above a tinted + * hero and a two-column "What we offer" checklist. + * + * The copy is maple's own. The prototype's `personas` array was derived from + * these namespaces — its taglines are `callToAction.title`, its "why" text is + * `callToAction.bodytext`, and its feature bullets are the `benefits.*.title` + * values — so nothing here is newly written. + * + * Routing is unchanged: each persona keeps its own URL under /why-use-maple. + */ + +export type Persona = { + slug: string + ns: string + color: string + Icon: typeof UsersIcon + benefits: string[] + /** callToAction body keys, in render order. */ + body: string[] +} + +export const PERSONAS: Persona[] = [ + { + slug: "for-individuals", + ns: "forindividuals", + color: NAVY, + Icon: UsersIcon, + benefits: [ + "youMatter", + "multipleSides", + "trustedOrgs", + "anyLanguage", + "stayInformed" + ], + body: ["bodytext"] + }, + { + slug: "for-orgs", + ns: "fororgs", + color: GREEN, + Icon: MegaphoneIcon, + benefits: [ + "reach", + "connect", + "language", + "coordinate", + "seeEveryone", + "curateHistory", + "legislativeResearch", + "changeNorms", + "signUp" + ], + body: ["bodytext"] + }, + { + slug: "for-legislators", + ns: "forlegislators", + color: ORANGE, + Icon: ScaleIcon, + benefits: [ + "constituents", + "seeTestimony", + "simplifyTestimony", + "languageAccess", + "advancedStatistics" + ], + body: ["bodytextOne", "bodytextTwo"] + } +] + +const PersonaGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 1.25rem; + + @media (max-width: 48rem) { + grid-template-columns: 1fr; + } +` + +const PersonaCard = styled.button<{ $color: string; $active: boolean }>` + border-radius: var(--maple-radius-lg); + padding: 0.875rem 1rem; + text-align: left; + border: 2px solid ${p => (p.$active ? p.$color : "transparent")}; + background-color: ${p => + p.$active ? p.$color : "var(--maple-surface-base)"}; + box-shadow: var(--maple-shadow-sm); + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; + + .glyph { + display: block; + margin-bottom: 0.25rem; + color: ${p => (p.$active ? "#fff" : "var(--bs-gray-400, #9ca3af)")}; + } + + .label { + font-weight: 700; + font-size: 0.8125rem; + margin: 0; + color: ${p => (p.$active ? "#fff" : "#374151")}; + } + + &:hover { + border-color: ${p => p.$color}; + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 3px; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +` + +const Detail = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; +` + +const Hero = styled.div<{ $color: string }>` + padding: 1.5rem 1.75rem; + background-color: ${p => alpha(p.$color, 5)}; + + .tagline { + font-family: var(--maple-font-heading); + font-weight: 900; + font-size: 1.25rem; + line-height: 1.3; + color: ${p => p.$color}; + margin-bottom: 0.5rem; + } + + .why { + color: var(--maple-text-body); + font-size: 0.9375rem; + line-height: 1.6; + margin-bottom: 0; + } + + .why + .why { + margin-top: 0.75rem; + } + + @media (max-width: 36rem) { + padding: 1.25rem; + } +` + +const Offer = styled.div` + padding: 2.25rem 2rem 2.5rem; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1.375rem; + margin-bottom: 1.5rem; + } + + ul { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.125rem 1.5rem; + margin: 0; + padding: 0; + list-style: none; + + @media (max-width: 48rem) { + grid-template-columns: 1fr; + } + } + + li { + display: flex; + align-items: flex-start; + gap: 0.75rem; + } + + .tick { + flex-shrink: 0; + margin-top: 0.125rem; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--maple-radius-pill); + display: flex; + align-items: center; + justify-content: center; + } + + .feature { + color: var(--maple-text-body); + font-size: 1rem; + line-height: 1.55; + } + + @media (max-width: 36rem) { + padding: 1.25rem; + } +` + +export const WhyUseMaple = ({ + slug, + onSelect +}: { + slug: string + onSelect: (slug: string) => void +}) => { + const { t } = useTranslation([ + "learn", + "forindividuals", + "fororgs", + "forlegislators" + ]) + + const active = PERSONAS.find(p => p.slug === slug) ?? PERSONAS[0] + + return ( + + + + + + {PERSONAS.map(persona => { + const isActive = persona.slug === active.slug + return ( + onSelect(persona.slug)} + $color={persona.color} + $active={isActive} + > + + ) + })} + + + + +

    {t(`${active.ns}:callToAction.title`)}

    + {active.body.map(key => ( +

    + {t(`${active.ns}:callToAction.${key}`)} +

    + ))} +
    + + +

    {t(`${active.ns}:benefits.header`)}

    +
      + {active.benefits.map(key => ( +
    • + + + {t(`${active.ns}:benefits.${key}.title`)} + +
    • + ))} +
    +
    +
    +
    + ) +} + +export default WhyUseMaple diff --git a/components/learn/icons/Blobs.tsx b/components/learn/icons/Blobs.tsx new file mode 100644 index 000000000..1c7db48ba --- /dev/null +++ b/components/learn/icons/Blobs.tsx @@ -0,0 +1,181 @@ +import styled from "styled-components" +import svgPaths from "./svgPaths" + +// The WHO/WHAT/WHEN/WHERE/WHY/HOW badges from the Figma prototype. +// +// The prototype positioned each shape with Tailwind's `hypot()` sizing and +// `container-type: size`, which is how Figma expresses a rotated path inside a +// bounding box. A rotated reaches the same result without Tailwind. + +const Badge = styled.div<{ $w: number; $h: number }>` + position: relative; + flex-shrink: 0; + width: ${p => p.$w}px; + height: ${p => p.$h}px; + + svg { + position: absolute; + display: block; + } + + .label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-weight: 900; + font-size: 12.45px; + line-height: 1; + letter-spacing: 1.5px; + color: #fff; + white-space: nowrap; + padding-left: 1.5px; + } +` + +type BlobProps = { + label: string + color: string + path: string + viewBox: string + /** Outer badge box. */ + w: number + h: number + /** The shape's own box, positioned within the badge. */ + shape: { w: number; h: number; left: number; top: number } + rotate?: number + /** + * Rotating a shape grows its bounding box, so a rotated blob would otherwise + * render noticeably larger than an unrotated one. Rotated blobs are centred + * in the badge and scaled down. Note the scale is not a pure bounding-box + * match: a rotated bbox includes empty diagonal corners, so matching bbox + * width would leave the visible shape smaller than its unrotated siblings. + */ + scale?: number +} + +const Blob = ({ + label, + color, + path, + viewBox, + w, + h, + shape, + rotate, + scale +}: BlobProps) => { + const transform = rotate + ? `translate(-50%, -50%) rotate(${rotate}deg) scale(${scale ?? 1})` + : undefined + + return ( + + + {label} + + ) +} + +export const WhoBlob = () => ( + +) + +export const WhatBlob = () => ( + +) + +export const WhenBlob = () => ( + +) + +export const WhereBlob = () => ( + +) + +export const WhyBlob = () => ( + +) + +export const HowBlob = () => ( + +) diff --git a/components/learn/icons/ProcessStageIcons.tsx b/components/learn/icons/ProcessStageIcons.tsx new file mode 100644 index 000000000..e22066b61 --- /dev/null +++ b/components/learn/icons/ProcessStageIcons.tsx @@ -0,0 +1,836 @@ +// Legislative-process stage icons, exported verbatim from the Figma prototype +// (maple-learn-tab/src/app/sections/ProcessSection.tsx). +// +// Each SVG's internal mask ids are namespaced per stage so that all six can be +// rendered in the same document without colliding. The icons are decorative: +// the visible stage label provides the accessible name. +// +// One repair vs. the prototype: IntroductionIcon carried a cubic command with +// four parameters instead of six (`C x1 y1 x2 y2 L x y`). Chrome rejected the +// whole `d` attribute and dropped the remainder of that path. The trailing L +// point is now the curve's endpoint, which is what the geometry implies. + +import React from "react" + +export const IntroductionIcon = () => ( + +) + +export const CommitteeReferralIcon = () => ( + +) + +export const PublicHearingIcon = () => ( + +) + +export const CommitteeReportIcon = () => ( + +) + +export const FloorDebateIcon = () => ( + +) + +export const GovernorActionIcon = () => ( + +) + +/** Ordered to match the six stages in the process rail. */ +export const STAGE_ICONS = [ + IntroductionIcon, + CommitteeReferralIcon, + PublicHearingIcon, + CommitteeReportIcon, + FloorDebateIcon, + GovernorActionIcon +] diff --git a/components/learn/icons/WhyItMattersIcons.tsx b/components/learn/icons/WhyItMattersIcons.tsx new file mode 100644 index 000000000..1ee9cda33 --- /dev/null +++ b/components/learn/icons/WhyItMattersIcons.tsx @@ -0,0 +1,74 @@ +import svgPaths from "./svgPaths" + +// The three "Why it Matters" icons on /learn/testimony, taken verbatim from the +// Figma prototype. Decorative: each is paired with a visible heading. +// +// These use a parchment/navy/ochre palette of their own rather than the brand +// colors, so the hexes stay literal. + +const size = { width: 40, height: 40 } +const a11y = { "aria-hidden": true, focusable: "false" } as const + +export const IconAgenda = () => ( + + + + + + +) + +export const IconInsight = () => ( + + + + + +) + +export const IconChanges = () => ( + + + + + +) + +export const WHY_ICONS = [IconAgenda, IconInsight, IconChanges] diff --git a/components/learn/icons/index.ts b/components/learn/icons/index.ts new file mode 100644 index 000000000..6e1a9bbb3 --- /dev/null +++ b/components/learn/icons/index.ts @@ -0,0 +1,19 @@ +// Icon aliases for the Learn section. +// +// The Figma prototype was built against lucide-react. Maple already ships +// @mui/icons-material, so the prototype's icons are mapped onto MUI equivalents +// here rather than adding a second icon library. Deep imports keep tree-shaking +// working. Swapping icon sets later means editing only this file. + +export { default as BookOpenIcon } from "@mui/icons-material/MenuBook" +// Bullhorn, for the Organizations persona. +export { default as MegaphoneIcon } from "@mui/icons-material/Campaign" +export { default as ScaleIcon } from "@mui/icons-material/Balance" +export { default as UsersIcon } from "@mui/icons-material/Groups" +export { default as BotIcon } from "@mui/icons-material/SmartToy" +export { default as ArrowRightIcon } from "@mui/icons-material/ArrowForward" +export { default as CheckIcon } from "@mui/icons-material/Check" +export { default as ExternalLinkIcon } from "@mui/icons-material/OpenInNew" +export { default as ChevronUpIcon } from "@mui/icons-material/KeyboardArrowUp" +export { default as ChevronDownIcon } from "@mui/icons-material/KeyboardArrowDown" +export { default as ChevronRightIcon } from "@mui/icons-material/ChevronRight" diff --git a/components/learn/icons/svgPaths.ts b/components/learn/icons/svgPaths.ts new file mode 100644 index 000000000..8cd3ef312 --- /dev/null +++ b/components/learn/icons/svgPaths.ts @@ -0,0 +1,33 @@ +// SVG path data for the Learn section's decorative blob badges and +// "why it matters" icons. Copied verbatim from the Figma prototype at +// maple-learn-tab/src/imports/RedesignLearnTab/svg-00izgm1xmm.ts +// +// Do not hand-edit: regenerate from the design export if the shapes change. + +export default { + p107a080: + "M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z", + p1f124780: + "M15.0006 55.5237C-7.60404 55.177 -0.2398 25.4037 8.55088 12.8335C14.0072 5.03118 26.4299 0 34.6327 0C45.9175 0 44.9995 15.6705 55.1716 15.6705C69.8645 10.9024 74.3756 35.1512 63.224 43.8204C52.0724 52.4895 49.8465 66.7478 40.9893 65.4821C24.1262 63.0722 37.6051 55.8705 15.0006 55.5237Z", + p1f789000: + "M8.79496 59.4576C-13.7076 45.3061 14.0729 40.3717 14.0733 26.2199C14.0736 12.0681 11.5727 3.14063 29.5746 1.1887C47.5764 -0.763238 51.6589 -2.00737 58.1428 11.7359C61.2661 25.204 65.175 34.8839 58.8952 41.9109C52.6154 48.9378 45.9886 52.3718 39.8833 58.3902C33.7781 64.4087 26.797 70.7788 8.79496 59.4576Z", + p226cc7c0: "M13.9996 14.0001L11.0996 11.1001", + p276c5200: + "M10.0012 62.2137C-12.5014 48.0621 10.001 41.2304 10.0013 27.0786C10.0017 12.9269 14.5017 2.1917 32.5035 0.239764C50.5054 -1.71217 71.2288 8.53542 71.2289 20.7351C71.2289 30.0065 57.0994 32.7392 50.8196 39.7662C44.5398 46.7931 51.867 49.526 47.6801 59.7737C41.5748 65.7922 28.0033 73.5349 10.0012 62.2137Z", + p33cd4800: + "M23 22H17C16.4477 22 16 22.4477 16 23V31C16 31.5523 16.4477 32 17 32H23C23.5523 32 24 31.5523 24 31V23C24 22.4477 23.5523 22 23 22Z", + p38984180: + "M15.0022 50.0736C-7.60486 49.7831 -0.239825 24.8413 8.55179 14.3109C14.0087 7.77467 36.695 -4.31778 42.1625 1.57133C48.5679 8.47051 45.0043 16.6875 55.1774 16.6875C69.872 12.6932 74.3835 33.007 63.2308 40.2694C52.078 47.5318 50.198 58.3698 38.1349 58.3698C28.4351 58.3698 37.6092 50.3641 15.0022 50.0736Z", + p38dd600: + "M7.66718 39.2328C-5.57009 20.9043 0.27363 12.9213 10.5766 10.5446C25.433 7.11757 22.3179 -3.39192 40.1597 1.09792C49.2892 3.39533 46.5803 10.5446 57.2022 12.9213C67.9826 19.7762 68.7609 34.5632 59.4217 39.2328C47.5178 45.1847 52.2228 54.6035 40.1597 54.6035C30.4598 54.6035 12.7259 46.2372 7.66718 39.2328Z", + p3a03a000: + "M8 30V18L20 8L32 18V30C32 30.5304 31.7893 31.0391 31.4142 31.4142C31.0391 31.7893 30.5304 32 30 32H10C9.46957 32 8.96086 31.7893 8.58579 31.4142C8.21071 31.0391 8 30.5304 8 30Z", + p3d8d7100: + "M31 8H9C7.34315 8 6 9.34315 6 11V29C6 30.6569 7.34315 32 9 32H31C32.6569 32 34 30.6569 34 29V11C34 9.34315 32.6569 8 31 8Z", + p5075f20: + "M20 22C21.1046 22 22 21.1046 22 20C22 18.8954 21.1046 18 20 18C18.8954 18 18 18.8954 18 20C18 21.1046 18.8954 22 20 22Z", + pad44800: + "M20 34C27.732 34 34 27.732 34 20C34 12.268 27.732 6 20 6C12.268 6 6 12.268 6 20C6 27.732 12.268 34 20 34Z", + pe310000: + "M30 34C33.3137 34 36 31.3137 36 28C36 24.6863 33.3137 22 30 22C26.6863 22 24 24.6863 24 28C24 31.3137 26.6863 34 30 34Z" +} diff --git a/components/learn/palette.ts b/components/learn/palette.ts new file mode 100644 index 000000000..764cb01d2 --- /dev/null +++ b/components/learn/palette.ts @@ -0,0 +1,38 @@ +// Stage palette for the Learn section. +// +// The Figma prototype hardcoded NAVY #1A3185, GREEN #3D9922, ORANGE #FF8600 and +// CRIMSON #C71E32. Those are byte-identical to $blue/$green/$orange/$red in +// styles/bootstrap.scss, so we reference the emitted custom properties instead +// of repeating the hexes. + +export const NAVY = "var(--bs-blue)" +export const GREEN = "var(--bs-green)" +export const ORANGE = "var(--bs-orange)" +export const CRIMSON = "var(--bs-red)" + +/** + * Opaque tints used for a completed rail node's fill. These are design values + * from the prototype, not a computed percentage of the base color, so they are + * kept as literals rather than derived with color-mix. + */ +export const TINT: Record = { + [NAVY]: "#e1e4ef", + [GREEN]: "#e5f1e2", + [ORANGE]: "#ffefdd", + [CRIMSON]: "#f8e1e4" +} + +/** + * Translucent variant of a stage color. + * + * The prototype appended hex alpha to a hex string (`color + "40"`). That does + * not work on a `var(--bs-blue)` reference, so the same ratios are expressed as + * percentages through color-mix. + * + * 0x40 -> 25%, 0x28 -> 16%, 0x18 -> 9%, 0x12 -> 7% + */ +export const alpha = (color: string, percent: number) => + `color-mix(in srgb, ${color} ${percent}%, transparent)` + +/** Ordered stage colors, cycling navy -> green -> orange -> crimson. */ +export const STAGE_COLORS = [NAVY, GREEN, ORANGE, CRIMSON, NAVY, GREEN] diff --git a/components/publish/WriteTestimony.tsx b/components/publish/WriteTestimony.tsx index 859785f08..df322708a 100644 --- a/components/publish/WriteTestimony.tsx +++ b/components/publish/WriteTestimony.tsx @@ -84,7 +84,7 @@ export const WriteTestimony = styled(props => { } components={[ // eslint-disable-next-line react/jsx-key - + ]} /> diff --git a/next.config.js b/next.config.js index c8980b67e..8cdcb373b 100644 --- a/next.config.js +++ b/next.config.js @@ -17,7 +17,22 @@ const config = { module.exports = { ...config, async redirects() { - return [redirectFirebaseAuthHandlers()] + return [ + redirectFirebaseAuthHandlers(), + // The four Learn testimony tabs were consolidated into /learn/testimony. + // These slugs were linked from the footer and from external sites, so + // they redirect permanently rather than 404. + ...[ + "testimony-basics", + "role-of-testimony", + "writing-effective-testimony", + "communicating-with-legislators" + ].map(slug => ({ + source: `/learn/${slug}`, + destination: "/learn/testimony", + permanent: true + })) + ] }, async rewrites() { const rewrites = [ diff --git a/pages/learn/[slug].tsx b/pages/learn/[slug].tsx deleted file mode 100644 index 491619fb4..000000000 --- a/pages/learn/[slug].tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useRouter } from "next/router" -import { createPage } from "../../components/page" -import { - Basics, - Role, - Write, - CommunicatingWithLegislators -} from "../../components/LearnTestimonyComponents/LearnComponents" -import Tabs from "../../components/Tabs/Tabs" -import { GetStaticPaths, GetStaticProps } from "next/types" -import { ParsedUrlQuery } from "querystring" -import { serverSideTranslations } from "next-i18next/serverSideTranslations" - -type TabsType = { - label: string - slug: string - index: number - Component: React.FC<{}> -} - -type Props = { - slug: string -} - -interface IParams extends ParsedUrlQuery { - slug: string -} - -const tabs: TabsType[] = [ - { - label: "Testimony Basics", - slug: "testimony-basics", - index: 1, - Component: Basics - }, - { - label: "The Role of Testimony", - slug: "role-of-testimony", - index: 2, - Component: Role - }, - { - label: "Writing Effective Testimony", - slug: "writing-effective-testimony", - index: 3, - Component: Write - }, - { - label: "Communicating with Legislators", - slug: "communicating-with-legislators", - index: 4, - Component: CommunicatingWithLegislators - } -] - -export default createPage<{ params: IParams }>({ - titleI18nKey: "learn", - Page: props => { - const router = useRouter() - - const { slug } = props.params as Props - - const [selectedTab] = tabs.filter(t => t.slug === slug) - - return ( - { - const [indexedTab] = tabs.filter(t => t.index === index) - if (indexedTab !== selectedTab) { - router.push(`/learn/${indexedTab.slug}`) - } - }} - tabs={tabs} - /> - ) - } -}) - -export const getStaticPaths: GetStaticPaths = async () => { - // Get the paths we want to prerender based on posts - // In production environments, prerender all pages - // (slower builds, but faster initial page load) - const paths = tabs.map(tab => ({ - params: { slug: tab.slug } - })) - - // { fallback: false } means other routes should 404 - return { paths, fallback: false } -} - -export const getStaticProps: GetStaticProps = async context => { - const params = context.params as IParams - const locale = context.locale ?? context.defaultLocale ?? "en" - - return { - props: { - params, - ...(await serverSideTranslations(locale, [ - "auth", - "common", - "footer", - "testimony", - "learnComponents" - ])) - } - } -} diff --git a/pages/learn/ai-tools.tsx b/pages/learn/ai-tools.tsx index da5b50b00..06dc0156c 100644 --- a/pages/learn/ai-tools.tsx +++ b/pages/learn/ai-tools.tsx @@ -1,22 +1,19 @@ -import { Container } from "../../components/bootstrap" import { createPage } from "../../components/page" import { createGetStaticTranslationProps } from "components/translations" import AiTools from "components/learn/AiTools/AiTools" +// Route and body are load-bearing: mcp-server/auth.ts and +// functions/src/mcp/proxy.ts send external MCP users to +// https://mapletestimony.org/learn/ai-tools for setup instructions. export default createPage({ titleI18nKey: "titles.ai_tools", - Page: () => { - return ( - - - - ) - } + Page: () => }) export const getStaticProps = createGetStaticTranslationProps([ "auth", "common", "footer", + "learn", "aiTools" ]) diff --git a/pages/learn/index.tsx b/pages/learn/index.tsx index a80f82b3c..07f9e3cf5 100644 --- a/pages/learn/index.tsx +++ b/pages/learn/index.tsx @@ -1,12 +1,15 @@ -import { useRouter } from "next/router" -import { useEffect } from "react" +import { createPage } from "../../components/page" +import LearnHub from "components/learn/Hub/LearnHub" +import { createGetStaticTranslationProps } from "components/translations" -export default function Page() { - const router = useRouter() +export default createPage({ + titleI18nKey: "titles.learn_hub", + Page: () => +}) - useEffect(() => { - router.push("/learn/testimony-basics") - }) - - return null -} +export const getStaticProps = createGetStaticTranslationProps([ + "auth", + "common", + "footer", + "learn" +]) diff --git a/pages/learn/legislative-process.tsx b/pages/learn/legislative-process.tsx index 76c892b74..c533a3369 100644 --- a/pages/learn/legislative-process.tsx +++ b/pages/learn/legislative-process.tsx @@ -1,26 +1,17 @@ -import AdditionalResources from "components/AdditionalResources" -import { Container } from "../../components/bootstrap" import { createPage } from "../../components/page" -import Legislative from "components/Legislative/Legislative" +import LegislativeProcess from "components/learn/Process/LegislativeProcess" import { createGetStaticTranslationProps } from "components/translations" -// TODO: Change export default createPage({ titleI18nKey: "titles.legislative_process", - Page: () => { - return ( - - - - - ) - } + Page: () => }) +// `learnComponents` stays: AdditionalResources reads its legislative.* keys. export const getStaticProps = createGetStaticTranslationProps([ "auth", "common", "footer", - "testimony", + "learn", "learnComponents" ]) diff --git a/pages/learn/testimony.tsx b/pages/learn/testimony.tsx new file mode 100644 index 000000000..ad65dbfdf --- /dev/null +++ b/pages/learn/testimony.tsx @@ -0,0 +1,15 @@ +import { createPage } from "../../components/page" +import AboutTestimony from "components/learn/Testimony/AboutTestimony" +import { createGetStaticTranslationProps } from "components/translations" + +export default createPage({ + titleI18nKey: "titles.about_testimony", + Page: () => +}) + +export const getStaticProps = createGetStaticTranslationProps([ + "auth", + "common", + "footer", + "learn" +]) diff --git a/pages/why-use-maple/[slug].tsx b/pages/why-use-maple/[slug].tsx index f0a2fe36b..f21512d2b 100644 --- a/pages/why-use-maple/[slug].tsx +++ b/pages/why-use-maple/[slug].tsx @@ -1,20 +1,10 @@ import { useRouter } from "next/router" import { createPage } from "../../components/page" -import ForIndividuals from "../../components/about/ForIndividuals/ForIndividuals" -import ForOrgs from "../../components/about/ForOrgs/ForOrgs" -import ForLegislators from "../../components/about/ForLegislators/ForLegislators" -import Tabs from "../../components/Tabs/Tabs" +import WhyUseMaple, { PERSONAS } from "components/learn/WhyUseMaple/WhyUseMaple" import { GetStaticPaths, GetStaticProps } from "next/types" import { ParsedUrlQuery } from "querystring" import { serverSideTranslations } from "next-i18next/serverSideTranslations" -type TabsType = { - label: string - slug: string - index: number - Component: React.FC<{}> -} - type Props = { slug: string } @@ -23,61 +13,40 @@ interface IParams extends ParsedUrlQuery { slug: string } -const tabs: TabsType[] = [ - { - label: "For Individuals", - slug: "for-individuals", - index: 1, - Component: ForIndividuals - }, - { - label: "For Organizations", - slug: "for-orgs", - index: 2, - Component: ForOrgs - }, - { - label: "For Legislators", - slug: "for-legislators", - index: 3, - Component: ForLegislators - } -] - export default createPage<{ params: IParams }>({ titleI18nKey: "titles.why_use_maple", Page: props => { const router = useRouter() - const { slug } = props.params as Props - - const [selectedTab] = tabs.filter(t => t.slug === slug) + // After a shallow navigation `props.params` is stale, so read the live + // route. It falls back to the build-time param for the first render. + const slug = + (router.query.slug as string | undefined) ?? (props.params as Props).slug + // Each persona keeps its own URL, so all three stay independently linkable + // and server-render with the right persona selected. + // + // The push is shallow: getStaticProps already loads all three persona + // namespaces for every slug, so re-fetching `/_next/data/.json` on + // each click would buy nothing and just add latency. return ( - { - const [indexedTab] = tabs.filter(t => t.index === index) - if (indexedTab !== selectedTab) { - router.push(`/why-use-maple/${indexedTab.slug}`) - } + { + if (next !== slug) + router.push(`/why-use-maple/${next}`, undefined, { shallow: true }) }} - tabs={tabs} /> ) } }) export const getStaticPaths: GetStaticPaths = async () => { - // Get the paths we want to prerender based on posts - // In production environments, prerender all pages - // (slower builds, but faster initial page load) - const paths = tabs.map(tab => ({ - params: { slug: tab.slug } - })) - - // { fallback: false } means other routes should 404 - return { paths, fallback: false } + return { + paths: PERSONAS.map(p => ({ params: { slug: p.slug } })), + // { fallback: false } means other routes should 404 + fallback: false + } } export const getStaticProps: GetStaticProps = async context => { @@ -91,7 +60,7 @@ export const getStaticProps: GetStaticProps = async context => { "auth", "common", "footer", - "testimony", + "learn", "forindividuals", "forlegislators", "fororgs" diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ea6789c8f..ec1f29d0f 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -199,7 +199,9 @@ "policies": "Policies", "unsubscribe": "Unsubscribe", "why_use_maple": "Why Use Maple?", - "ai_tools": "AI Research Tools" + "ai_tools": "AI Research Tools", + "learn_hub": "Learn", + "about_testimony": "How Testimony Works" }, "user_updates": "User Updates", "view": "View", diff --git a/public/locales/en/footer.json b/public/locales/en/footer.json index 1423b0ee9..f827543b8 100644 --- a/public/locales/en/footer.json +++ b/public/locales/en/footer.json @@ -6,9 +6,10 @@ "resources": "Resources" }, "links": { - "learnWriting": "To Write Effective Testimony", + "learnWriting": "About Testimony", "learnProcess": "About the Legislative Process", "learnWhy": "Why Use MAPLE", + "learnAi": "AI Research Tools", "supportMaple": "Support MAPLE", "ourMission": "Mission & Goals", "team": "Team", @@ -16,10 +17,9 @@ "mapleAI": "How MAPLE Uses AI", "socials": { "instagram": "Follow MAPLE on Intagram", - "linkedin":"Follow MAPLE on LinkedIn", - "twitter":"Follow MAPLE on Twitter" + "linkedin": "Follow MAPLE on LinkedIn", + "twitter": "Follow MAPLE on Twitter" } - }, "legal": { "disclaimer": "MAPLE is an independent project, not affiliated with the Massachusetts legislature", diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json new file mode 100644 index 000000000..dcd547483 --- /dev/null +++ b/public/locales/en/learn.json @@ -0,0 +1,202 @@ +{ + "breadcrumbLabel": "Breadcrumb", + "hub": { + "eyebrow": "Learn", + "title": "Your civic engagement guide", + "subhead": "Everything you need to understand how to make your voice heard in Massachusetts.", + "explore": "Explore", + "cards": [ + { + "id": "testimony", + "title": "About Testimony", + "desc": "Learn what testimony is, how to write it, and why it matters.", + "href": "/learn/testimony" + }, + { + "id": "process", + "title": "Legislative Process", + "desc": "Understand how a bill becomes a law in Massachusetts.", + "href": "/learn/legislative-process" + }, + { + "id": "why", + "title": "Why Use MAPLE", + "desc": "Whether you're a citizen, org, or legislator — MAPLE is for you.", + "href": "/why-use-maple/for-individuals" + }, + { + "id": "ai", + "title": "AI Research Tools", + "desc": "Search MAPLE's database with a conversational AI assistant.", + "href": "/learn/ai-tools" + } + ] + }, + "testimony": { + "breadcrumb": "About Testimony", + "title": "How Testimony Works", + "subhead": "All laws passed by state legislatures should consider feedback from residents and stakeholders. In Massachusetts, submitting written testimony to the legislature is one of the most direct ways to be heard.", + "cards": [ + { + "badge": "who", + "headline": "Anyone can submit testimony", + "body": "Legislators value testimony most when it comes from their own constituents. Testimony from MA residents is typically directed to both the committee responsible for the bill and the legislators representing your district." + }, + { + "badge": "what", + "headline": "Make it distinctive and relevant", + "body": "Write your own text and explain why you are personally interested in an issue. Form letters still count, but an individual, personalized letter will always have greater impact." + }, + { + "badge": "when", + "headline": "Before the committee hearing date", + "body": "Committees generally accept testimony up until the public hearing date for a bill. You can use bill pages on this site to identify relevant dates. Submit before the hearing for the greatest impact." + }, + { + "badge": "where", + "headline": "By email to committee chairs", + "body": "MAPLE makes it easy to find a bill you want to testify on and generate an email — which you fully control — to send to the relevant committee personnel." + } + ], + "why": { + "headline": "To shape the laws that govern your life", + "body": "If you don't share your perspective, it may not be taken into account when policymakers make decisions about the laws that govern all our lives. By speaking up, you can make the laws of Massachusetts work better for all of us. MAPLE is designed to make it more accessible.", + "mattersHeading": "Why it Matters:", + "matters": [ + { + "title": "Your voice shapes the legislative agenda", + "body": "It can guide what topics and bills the legislature considers, and how they decide to act and vote on each one." + }, + { + "title": "You give legislators real-world insight", + "body": "Testimony informs legislators of the benefits or negative consequences of proposed policies — context that doesn't always appear in the official record." + }, + { + "title": "You can recommend specific changes", + "body": "You can suggest concrete improvements to legislation, whether you generally support or oppose a bill. Specificity makes your testimony more actionable." + } + ] + }, + "readyToStart": "Ready to get started?", + "how": { + "headline": "To make your voice heard", + "intro": "Step through the process from picking a topic important to you, reading bills and ballot questions that impact you, deciding your own stance, and clicking through to submit. Once you are ready, here are your options:", + "steps": [ + { + "title": "Testify in writing", + "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill." + }, + { + "title": "Testify in person", + "body": "You can attend a public hearing for a bill of interest to you and sign up for a slot to speak before the Committee.", + "linkLabel": "Check the calendar:", + "linkText": "malegislature.gov/Events/Calendar/", + "href": "https://malegislature.gov/Events/Calendar/" + }, + { + "title": "Call your legislator", + "body": "You can contact your legislators any time by looking up their contact information on the MA Legislature website. Your voice will probably carry the most weight with the House and Senate representatives of your own district, but you are free to contact Committee Chairs or any other member of the legislature with your opinions. You could request a meeting in person.", + "linkLabel": "Find your legislator's number:", + "linkText": "malegislature.gov/Search/FindMyLegislator", + "href": "https://malegislature.gov/Search/FindMyLegislator" + } + ] + }, + "tipsToggle": "Tips for writing effective testimony", + "tipsIntro": "Clearly outline what bill you are testifying about, whether you support or oppose it, why you are concerned about the issue, what policy you would like to see enacted, and what legislative district you live in.", + "tips": [ + { + "label": "Be Timely", + "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill." + }, + { + "label": "Be Original", + "body": "Legislators receive a lot of form letters. These communications are important, but almost always an individual and personalized letter will have greater impact." + }, + { + "label": "Be Informative", + "body": "Whether you're a longtime advocate or first-time testifier, your testimony is important. Explain why you are concerned about an issue and why you think one policy choice would be better than another." + }, + { + "label": "Be Direct", + "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language." + }, + { + "label": "Be Respectful", + "body": "No matter how strongly you hold your position, there may be people of good intent who feel differently. Respectful testimony will carry more weight with legislators — especially those you may need to persuade." + } + ] + }, + "aiTools": { + "breadcrumb": "AI Research Tools" + }, + "whyUseMaple": { + "breadcrumb": "Why Use MAPLE", + "title": "Why Use MAPLE?", + "subhead": "Whether you're a citizen, an organization, or a legislator — MAPLE is for you." + }, + "process": { + "breadcrumb": "Legislative Process", + "title": "Understanding the Legislative Process", + "subhead": "How most bills become laws in Massachusetts.", + "railLabel": "Legislative process stages", + "expandAll": "Click here to expand all sections", + "stages": [ + "Bill Introduction", + "Committee Referral", + "Public Hearing", + "Committee Report", + "Floor Debate & Vote", + "Governor's Action" + ], + "chapters": [ + { + "num": "01", + "title": "A Bill is Introduced", + "lead": "Every law starts as an idea. Any MA resident can petition their legislator to file a bill.", + "body": "Bills can be filed at any time during the two-year session, but most are filed before the session begins — the standard deadline falls in December of the preceding year. Bills filed after that deadline require special approval. They receive a unique identifier — H. for House-originated bills, S. for Senate. The same idea can be filed in both chambers simultaneously. Bills are publicly available on the MA Legislature website and through MAPLE.", + "stat": { + "value": "6,000+", + "label": "bills filed each session", + "href": "/bills" + } + }, + { + "num": "02", + "title": "Committee Referral", + "lead": "Every bill is routed to a Joint Committee based on its subject matter.", + "body": "There are 33 Joint Legislative Committees covering topics from Education to Public Safety. Joint Committees include members from both the House and Senate. This is where the real work of examining a bill begins. Every bill is entitled to a hearing — but most bills are sent to study after that hearing, which ends their progress for that session.", + "stat": { + "value": "33", + "label": "Joint Committees", + "href": "https://malegislature.gov/Committees/Joint" + } + }, + { + "num": "03", + "title": "Public Hearing & Testimony", + "lead": "This is your moment. Anyone can submit written testimony or speak in person.", + "body": "Committees schedule public hearings where citizens, advocacy groups, experts, and government officials can all testify. Written testimony submitted before the hearing is accepted and reviewed by committee staff. Legislators pay attention to volume — and especially to constituent voices from their own districts.", + "callout": "MAPLE makes it easy to find a bill's hearing date and submit testimony directly to the committee chairs via email." + }, + { + "num": "04", + "title": "Committee Report", + "lead": "The committee decides a bill's fate — advance, amend, or let it die.", + "body": "After reviewing testimony and staff analysis, the committee votes whether to report the bill favorably, unfavorably, or with amendments. A bill reported favorably advances to the full chamber. Most bills are 'sent to study' — a formal committee recommendation that ends the bill's progress for that session. A bill can be refiled in the next session and go through the process again." + }, + { + "num": "05", + "title": "Floor Debate & Vote", + "lead": "Bills that clear committee are debated and amended on the floor, reviewed, and then voted on by each full chamber.", + "body": "In Massachusetts, each chamber reads a bill three times before voting on it. The first reading introduces the bill to the chamber. The second is where floor debate happens — members can propose amendments and changes to the text. The third sends the bill to the Committee on Bills in the Third Reading, which reviews the language before the chamber votes. A bill that passes is 'engrossed,' meaning that chamber has formally adopted its version of the text.\n\nBoth the House and Senate go through this process separately and must end up with identical text. If they pass different versions, a Conference Committee — three members from each chamber — reconciles them into one version. Both chambers then vote to accept or reject it with no further amendments allowed." + }, + { + "num": "06", + "title": "Governor's Action", + "lead": "The Governor has 10 days to sign, veto, or return a bill with amendments.", + "body": "A signed bill is 'chaptered' and becomes part of Massachusetts General Laws. A vetoed bill can be overridden by a 2/3 vote in each chamber. If the Governor returns the bill with amendments, the legislature can accept the amendments, override them, or let the bill die. If the Governor takes no action within 10 days and the legislature is still in session, the bill becomes law automatically. If the legislature has adjourned, the same inaction kills the bill — this is called a pocket veto." + } + ] + } +} diff --git a/public/locales/en/learnComponents.json b/public/locales/en/learnComponents.json index c4c9d44cc..a1d91a292 100644 --- a/public/locales/en/learnComponents.json +++ b/public/locales/en/learnComponents.json @@ -1,138 +1,5 @@ { - "basics": { - "title": "Testimony Basics", - "intro": "All laws passed by state legislatures should consider feedback from residents and community stakeholders. In Massachusetts, one way to have your voice heard is by submitting written testimony regarding specific bills. This website, the MAPLE platform, can help you submit your written testimony to the MA Legislature. However, please note that this is not an official government website and is not the only way to submit your testimony. Here are the essential things to know before submitting testimony:", - "content": [ - { - "title": "Anyone can submit testimony to the MA legislature", - "paragraph": "Legislators tend to value testimony most when it comes from their own constituents. Testimony from MA residents is typically directed to both the committee that is substantively responsible for the bill as well as the legislators (House member and Senator) representing your district." - }, - { - "title": "Your testimony will be most impactful when it feels distinctive and relevant", - "paragraph": "Be sure to write your own text and explain why you are interested in an issue." - }, - { - "title": "Committees generally accept testimony up until the hearing date designated for a bill", - "paragraph": "You can use the bill pages on this website to identify relevant committee dates. Although some committees will accept testimony after this date, for the greatest impact you should submit your testimony before the hearing." - }, - { - "title": "Testimony is generally accepted by committees of the legislature by sending an email to their Chairs", - "paragraph": "This website, MAPLE, will help you to do this by making it easy to find a bill you want to testify in and then generate an email, which you fully control, which you can then send to the relevant personnel." - }, - { - "title": "The key role of testimony is to let your legislators know how you feel about an issue", - "paragraph": "If you don't share your perspective, it will not be taken into account when policymakers make decisions about the laws that govern all our lives." - } - ] - }, - "role": { - "title": "The Role of Testimony", - "intro": "By speaking up, you can make the laws of Massachusetts work better for all of us! Everyone is able to convey their opinions to the legislature, but the process to submit testimony can be confusing and intimidating. We hope that this website, the MAPLE platform, will make that process easier, more straightforward, and more accessible to all stakeholders.", - "content": [ - { - "title": "Your voice is instrumental to the legislative process", - "paragraph": "It could guide the agenda of the legislature, what topics and bills they consider, and how they decide to act and vote on each bill." - }, - { - "title": "Your voice gives them insight", - "paragraph": "It can inform legislators of the benefits or negative consequences of proposed policies." - }, - { - "title": "You can give suggestions", - "paragraph": "You can also recommend specific changes or improvements to legislation, whether you generally support or oppose the bill." - } - ] - }, - "write": { - "title": "Writing Effective Testimony", - "intro": "The basics of writing effective testimony are to clearly outline what bill you are testifying about, whether you support or oppose it, why you are concerned about the issue, what policy you would like to see enacted, and what legislative district you live in. Here are some tips you can use to make sure the testimony you submit is as impactful as possible:", - "content": [ - { - "title": "Be Timely", - "paragraphs": [ - "Written testimony should be targeted towards specific bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill.", - "Some committees will continue accepting testimony after the hearing date, but it may not have the same impact on their deliberations." - ] - }, - { - "title": "Be Original", - "paragraphs": [ - "Legislators receive a lot of form letters and repeated sentiments from organized groups of constituents. These communications are important and their volume can be meaningful to legislators. But, almost always, an individual and personalized letter will have a greater impact." - ] - }, - { - "title": "Be Informative", - "paragraphs": [ - "Whether you are a longtime advocate or a first time testifier, whether you have a doctoral degree in the subject or lived experience regarding a policy, and no matter your age, race, creed, or background, your testimony is important. Explain why you are concerned about an issue and why you think one policy choice would be better than another. For example, your being a parent gives you special insight into education policy, your living in a community affected by pollution gives you special insight into environmental policy, etc." - ] - }, - { - "title": "Be Direct", - "paragraphs": [ - "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't have to know specific legal precedents or legislative language; just explain what you would like to happen and why." - ] - }, - { - "title": "Be Respectful", - "paragraphs": [ - "No matter how strongly and sincerely held your position is, there may be people of good intent who feel oppositely and expect and deserve to have their opinions considered by their legislators also. Respectful testimony will carry more weight with legislators, especially those who you may need to persuade to your side of an issue." - ] - } - ] - }, - "communicating": { - "title": "Communicating with Legislators", - "intro": "There are multiple ways to share your perspective and knowledge with your legislators.", - "testifyInWriting": { - "title": "Testify in writing", - "content": "You can submit your thoughts on a bill to the Committee hearing it before the date of their public hearing. This website, the MAPLE platform, focuses on this mechanism." - }, - "testifyOrally": { - "title": "Testify orally", - "content": "You can attend a public hearing for a bill of interest to you and sign up for a slot to speak before the Committee." - }, - "writeOrCall": { - "title": "Write or call them", - "content": "You can contact your legislators any time by looking up their contact information on the MA Legislature website. Your voice will probably carry the most weight with the House and Senate representatives of your own district, but you are free to contact Committee Chairs or any other member of the legislature with your opinions. You could request a meeting in person." - } - }, "legislative": { - "title": "Understanding the Massachusetts Legislative Process", - "intro": "Some of the key steps in the legislative process for how most bills become laws in MA.", - "content": [ - { - "title": "Filing", - "paragraph": "At the start of each two-year legislative session, Senators and Representatives (and sometimes the Governor or state agencies) file thousands of bills. This can be done at any time, but is typically done before the start of the legislative session (i.e. before the third Friday in January of odd-numbered years). Bills filed later (after the first month of the session) during the legislative session, require special approval." - }, - { - "title": "Committees", - "paragraph": "Each bill is assigned to the committee most relevant to its content (for example, a bill about water quality goes to the Joint Committee on Environment and Natural Resources). Committees are critical; they dictate whether a bill \"fails\" or whether it moves forward in the legislative process. In Massachusetts, bills are usually assigned to \"Joint\" committees - made up of Representatives and Senators. In some other states, House committees and Senate committees work independently, but that is rarely the case in Massachusetts. " - }, - { - "title": "Public Hearing", - "paragraph": "All bills are formally heard by committees during public hearings which are open to the public and recorded and posted as videos on the legislature's website. While the amount of time available to speak during a public hearing is limited, more detailed comments can be submitted to the committee in written testimony. All stakeholders have the chance to share their thoughts with the legislature by submitting written testimony. Typically, testimony is submitted to the committee assigned to the bill. You can also deliver oral testimony by attending a hearing or you can reach out to your legislators and speak to them directly." - }, - { - "title": "Committee Reports", - "paragraph": "Committees must file reports on each bill under their consideration after discussing them in Executive Session following the public hearing. This typically occurs before February of the second year of the legislative session (even-numbered years). The goal for proponents of a bill is that the Committee will recommend they \"ought to pass\" and thereby promote them out of the Committee. Most bills, however are \"sent to study,\" meaning that they will not be passed out of committee in that session. A successful bill may be redrafted or amended by the committee based on testimony received and other deliberations. Many of those bills will be refiled in the next session and can be considered again." - }, - { - "title": "Three Readings", - "paragraph": "Each bill passed out of Committee will be \"read\" three times by each branch, the House and Senate. This typically entails floor debate of the full chamber and a vote of the Committee on Ways and Means or Steering and Policy." - }, - { - "title": "Engrossment and Enactment", - "paragraph": "After the three readings, the bill will be voted on by each of the full chambers (House and Senate), resulting in (if successful) \"engrossment\" and then \"enactment.\"" - }, - { - "title": "Conference Committee", - "paragraph": "If necessary, differences between the House and Senate versions of a bill will be reconciled by a temporary Conference Committee appointed by the House Speaker and Senate President." - }, - { - "title": "Executive Branch", - "paragraph": "Lastly, the Governor is responsible for signing the enacted and reconciled bill into law. The governor can also veto the bill, return it to the Legislature for changes, or take a number of other less-common actions." - } - ], "additional_resources": "Additional Resources", "resources_intro": "We hope these pages will help you submit effective testimony. You may want to consult these other resources to build a more detailed understanding of the legislative process and how you can contribute.", "find_legislator": "The MA Legislature has an <0>online tool you can use to identify your legislators based on your home address.", diff --git a/stories_hold/pages/education/CommunicatingWithLegislators.stories.tsx b/stories_hold/pages/education/CommunicatingWithLegislators.stories.tsx deleted file mode 100644 index f2d49ee94..000000000 --- a/stories_hold/pages/education/CommunicatingWithLegislators.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ComponentStory } from "@storybook/react" -import { createMeta } from "stories/utils" -import { CommunicatingWithLegislators } from "components/LearnTestimonyComponents/LearnComponents" -export default createMeta({ - title: "Pages/Education/CommunicatingWithLegislators", - component: CommunicatingWithLegislators -}) - -const Template: ComponentStory = () => ( - -) - -export const Primary = Template.bind({}) - -Primary.storyName = "CommunicatingWithLegislators" diff --git a/styles/bootstrap.scss b/styles/bootstrap.scss index c42251210..d551b595a 100644 --- a/styles/bootstrap.scss +++ b/styles/bootstrap.scss @@ -7,6 +7,7 @@ $orange: #ff8600; $blue: #1a3185; $dark-blue: #0b0a3e; $green: #3d9922; +$gold: #fbbc04; $gray-700: #495057; $pantone-blue: #d1d6e7; @@ -57,6 +58,10 @@ $maple-text-inverse-muted: rgba(255, 255, 255, 0.55); $maple-surface-base: #ffffff; // Provisional: keep the current page background in PR 1 and retune later with surface contrast as a group. $maple-surface-page: #eae7e7; +// Learn section only. Distinct from $maple-surface-page on purpose. +$maple-surface-learn: #eef0f8; +// The HOW badge on /learn/testimony. Not part of the core brand palette. +$maple-learn-cyan: #42c1e1; $maple-surface-muted: rgba(248, 250, 252, 0.9); $maple-surface-accent: rgba(94, 114, 228, 0.08); $maple-surface-accent-strong: rgba(94, 114, 228, 0.12); @@ -117,6 +122,8 @@ $maple-space-2xl: 2rem; $maple-space-3xl: 3rem; // Typography +$maple-font-heading: "Lexend", system-ui, -apple-system, "Segoe UI", sans-serif; + $maple-font-weight-normal: 400; $maple-font-weight-medium: 500; $maple-font-weight-semibold: 600; @@ -236,14 +243,22 @@ $utilities: ( @import "bootstrap/scss/bootstrap"; -// Import fonts using the default bootstrap weights -@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;700&display=swap"); +// Import fonts using the default bootstrap weights. +// Lexend is the display face for the Learn section headings; Nunito remains the body face. +@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;700&family=Lexend:wght@400;600;700;900&display=swap"); :root { --bs-blue-100: #{$blue-100}; --bs-blue-300: #{$blue-300}; --bs-red-100: #{$red-100}; --bs-dark-blue: #0b0a3e; + --bs-gold: #{$gold}; + + --maple-font-heading: #{$maple-font-heading}; + + // Height of the sticky site navbar. The Learn process rail sticks below it, + // and section rows use it for scroll-margin so smooth-scroll targets clear it. + --maple-navbar-height: 4.5rem; // Maple design tokens — emitted from the SCSS variables defined above so // the compile-time (Bootstrap) and runtime (app components) layers stay @@ -267,6 +282,8 @@ $utilities: ( --maple-surface-base: #{$maple-surface-base}; --maple-surface-page: #{$maple-surface-page}; + --maple-surface-learn: #{$maple-surface-learn}; + --maple-learn-cyan: #{$maple-learn-cyan}; --maple-surface-muted: #{$maple-surface-muted}; --maple-surface-accent: #{$maple-surface-accent}; --maple-surface-accent-strong: #{$maple-surface-accent-strong}; From 4c96e4fac9e50958e701d9189dade27c6e6d1897 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 08:48:31 -0400 Subject: [PATCH 02/31] Reword the How section intro and Testify in writing step Replaces the "Step through the process..." intro with a shorter framing line and points readers at MAPLE from the written-testimony step. --- public/locales/en/learn.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index dcd547483..a8db22089 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -80,11 +80,11 @@ "readyToStart": "Ready to get started?", "how": { "headline": "To make your voice heard", - "intro": "Step through the process from picking a topic important to you, reading bills and ballot questions that impact you, deciding your own stance, and clicking through to submit. Once you are ready, here are your options:", + "intro": "There are multiple ways to share your perspective and knowledge with your legislators.", "steps": [ { "title": "Testify in writing", - "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill." + "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill. This is what MAPLE can help with!" }, { "title": "Testify in person", From 658cf43391dabadd1bbf1ad74af54b003c87a0cc Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 09:03:28 -0400 Subject: [PATCH 03/31] Let scroll drive the legislative process accordion The chapter crossing a line just under the sticky rail opens; the previous one closes. "Click here to expand all sections" overrides this and opens everything; clicking the link again, a rail node, or a chapter header hands control back to scroll. Two things this needed: - No manual scroll compensation. The browser's native scroll anchoring already holds the reader's position when a panel above them opens or closes. Compensating on top of it double-corrected, and near the bottom of the page (where scroll also clamps as the document shrinks) the two walked the accordion backwards -- a jump to y=1200 unwound to y=383. - A short settle lock. Without it a reflow can push the next header across the trigger line in the same frame, opening it, and so on down the page. Side effects also had to move out of the setOpenIdx updater: React invokes updaters twice under StrictMode, which fired the lock twice. --- .../learn/Process/LegislativeProcess.tsx | 118 ++++++++++++++---- public/locales/en/learn.json | 1 + 2 files changed, 93 insertions(+), 26 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index c00469119..cbc478864 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -554,6 +554,21 @@ export const LegislativeProcess = () => { const pendingHeaderScrollRef = useRef(false) const headerRef = useRef(null) + // Scroll-driven opening: while the reader scrolls, the chapter crossing the + // line just under the rail opens and the previous one closes. + // + // Opening a panel reflows everything below it, so a naive implementation + // oscillates: the row that just opened pushes the next header across the + // trigger line, which opens that one, and so on. Two guards prevent that. + // `suppressUntilRef` ignores scroll while a programmatic smooth-scroll is in + // flight, and `anchorRef` pins the newly-opened header in place across the + // reflow so the page does not shift under the reader. + const suppressUntilRef = useRef(0) + // Mirrors openIdx so the scroll handler can compare without reading state + // inside a setState updater (updaters must stay pure — React invokes them + // twice in StrictMode, which fired the anchor and the lock twice). + const openIdxRef = useRef(0) + // The rail must sit directly beneath whatever remains of the site navbar. // // A one-off height measurement is not enough: the navbar's `sticky-top` does @@ -613,6 +628,60 @@ export const LegislativeProcess = () => { return () => observer.disconnect() }, []) + // No manual scroll compensation here: the browser's native scroll anchoring + // already keeps the reader's position stable when a panel above them opens or + // closes. Compensating on top of it double-corrects, and near the bottom of + // the document (where the scroll position also clamps as the page shrinks) + // the two together walk the accordion backwards. + + // Scroll drives which chapter is open, unless everything is expanded. + useEffect(() => { + if (expandAll) return + + let frame = 0 + const evaluate = () => { + frame = 0 + if (performance.now() < suppressUntilRef.current) return + if (pendingScrollRef.current !== null) return + + const rail = railRef.current + if (!rail) return + // The trigger line sits just below the sticky rail. + const line = rail.getBoundingClientRect().bottom + 24 + + const headers = Array.from( + document.querySelectorAll('[id^="learn-process-header-"]') + ) + let candidate = 0 + headers.forEach((header, i) => { + if (header.getBoundingClientRect().top <= line + 4) candidate = i + }) + + if (openIdxRef.current === candidate) return + + // Let the reflow and its scroll correction settle before evaluating + // again, so a correction cannot immediately retrigger the next chapter. + suppressUntilRef.current = performance.now() + 220 + openIdxRef.current = candidate + setActiveIdx(candidate) + setOpenIdx(candidate) + } + + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(evaluate) + } + + evaluate() + window.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + } + }, [expandAll]) + useEffect(() => { const target = pendingScrollRef.current if (target === null) return @@ -626,6 +695,7 @@ export const LegislativeProcess = () => { // view. A card below the fold rises just far enough to be fully visible // rather than jumping to the top of the viewport; a card above the fold // aligns to its top, where scroll-margin-top clears the navbar and rail. + suppressUntilRef.current = performance.now() + (reduceMotion ? 100 : 900) el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" @@ -659,36 +729,30 @@ export const LegislativeProcess = () => { }) }, [activeIdx]) - const selectStage = useCallback((i: number) => { + // Clicking a rail node or a chapter header leaves expand-all and hands + // control back to scroll: the chapter is opened and scrolled to, and from + // there scrolling takes over again. + const focusChapter = useCallback((i: number) => { + suppressUntilRef.current = performance.now() + 900 pendingScrollRef.current = i + openIdxRef.current = i setExpandAll(false) setActiveIdx(i) setOpenIdx(i) }, []) - const toggleChapter = useCallback( - (i: number) => { - if (expandAll) { - // Leaving expand-all: keep the chapter the reader just reached for. - setExpandAll(false) - setOpenIdx(i) - setActiveIdx(i) - return - } - setOpenIdx(prev => { - const next = prev === i ? null : i - if (next !== null) setActiveIdx(next) - return next - }) - }, - [expandAll] - ) - - // Expand-only. Any rail node or chapter header returns to the normal - // one-at-a-time accordion. - const expandAllSections = useCallback(() => { - setExpandAll(true) - pendingHeaderScrollRef.current = true + const selectStage = focusChapter + const toggleChapter = focusChapter + + const toggleExpandAll = useCallback(() => { + suppressUntilRef.current = performance.now() + 900 + setExpandAll(prev => { + // Collapsing hands control back to scroll: the chapter at the trigger + // line reopens on the next frame. + if (prev) return false + pendingHeaderScrollRef.current = true + return true + }) }, []) return ( @@ -706,11 +770,13 @@ export const LegislativeProcess = () => { {t("process.subhead")}{" "} panelId(i)).join(" ")} > - {t("process.expandAll")} + {expandAll + ? t("process.collapseAll") + : t("process.expandAll")} } diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index a8db22089..41dc36340 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -141,6 +141,7 @@ "subhead": "How most bills become laws in Massachusetts.", "railLabel": "Legislative process stages", "expandAll": "Click here to expand all sections", + "collapseAll": "Click here to collapse all sections", "stages": [ "Bill Introduction", "Committee Referral", From 369baa05ab4ec5ae1e154979d47f8f15c4a8ec9f Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 10:13:00 -0400 Subject: [PATCH 04/31] Animate the process accordion; revert scroll-driven opening Scroll-driven opening is removed. It could not be made to feel right: each chapter that collapses telescopes the list upward, so the reader's scroll carries them through several chapters at once. Damping it (settle locks, one-chapter stepping, scroll snapping) either made the open chapter lag behind the reader or made the page feel sticky. Chapters open on click again. The animation from that work is kept. Panels now transition their height via grid-template-rows 0fr -> 1fr, which needs no measured height, with a delayed visibility so a collapsed panel leaves the accessibility tree and the focus order once it has finished closing (verified: links inside a closed panel cannot take focus). Expand-all staggers the panels open. Collapse-all now closes every chapter, including the one open beforehand. --- .../learn/Process/LegislativeProcess.tsx | 173 +++++++----------- 1 file changed, 63 insertions(+), 110 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index cbc478864..d75b6dcc4 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -314,33 +314,36 @@ const RowHeader = styled.h3` ` const Panel = styled.div` - padding: 0 1.75rem 1.75rem; + /* Animating grid-template-rows from 0fr to 1fr gives a height transition + without knowing the content height. The delayed visibility keeps a closed + panel out of the accessibility tree and the focus order once it has + finished collapsing. */ + display: grid; + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; + transition: grid-template-rows 0.55s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s ease, visibility 0s linear 0.55s; + + &[data-open="true"] { + grid-template-rows: 1fr; + opacity: 1; + visibility: visible; + transition: grid-template-rows 0.55s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.32s ease 0.12s, visibility 0s; + transition-delay: var(--learn-panel-delay, 0ms); + } - @keyframes learn-panel-in { - from { - opacity: 0; - transform: translateY(-0.375rem); - } - to { - opacity: 1; - transform: none; - } + .clip { + min-height: 0; + overflow: hidden; } .inner { display: flex; flex-direction: column; gap: 1rem; - padding-top: 1rem; - padding-left: 5.5rem; - animation: learn-panel-in 0.24s ease both; - animation-delay: var(--learn-panel-delay, 0ms); - } - - @media (prefers-reduced-motion: reduce) { - .inner { - animation: none; - } + padding: 1rem 1.75rem 1.75rem 5.5rem; } .body { @@ -394,9 +397,15 @@ const Panel = styled.div` } @media (max-width: 36rem) { - padding: 0 1rem 1.25rem; .inner { - padding-left: 0; + padding: 1rem; + } + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + &[data-open="true"] { + transition: none; } } ` @@ -496,18 +505,16 @@ const AccordionRow = ({ id={panelId(index)} role="region" aria-labelledby={headerId(index)} - hidden={!isOpen} - style={{ borderTop: isOpen ? `2px solid ${alpha(color, 9)}` : undefined }} + data-open={isOpen ? "true" : "false"} + style={ + { + borderTop: isOpen ? `2px solid ${alpha(color, 9)}` : undefined, + "--learn-panel-delay": `${stagger * 55}ms` + } as React.CSSProperties + } > - {isOpen && ( -
    +
    +

    {chapter.body}

    {chapter.stat && ( @@ -526,7 +533,7 @@ const AccordionRow = ({
    )}
    - )} +
    ) @@ -554,21 +561,6 @@ export const LegislativeProcess = () => { const pendingHeaderScrollRef = useRef(false) const headerRef = useRef(null) - // Scroll-driven opening: while the reader scrolls, the chapter crossing the - // line just under the rail opens and the previous one closes. - // - // Opening a panel reflows everything below it, so a naive implementation - // oscillates: the row that just opened pushes the next header across the - // trigger line, which opens that one, and so on. Two guards prevent that. - // `suppressUntilRef` ignores scroll while a programmatic smooth-scroll is in - // flight, and `anchorRef` pins the newly-opened header in place across the - // reflow so the page does not shift under the reader. - const suppressUntilRef = useRef(0) - // Mirrors openIdx so the scroll handler can compare without reading state - // inside a setState updater (updaters must stay pure — React invokes them - // twice in StrictMode, which fired the anchor and the lock twice). - const openIdxRef = useRef(0) - // The rail must sit directly beneath whatever remains of the site navbar. // // A one-off height measurement is not enough: the navbar's `sticky-top` does @@ -634,54 +626,6 @@ export const LegislativeProcess = () => { // the document (where the scroll position also clamps as the page shrinks) // the two together walk the accordion backwards. - // Scroll drives which chapter is open, unless everything is expanded. - useEffect(() => { - if (expandAll) return - - let frame = 0 - const evaluate = () => { - frame = 0 - if (performance.now() < suppressUntilRef.current) return - if (pendingScrollRef.current !== null) return - - const rail = railRef.current - if (!rail) return - // The trigger line sits just below the sticky rail. - const line = rail.getBoundingClientRect().bottom + 24 - - const headers = Array.from( - document.querySelectorAll('[id^="learn-process-header-"]') - ) - let candidate = 0 - headers.forEach((header, i) => { - if (header.getBoundingClientRect().top <= line + 4) candidate = i - }) - - if (openIdxRef.current === candidate) return - - // Let the reflow and its scroll correction settle before evaluating - // again, so a correction cannot immediately retrigger the next chapter. - suppressUntilRef.current = performance.now() + 220 - openIdxRef.current = candidate - setActiveIdx(candidate) - setOpenIdx(candidate) - } - - const schedule = () => { - if (frame) return - frame = requestAnimationFrame(evaluate) - } - - evaluate() - window.addEventListener("scroll", schedule, { passive: true }) - window.addEventListener("resize", schedule) - return () => { - if (frame) cancelAnimationFrame(frame) - window.removeEventListener("scroll", schedule) - window.removeEventListener("resize", schedule) - } - }, [expandAll]) - useEffect(() => { const target = pendingScrollRef.current if (target === null) return @@ -695,7 +639,6 @@ export const LegislativeProcess = () => { // view. A card below the fold rises just far enough to be fully visible // rather than jumping to the top of the viewport; a card above the fold // aligns to its top, where scroll-margin-top clears the navbar and rail. - suppressUntilRef.current = performance.now() + (reduceMotion ? 100 : 900) el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" @@ -729,31 +672,41 @@ export const LegislativeProcess = () => { }) }, [activeIdx]) - // Clicking a rail node or a chapter header leaves expand-all and hands - // control back to scroll: the chapter is opened and scrolled to, and from - // there scrolling takes over again. - const focusChapter = useCallback((i: number) => { - suppressUntilRef.current = performance.now() + 900 + const selectStage = useCallback((i: number) => { pendingScrollRef.current = i - openIdxRef.current = i setExpandAll(false) setActiveIdx(i) setOpenIdx(i) }, []) - const selectStage = focusChapter - const toggleChapter = focusChapter + const toggleChapter = useCallback( + (i: number) => { + if (expandAll) { + // Leaving expand-all: keep the chapter the reader just reached for. + setExpandAll(false) + setOpenIdx(i) + setActiveIdx(i) + return + } + setOpenIdx(prev => { + const next = prev === i ? null : i + if (next !== null) setActiveIdx(next) + return next + }) + }, + [expandAll] + ) const toggleExpandAll = useCallback(() => { - suppressUntilRef.current = performance.now() + 900 setExpandAll(prev => { - // Collapsing hands control back to scroll: the chapter at the trigger - // line reopens on the next frame. if (prev) return false pendingHeaderScrollRef.current = true return true }) - }, []) + // Collapsing closes every chapter, including the one that was open before + // expand-all was pressed. + setOpenIdx(prev => (expandAll ? null : prev)) + }, [expandAll]) return ( // "medium" rather than "narrow": the h1 wraps at 48rem. From 6fad899cad14907299ee3c8dbc73ee4030a98b44 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 10:36:30 -0400 Subject: [PATCH 05/31] Land a clicked chapter below the rail without a corrective jump Clicking a rail stage could leave the chapter's header hidden behind the sticky rail. scrollIntoView({ block: "nearest" }) treats the whole viewport as visible and knows nothing about the rail painted over it, so when the reader had already scrolled the header behind the rail it considered the row in view, declined to scroll, and scroll-margin-top never applied. Correcting afterwards worked but read as a jump. Instead the final position is now predicted before scrolling: the only thing that moves the row is the panel above it collapsing, and that panel can be measured before it animates. One scroll, computed from the card's top border against the rail's live bottom edge: under the rail, it comes down to sit just below it; below the fold, it rises only as far as needed and never past the rail. Also: - overflow-anchor: none on the rows. We compute the post-collapse position ourselves, and the browser's own scroll anchoring correcting the same reflow is what produced the visible jump. - Clicking the already-open chapter now scrolls to it. openIdx did not change, so the effect never ran and pendingScrollRef went stale. - previousOpenRef is synced from a later effect so header toggles and expand-all keep it accurate. Verified across every scroll position x stage combination, and that the safety net after the transition never fires. --- .../learn/Process/LegislativeProcess.tsx | 94 +++++++++++++++++-- public/locales/en/forindividuals.json | 2 +- public/locales/en/forlegislators.json | 2 +- public/locales/en/fororgs.json | 2 +- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index d75b6dcc4..8182dcbd2 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -35,6 +35,9 @@ const prefersReducedMotion = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches +/** Keep in sync with the Panel grid-template-rows transition below. */ +const PANEL_TRANSITION_MS = 550 + const rowId = (i: number) => `learn-process-stage-${i}` const panelId = (i: number) => `learn-process-panel-${i}` const headerId = (i: number) => `learn-process-header-${i}` @@ -213,6 +216,10 @@ const Row = styled.div` box-shadow: var(--maple-shadow-sm); overflow: hidden; + /* We compute the post-collapse scroll position ourselves, so the browser must + not also try to hold position when a panel above this one collapses. */ + overflow-anchor: none; + /* Smooth-scroll targets must clear the sticky navbar and the sticky rail when they align to the top, and keep a little air when they align to the bottom (scrollIntoView block: "nearest"). */ @@ -557,6 +564,11 @@ export const LegislativeProcess = () => { // Set when a rail node is clicked, so that expanding a row from its own // header does not also yank the page around. const pendingScrollRef = useRef(null) + // Which chapter was open before this change, so we know whose panel is about + // to collapse and by how much it will shift the page. + const previousOpenRef = useRef(0) + // Bumped on every click so clicking the already-open chapter still scrolls. + const [scrollNonce, setScrollNonce] = useState(0) // Set when "expand all" is pressed, so the subhead is brought to the top. const pendingHeaderScrollRef = useRef(false) const headerRef = useRef(null) @@ -631,18 +643,79 @@ export const LegislativeProcess = () => { if (target === null) return pendingScrollRef.current = null - const el = document.getElementById(rowId(target)) - if (!el) return + const previous = previousOpenRef.current + previousOpenRef.current = target + + const row = document.getElementById(rowId(target)) + const header = document.getElementById(headerId(target)) + const rail = railRef.current + if (!row || !header || !rail) return const reduceMotion = prefersReducedMotion() - // "nearest" scrolls the least distance that brings the whole card into - // view. A card below the fold rises just far enough to be fully visible - // rather than jumping to the top of the viewport; a card above the fold - // aligns to its top, where scroll-margin-top clears the navbar and rail. - el.scrollIntoView({ - behavior: reduceMotion ? "auto" : "smooth", - block: "nearest" - }) + const behavior: ScrollBehavior = reduceMotion ? "auto" : "smooth" + const GAP = 16 + + // A chapter above this one collapsing pulls this row upward by that panel's + // height. Measure it now, before it animates away, and fold it into a single + // scroll -- otherwise the row slides under the rail and has to be yanked + // back, which is the jump the reader sees. + let shiftUp = 0 + if (previous !== null && previous < target && !expandAll) { + const closing = document.getElementById(panelId(previous)) + if (closing) shiftUp = closing.getBoundingClientRect().height + } + + // The row's height once its own panel has finished opening. The inner + // content is laid out even while the panel is collapsed, so it can be + // measured up front. + const inner = row.querySelector(".inner") + const openPanel = document.getElementById(panelId(target)) + const currentPanelHeight = openPanel + ? openPanel.getBoundingClientRect().height + : 0 + const finalRowHeight = + row.getBoundingClientRect().height - + currentPanelHeight + + (inner ? inner.scrollHeight : 0) + + const railBottom = rail.getBoundingClientRect().bottom + const desiredTop = railBottom + GAP + const finalTop = header.getBoundingClientRect().top - shiftUp + const finalBottom = finalTop + finalRowHeight + const viewportBottom = window.innerHeight - GAP + + // Positive delta scrolls the page down (content moves up). + let delta = 0 + if (finalTop < desiredTop) { + // Would sit under the rail: bring its top border to just below the rail. + delta = finalTop - desiredTop + } else if (finalBottom > viewportBottom) { + // Below the fold: rise only as far as needed, never past the rail. + delta = Math.min(finalBottom - viewportBottom, finalTop - desiredTop) + } + + if (Math.abs(delta) > 1) window.scrollBy({ top: delta, behavior }) + + // Safety net: if a measurement was off, settle it once the transition ends. + // When the prediction is right this is a no-op, so there is no second jump. + const timer = window.setTimeout( + () => { + const overlap = + rail.getBoundingClientRect().bottom + + GAP - + header.getBoundingClientRect().top + if (overlap > 4) window.scrollBy({ top: -overlap, behavior }) + }, + reduceMotion ? 0 : PANEL_TRANSITION_MS + 80 + ) + return () => clearTimeout(timer) + }, [openIdx, scrollNonce, expandAll]) + + // Declared after the scroll effect so that effect still sees the previous + // value. Keeps the reference correct when a chapter is opened or closed + // without a scroll (a header toggle, expand-all). + useEffect(() => { + previousOpenRef.current = openIdx }, [openIdx]) useEffect(() => { @@ -674,6 +747,7 @@ export const LegislativeProcess = () => { const selectStage = useCallback((i: number) => { pendingScrollRef.current = i + setScrollNonce(n => n + 1) setExpandAll(false) setActiveIdx(i) setOpenIdx(i) diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index 5443654ce..440bafc89 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -2,7 +2,7 @@ "title": "MAPLE for Individuals", "callToAction": { "header": "Why use MAPLE", - "title": "MAPLE makes it easy for you to join the Commonwealth-wide conversation about the bills that will shape our future.", + "title": "Making your voice count in the laws that shape our future.", "bodytext": "With a quick search on MAPLE, you can see what bills the state legislature is considering about the topics that matter to you. You can find out what policy changes are proposed, see what other constituents and organizations are saying about them, and-most important-you can submit written testimony directly to our representatives in the State House to make your perspective part of the conversation." }, "benefits": { diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index e24b0c0b1..38c8a5685 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -2,7 +2,7 @@ "title": "MAPLE for Legislators", "callToAction": { "header": "Why use MAPLE", - "title": "MAPLE is a free, nonpartisan, nonprofit platform that makes it easy for legislative offices to see what constituents from around the Commonwealth are saying about bills sitting in any Committee.", + "title": "Gain free, nonpartisan insight into how constituents are thinking about any proposed measure.", "bodytextOne": "Even before you create your own account, you can search our lightning-fast platform for all testimony submitted by MAPLE users about any bill. Our advanced features let you filter to bills sponsored by your office, bills concerning your city, or bills that you choose to follow.", "bodytextTwo": "Why trust MAPLE? We are a nonpartisan, nonprofit, volunteer-developed civic technology initiative focused on increasing engagement between the legislature and its constituents. We offer our product as an open source public good; we are committed not to charging our users and we will never sell the data from our platform." }, diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index aaedc16f6..8bd8f790a 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -2,7 +2,7 @@ "title": "MAPLE for Organizations", "callToAction": { "header": "Why use MAPLE", - "title": "MAPLE is a powerful communication tool for organizations, offered completely for free.", + "title": "Reach a wider civically-engage audience and get your message out.", "bodytext": "It takes only a couple minutes to sign up for your organization's account on MAPLE, giving you immediate access to publish and share your testimony, designate your priority bills, connect with your members and followers, and research legislation." }, "benefits": { From bb1601865daac18fa8d32c780bf5a3b79ce7cad5 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 10:40:04 -0400 Subject: [PATCH 06/31] Reword the Individuals and Organizations taglines Individuals: "Making your voice count..." -> "Make your voice count..." Organizations: rewritten, dropping the "civically-engage" typo. --- public/locales/en/forindividuals.json | 2 +- public/locales/en/fororgs.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index 440bafc89..048c3cd8e 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -2,7 +2,7 @@ "title": "MAPLE for Individuals", "callToAction": { "header": "Why use MAPLE", - "title": "Making your voice count in the laws that shape our future.", + "title": "Make your voice count in the laws that shape our future.", "bodytext": "With a quick search on MAPLE, you can see what bills the state legislature is considering about the topics that matter to you. You can find out what policy changes are proposed, see what other constituents and organizations are saying about them, and-most important-you can submit written testimony directly to our representatives in the State House to make your perspective part of the conversation." }, "benefits": { diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 8bd8f790a..bef27ea7b 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -2,7 +2,7 @@ "title": "MAPLE for Organizations", "callToAction": { "header": "Why use MAPLE", - "title": "Reach a wider civically-engage audience and get your message out.", + "title": "Get your message out and rally your supporters in civic discourse.", "bodytext": "It takes only a couple minutes to sign up for your organization's account on MAPLE, giving you immediate access to publish and share your testimony, designate your priority bills, connect with your members and followers, and research legislation." }, "benefits": { From b6b9fd3ab965fc96a5aee92ae5704f5c3fe4e9ed Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 11:14:51 -0400 Subject: [PATCH 07/31] Rework the process page: the rail follows the reader The rail no longer opens or closes chapters. Every chapter is expanded by default and collapses independently from its own header. As the reader scrolls, the rail highlights the chapter they are looking at, cycling from the first. Clicking a stage scrolls to that chapter and opens it only if it was collapsed; it never closes anything. The expand-all / collapse-all link is gone. This also removes a whole class of bugs. Because the rail no longer changes any layout, a highlight can no longer reflow the page, so nothing can cascade, oscillate, or drag a chapter out from under the reader. The scroll handler is now pure observation. The handover has a 180px look-ahead measured from the rail's live bottom edge, so the next stage lights up while roughly a card's worth of the previous chapter is still in view. Also in this change: - Why Use MAPLE: benefits become disclosures, all collapsed by default, each with its supporting copy. `signUp` is a call to action, not a benefit, so it no longer renders as one. `legislativeResearch` has no bodytext -- it is stored around a link -- and is reassembled with an internal link to /bills. - Print styles: every disclosure prints expanded, on all three pages. The tips panel needs `display: block !important` because Chrome's user-agent sheet declares `[hidden] { display: none !important }`. - Copy: "Find your legislator's contact info", and the "multiples sides" typo. --- .../learn/Process/LegislativeProcess.tsx | 305 ++++++++---------- components/learn/Testimony/AboutTestimony.tsx | 24 ++ components/learn/WhyUseMaple/WhyUseMaple.tsx | 240 ++++++++++++-- public/locales/en/forindividuals.json | 2 +- public/locales/en/fororgs.json | 6 +- public/locales/en/learn.json | 4 +- 6 files changed, 364 insertions(+), 217 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 8182dcbd2..723a05a65 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -179,31 +179,6 @@ const HeaderAnchor = styled.div` scroll-margin-top: calc(var(--maple-navbar-height) + 0.75rem); ` -const ExpandAllButton = styled.button` - display: inline; - padding: 0; - margin: 0.5rem 0 0; - background: none; - border: 0; - color: var(--bs-blue); - font-weight: 400; - text-decoration: underline; - cursor: pointer; - - &:hover { - text-decoration: none; - } - - &:focus-visible { - outline: 2px solid var(--bs-blue); - outline-offset: 2px; - border-radius: var(--maple-radius-sm); - } -` - -/* A `position: sticky` element can only stick within its containing block. - Scoping the rail to the chapters means it releases and scrolls away once the - last chapter passes, rather than hovering over Additional Resources. */ const StickyScope = styled.div` position: relative; ` @@ -318,6 +293,15 @@ const RowHeader = styled.h3` width: 2.25rem; } } + + @media print { + .chevron { + display: none; + } + button { + cursor: auto; + } + } ` const Panel = styled.div` @@ -338,7 +322,6 @@ const Panel = styled.div` visibility: visible; transition: grid-template-rows 0.55s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.32s ease 0.12s, visibility 0s; - transition-delay: var(--learn-panel-delay, 0ms); } .clip { @@ -415,6 +398,19 @@ const Panel = styled.div` transition: none; } } + + /* Every chapter prints expanded, whatever is open on screen. */ + @media print { + display: block; + grid-template-rows: none; + opacity: 1; + visibility: visible; + transition: none; + + .clip { + overflow: visible; + } + } ` const StatLabel = ({ stat }: { stat: Stat }) => { @@ -447,8 +443,7 @@ const AccordionRow = ({ color, isOpen, isActive, - onToggle, - stagger = 0 + onToggle }: { index: number chapter: Chapter @@ -456,8 +451,6 @@ const AccordionRow = ({ isOpen: boolean isActive: boolean onToggle: () => void - /** Index used to stagger the open animation when several panels open at once. */ - stagger?: number }) => (
    @@ -550,28 +540,28 @@ export const LegislativeProcess = () => { const chapters = t("process.chapters", { returnObjects: true }) as Chapter[] const stages = t("process.stages", { returnObjects: true }) as string[] - const [activeIdx, setActiveIdx] = useState(0) - const [openIdx, setOpenIdx] = useState(0) - // "Expand all" is a temporary override. Any interaction with a rail node or a - // chapter header drops back to the normal one-at-a-time accordion. - const [expandAll, setExpandAll] = useState(false) + // Every chapter starts expanded. Each one collapses independently from its own + // header; opening or closing one never touches the others. + const [openChapters, setOpenChapters] = useState>( + () => new Set(chapters.map((_, i) => i)) + ) + const isOpen = (i: number) => openChapters.has(i) - const isOpen = (i: number) => expandAll || openIdx === i + // The rail is read-only feedback: it follows the chapter the reader is looking + // at. It no longer opens or closes anything, so it cannot reflow the page. + const [activeIdx, setActiveIdx] = useState(0) const railRef = useRef(null) const trackRef = useRef(null) const scopeRef = useRef(null) - // Set when a rail node is clicked, so that expanding a row from its own - // header does not also yank the page around. + const headerRef = useRef(null) + + // Set when a rail node is clicked, so we scroll to that chapter. const pendingScrollRef = useRef(null) - // Which chapter was open before this change, so we know whose panel is about - // to collapse and by how much it will shift the page. - const previousOpenRef = useRef(0) - // Bumped on every click so clicking the already-open chapter still scrolls. const [scrollNonce, setScrollNonce] = useState(0) - // Set when "expand all" is pressed, so the subhead is brought to the top. - const pendingHeaderScrollRef = useRef(false) - const headerRef = useRef(null) + // While a programmatic scroll is running, the reader is not driving, so the + // rail should not chase the intermediate positions. + const suppressUntilRef = useRef(0) // The rail must sit directly beneath whatever remains of the site navbar. // @@ -589,8 +579,10 @@ export const LegislativeProcess = () => { const publishNavOffset = () => { const nav = document.querySelector(".main-navbar") const bottom = nav ? nav.getBoundingClientRect().bottom : 0 - const offset = Math.max(0, bottom) - scope.style.setProperty("--maple-navbar-height", `${offset}px`) + scope.style.setProperty( + "--maple-navbar-height", + `${Math.max(0, bottom)}px` + ) } const schedule = () => { if (frame) return @@ -632,20 +624,58 @@ export const LegislativeProcess = () => { return () => observer.disconnect() }, []) - // No manual scroll compensation here: the browser's native scroll anchoring - // already keeps the reader's position stable when a panel above them opens or - // closes. Compensating on top of it double-corrects, and near the bottom of - // the document (where the scroll position also clamps as the page shrinks) - // the two together walk the accordion backwards. + // Scroll drives the rail's highlight, and nothing else. The active chapter is + // the last one whose top has passed under the rail -- i.e. the one the reader + // is currently reading. Because this changes no layout, it cannot oscillate. + useEffect(() => { + let frame = 0 + const evaluate = () => { + frame = 0 + if (performance.now() < suppressUntilRef.current) return + + const rail = railRef.current + if (!rail) return + const railBottom = rail.getBoundingClientRect().bottom + // Once the rail has scrolled away there is nothing to track against. + if (railBottom <= 0) return + + // Look-ahead: a stage lights up while its chapter is still approaching the + // rail, so the next step is highlighted with a little of the previous one + // still in view. Measured down from the rail's live bottom edge. + const LOOK_AHEAD = 180 + + const rows = Array.from( + document.querySelectorAll('[id^="learn-process-stage-"]') + ) + let next = 0 + rows.forEach((row, i) => { + if (row.getBoundingClientRect().top <= railBottom + LOOK_AHEAD) next = i + }) + setActiveIdx(prev => (prev === next ? prev : next)) + } + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(evaluate) + } + + evaluate() + window.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + } + }, []) + // Scroll to a chapter after a rail click. Nothing above it collapses any more, + // so the only thing that can move it is its own panel opening -- which grows + // downward and leaves the header where it is. useEffect(() => { const target = pendingScrollRef.current if (target === null) return pendingScrollRef.current = null - const previous = previousOpenRef.current - previousOpenRef.current = target - const row = document.getElementById(rowId(target)) const header = document.getElementById(headerId(target)) const rail = railRef.current @@ -655,82 +685,40 @@ export const LegislativeProcess = () => { const behavior: ScrollBehavior = reduceMotion ? "auto" : "smooth" const GAP = 16 - // A chapter above this one collapsing pulls this row upward by that panel's - // height. Measure it now, before it animates away, and fold it into a single - // scroll -- otherwise the row slides under the rail and has to be yanked - // back, which is the jump the reader sees. - let shiftUp = 0 - if (previous !== null && previous < target && !expandAll) { - const closing = document.getElementById(panelId(previous)) - if (closing) shiftUp = closing.getBoundingClientRect().height - } + // Don't let the rail chase the smooth scroll on its way past other chapters. + suppressUntilRef.current = + performance.now() + (reduceMotion ? 50 : PANEL_TRANSITION_MS + 350) + setActiveIdx(target) - // The row's height once its own panel has finished opening. The inner - // content is laid out even while the panel is collapsed, so it can be - // measured up front. + // The row's height once its panel has finished opening. The inner content is + // laid out even while the panel is collapsed, so it can be measured up front. const inner = row.querySelector(".inner") - const openPanel = document.getElementById(panelId(target)) - const currentPanelHeight = openPanel - ? openPanel.getBoundingClientRect().height - : 0 + const panel = document.getElementById(panelId(target)) const finalRowHeight = row.getBoundingClientRect().height - - currentPanelHeight + + (panel ? panel.getBoundingClientRect().height : 0) + (inner ? inner.scrollHeight : 0) - const railBottom = rail.getBoundingClientRect().bottom - const desiredTop = railBottom + GAP - const finalTop = header.getBoundingClientRect().top - shiftUp - const finalBottom = finalTop + finalRowHeight + const desiredTop = rail.getBoundingClientRect().bottom + GAP + const top = header.getBoundingClientRect().top + const bottom = top + finalRowHeight const viewportBottom = window.innerHeight - GAP // Positive delta scrolls the page down (content moves up). let delta = 0 - if (finalTop < desiredTop) { - // Would sit under the rail: bring its top border to just below the rail. - delta = finalTop - desiredTop - } else if (finalBottom > viewportBottom) { + if (top < desiredTop) { + // Sitting under the rail: bring its top border to just below it. + delta = top - desiredTop + } else if (bottom > viewportBottom) { // Below the fold: rise only as far as needed, never past the rail. - delta = Math.min(finalBottom - viewportBottom, finalTop - desiredTop) + delta = Math.min(bottom - viewportBottom, top - desiredTop) } if (Math.abs(delta) > 1) window.scrollBy({ top: delta, behavior }) - - // Safety net: if a measurement was off, settle it once the transition ends. - // When the prediction is right this is a no-op, so there is no second jump. - const timer = window.setTimeout( - () => { - const overlap = - rail.getBoundingClientRect().bottom + - GAP - - header.getBoundingClientRect().top - if (overlap > 4) window.scrollBy({ top: -overlap, behavior }) - }, - reduceMotion ? 0 : PANEL_TRANSITION_MS + 80 - ) - return () => clearTimeout(timer) - }, [openIdx, scrollNonce, expandAll]) - - // Declared after the scroll effect so that effect still sees the previous - // value. Keeps the reference correct when a chapter is opened or closed - // without a scroll (a header toggle, expand-all). - useEffect(() => { - previousOpenRef.current = openIdx - }, [openIdx]) - - useEffect(() => { - if (!expandAll || !pendingHeaderScrollRef.current) return - pendingHeaderScrollRef.current = false - const el = headerRef.current - if (!el) return - el.scrollIntoView({ - behavior: prefersReducedMotion() ? "auto" : "smooth", - block: "start" - }) - }, [expandAll]) + }, [scrollNonce]) // On narrow screens the rail overflows horizontally. Keep the active node - // visible, whichever way the stage was selected. + // visible as the highlight moves. useEffect(() => { const track = trackRef.current if (!track || track.scrollWidth <= track.clientWidth) return @@ -738,49 +726,34 @@ export const LegislativeProcess = () => { const node = track.children[activeIdx] as HTMLElement | undefined if (!node) return - const reduceMotion = prefersReducedMotion() track.scrollTo({ left: node.offsetLeft - (track.clientWidth - node.offsetWidth) / 2, - behavior: reduceMotion ? "auto" : "smooth" + behavior: prefersReducedMotion() ? "auto" : "smooth" }) }, [activeIdx]) + // A rail click scrolls to the chapter, and opens it only if it was collapsed. + // It never closes anything. const selectStage = useCallback((i: number) => { + setOpenChapters(prev => { + if (prev.has(i)) return prev + const next = new Set(prev) + next.add(i) + return next + }) pendingScrollRef.current = i setScrollNonce(n => n + 1) - setExpandAll(false) - setActiveIdx(i) - setOpenIdx(i) }, []) - const toggleChapter = useCallback( - (i: number) => { - if (expandAll) { - // Leaving expand-all: keep the chapter the reader just reached for. - setExpandAll(false) - setOpenIdx(i) - setActiveIdx(i) - return - } - setOpenIdx(prev => { - const next = prev === i ? null : i - if (next !== null) setActiveIdx(next) - return next - }) - }, - [expandAll] - ) - - const toggleExpandAll = useCallback(() => { - setExpandAll(prev => { - if (prev) return false - pendingHeaderScrollRef.current = true - return true + // A chapter header toggles just that chapter, and does not scroll. + const toggleChapter = useCallback((i: number) => { + setOpenChapters(prev => { + const next = new Set(prev) + if (next.has(i)) next.delete(i) + else next.add(i) + return next }) - // Collapsing closes every chapter, including the one that was open before - // expand-all was pressed. - setOpenIdx(prev => (expandAll ? null : prev)) - }, [expandAll]) + }, []) return ( // "medium" rather than "narrow": the h1 wraps at 48rem. @@ -792,21 +765,7 @@ export const LegislativeProcess = () => { title={t("process.title")} titleSize="2.375rem" subheadMaxWidth="none" - subhead={ - <> - {t("process.subhead")}{" "} - panelId(i)).join(" ")} - > - {expandAll - ? t("process.collapseAll") - : t("process.expandAll")} - - - } + subhead={t("process.subhead")} /> @@ -817,10 +776,8 @@ export const LegislativeProcess = () => { {stages.map((title, i) => { const color = STAGE_COLORS[i] - // With every chapter expanded, no stage is "past" and all - // of them read as current. - const done = !expandAll && i < activeIdx - const current = expandAll || i === activeIdx + const done = i < activeIdx + const current = i === activeIdx const Icon = STAGE_ICONS[i] return ( @@ -830,7 +787,6 @@ export const LegislativeProcess = () => { onClick={() => selectStage(i)} aria-current={current ? "step" : undefined} aria-controls={panelId(i)} - aria-expanded={isOpen(i)} > { ? TINT[color] : "var(--bs-gray-50, #f9fafb)", border: `2px solid ${ - expandAll || i <= activeIdx + i <= activeIdx ? color : "var(--bs-gray-200, #e5e7eb)" }`, @@ -883,7 +839,6 @@ export const LegislativeProcess = () => { isOpen={isOpen(i)} isActive={activeIdx === i} onToggle={() => toggleChapter(i)} - stagger={expandAll ? i : 0} /> ))}
    diff --git a/components/learn/Testimony/AboutTestimony.tsx b/components/learn/Testimony/AboutTestimony.tsx index 3dd7e7c37..c3227ec37 100644 --- a/components/learn/Testimony/AboutTestimony.tsx +++ b/components/learn/Testimony/AboutTestimony.tsx @@ -195,6 +195,23 @@ const TipsPanel = styled.div` line-height: 1.6; margin: 0; } + + /* The content is collapsed with the hidden attribute. Chrome's user-agent + sheet declares [hidden] { display: none !important }, so nothing short of + !important can reveal it for printing. */ + @media print { + .content[hidden] { + display: block !important; + } + + button { + cursor: auto; + } + + svg { + display: none; + } + } ` const Panel = styled.div` @@ -242,6 +259,13 @@ const Reveal = styled.div<{ $hidden: boolean }>` transform: none; transition: none; } + + /* The How section is revealed on scroll; on paper it must always be there. */ + @media print { + opacity: 1; + transform: none; + transition: none; + } ` const ReadyToStart = styled.p` diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx index fe4017e99..60e377c18 100644 --- a/components/learn/WhyUseMaple/WhyUseMaple.tsx +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -1,9 +1,17 @@ -import { useTranslation } from "next-i18next" +import { TFunction, useTranslation } from "next-i18next" +import { useEffect, useState } from "react" import styled from "styled-components" +import { Internal } from "../../links" import LearnBreadcrumb from "../LearnBreadcrumb" import LearnHeader from "../LearnHeader" import LearnLayout from "../LearnLayout" -import { CheckIcon, MegaphoneIcon, ScaleIcon, UsersIcon } from "../icons" +import { + CheckIcon, + ChevronDownIcon, + MegaphoneIcon, + ScaleIcon, + UsersIcon +} from "../icons" import { GREEN, NAVY, ORANGE, alpha } from "../palette" /** @@ -56,8 +64,7 @@ export const PERSONAS: Persona[] = [ "seeEveryone", "curateHistory", "legislativeResearch", - "changeNorms", - "signUp" + "changeNorms" ], body: ["bodytext"] }, @@ -134,22 +141,22 @@ const Detail = styled.div` ` const Hero = styled.div<{ $color: string }>` - padding: 1.5rem 1.75rem; + padding: 2rem; background-color: ${p => alpha(p.$color, 5)}; .tagline { font-family: var(--maple-font-heading); font-weight: 900; - font-size: 1.25rem; - line-height: 1.3; + font-size: 1.5rem; + line-height: 1.25; color: ${p => p.$color}; - margin-bottom: 0.5rem; + margin-bottom: 0.75rem; } .why { color: var(--maple-text-body); - font-size: 0.9375rem; - line-height: 1.6; + font-size: 1.0625rem; + line-height: 1.65; margin-bottom: 0; } @@ -174,22 +181,41 @@ const Offer = styled.div` } ul { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1.125rem 1.5rem; + /* One full-width column: each benefit is its own disclosure. */ + display: flex; + flex-direction: column; + gap: 0.5rem; margin: 0; padding: 0; list-style: none; - - @media (max-width: 48rem) { - grid-template-columns: 1fr; - } } li { + border: 1px solid var(--maple-surface-border); + border-radius: var(--maple-radius-lg); + overflow: hidden; + } + + .benefit-toggle { + width: 100%; display: flex; align-items: flex-start; gap: 0.75rem; + padding: 1rem 1.25rem; + background: none; + border: 0; + text-align: left; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--maple-surface-muted); + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } } .tick { @@ -203,10 +229,85 @@ const Offer = styled.div` justify-content: center; } - .feature { - color: var(--maple-text-body); - font-size: 1rem; - line-height: 1.55; + .feature-title { + flex: 1; + min-width: 0; + font-weight: 700; + color: var(--maple-text-strong); + line-height: 1.4; + } + + .chevron { + flex-shrink: 0; + color: var(--maple-text-muted); + transition: transform 0.25s ease; + } + + .benefit-toggle[aria-expanded="true"] .chevron { + transform: rotate(180deg); + } + + /* Height transition without measuring the content; delayed visibility keeps + a closed body out of the accessibility tree and the focus order. */ + .benefit-body { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; + transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.2s ease, visibility 0s linear 0.35s; + } + + .benefit-body[data-open="true"] { + grid-template-rows: 1fr; + opacity: 1; + visibility: visible; + transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.25s ease 0.08s, visibility 0s; + } + + .benefit-body > div { + min-height: 0; + overflow: hidden; + } + + .benefit-body p { + color: var(--maple-text-muted); + font-size: 0.9375rem; + line-height: 1.6; + margin: 0; + padding: 0 1.25rem 1.25rem 3.25rem; + } + + @media (prefers-reduced-motion: reduce) { + .benefit-body, + .benefit-body[data-open="true"], + .chevron { + transition: none; + } + } + + /* Print the whole page: every benefit expanded, no toggles. */ + @media print { + .benefit-body { + display: block; + grid-template-rows: none; + opacity: 1; + visibility: visible; + transition: none; + } + + .benefit-body > div { + overflow: visible; + } + + .chevron { + display: none; + } + + .benefit-toggle { + cursor: auto; + } } @media (max-width: 36rem) { @@ -214,6 +315,36 @@ const Offer = styled.div` } ` +/** + * Supporting copy for a benefit. Most read a single `bodytext`; `fororgs`'s + * legislativeResearch is stored as bodytext1 + a link + bodytext2, so it is + * reassembled with an internal link to the bill search. + */ +const BenefitBody = ({ + ns, + benefitKey, + t +}: { + ns: string + benefitKey: string + t: TFunction +}) => { + const base = `${ns}:benefits.${benefitKey}` + const body = t(`${base}.bodytext`, { defaultValue: "" }) + if (body) return <>{body} + + const before = t(`${base}.bodytext1`, { defaultValue: "" }) + const linkText = t(`${base}.linkText`, { defaultValue: "" }) + const after = t(`${base}.bodytext2`, { defaultValue: "" }) + if (!before && !linkText && !after) return null + + return ( + <> + {before} {linkText} {after} + + ) +} + export const WhyUseMaple = ({ slug, onSelect @@ -230,6 +361,13 @@ export const WhyUseMaple = ({ const active = PERSONAS.find(p => p.slug === slug) ?? PERSONAS[0] + // One benefit open at a time. Keyed by persona so switching tabs starts fresh + // rather than leaving a stale key from the previous persona's list. + const [openBenefit, setOpenBenefit] = useState(null) + useEffect(() => { + setOpenBenefit(null) + }, [active]) + return ( @@ -284,20 +422,52 @@ export const WhyUseMaple = ({

    {t(`${active.ns}:benefits.header`)}

      - {active.benefits.map(key => ( -
    • - - - {t(`${active.ns}:benefits.${key}.title`)} - -
    • - ))} + {active.benefits.map(key => { + const open = openBenefit === key + return ( +
    • + +
      +
      +

      + +

      +
      +
      +
    • + ) + })}
    diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index 048c3cd8e..8e4be66c4 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -12,7 +12,7 @@ "bodytext": "Before the Massachusetts state legislature can pass a bill, they convene three committees to hear public testimony on the policy changes that bill proposes. Don't miss out on the chance to make your perspective heard; submit your testimony to use your voice to shape public policy for our Commonwealth." }, "multipleSides": { - "title": "Hear from multiples sides and perspectives", + "title": "Hear from multiple sides and perspectives", "bodytext": "As a non-partisan platform, all stakeholders can use MAPLE to share their perspective on issues and bills. See what others are saying to get more informed about the most important public policy issues of the day." }, "trustedOrgs": { diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index bef27ea7b..56a900826 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -1,7 +1,7 @@ { "title": "MAPLE for Organizations", "callToAction": { - "header": "Why use MAPLE", + "header": "Why use MAPLE", "title": "Get your message out and rally your supporters in civic discourse.", "bodytext": "It takes only a couple minutes to sign up for your organization's account on MAPLE, giving you immediate access to publish and share your testimony, designate your priority bills, connect with your members and followers, and research legislation." }, @@ -9,7 +9,7 @@ "header": "What we offer", "reach": { "title": "Help your priorities & positions reach a wider audience", - "bodytext": " Your organization's testimony will be highlighted to MAPLE visitors when they look up bills, with easy tools for sharing on social media. Testimony published on MAPLE will be seen by legislators, residents, members of the media, and other stakeholders. The more organizations share their testimony on the platform, the more people will see it. You can even designate bills as your legislative priorities to signal your focus for each session." + "bodytext": "Your organization's testimony will be highlighted to MAPLE visitors when they look up bills, with easy tools for sharing on social media. Testimony published on MAPLE will be seen by legislators, residents, members of the media, and other stakeholders. The more organizations share their testimony on the platform, the more people will see it. You can even designate bills as your legislative priorities to signal your focus for each session." }, "connect": { "title": "Connect with your members, and grow your reach", @@ -34,7 +34,7 @@ "legislativeResearch": { "title": "Speed up your legislative research", "bodytext1": "MAPLE is fast. (Really fast.). Try", - "linkText": "searching for a bill", + "linkText": "searching for a bill", "bodytext2": "to see just how much MAPLE can speed up your legislative research process; no sign in required!" }, "changeNorms": { diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index 41dc36340..08a482f1a 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -96,7 +96,7 @@ { "title": "Call your legislator", "body": "You can contact your legislators any time by looking up their contact information on the MA Legislature website. Your voice will probably carry the most weight with the House and Senate representatives of your own district, but you are free to contact Committee Chairs or any other member of the legislature with your opinions. You could request a meeting in person.", - "linkLabel": "Find your legislator's number:", + "linkLabel": "Find your legislator's contact info:", "linkText": "malegislature.gov/Search/FindMyLegislator", "href": "https://malegislature.gov/Search/FindMyLegislator" } @@ -140,8 +140,6 @@ "title": "Understanding the Legislative Process", "subhead": "How most bills become laws in Massachusetts.", "railLabel": "Legislative process stages", - "expandAll": "Click here to expand all sections", - "collapseAll": "Click here to collapse all sections", "stages": [ "Bill Introduction", "Committee Referral", From 8415911d3069b39b2b18412d0943ac0032e87df6 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 11:16:38 -0400 Subject: [PATCH 08/31] Reduce the rail's look-ahead to 120px The next stage now lights up with a little less of the previous chapter still showing. --- components/learn/Process/LegislativeProcess.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 723a05a65..9a3d14a5d 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -642,7 +642,7 @@ export const LegislativeProcess = () => { // Look-ahead: a stage lights up while its chapter is still approaching the // rail, so the next step is highlighted with a little of the previous one // still in view. Measured down from the rail's live bottom edge. - const LOOK_AHEAD = 180 + const LOOK_AHEAD = 120 const rows = Array.from( document.querySelectorAll('[id^="learn-process-stage-"]') From 02551628f26a989872541d4dc62d2c87b352b7fd Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 11:26:32 -0400 Subject: [PATCH 09/31] Make the rail behave differently going up than going down Scrolling down, the next stage lights while some of the current chapter is still in view. Scrolling up, a stage only lights once the top of its own card is back in view below the rail -- a stage never lights while its card's top is off-screen above the reader. The highlight now steps from the current stage rather than being recomputed each frame. Recomputing made the upward handover fire about 500px late. The asymmetry between the two lines is also the hysteresis: changing the highlight back costs a whole card's height. An earlier attempt moved a single line with the direction of travel, which was much worse -- it jumped by the width of the gap whenever the reader reversed, so the highlight flickered on the smallest nudge (23 changes across 24 alternating scrolls; now 0). --- .../learn/Process/LegislativeProcess.tsx | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 9a3d14a5d..72730e5df 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -624,11 +624,27 @@ export const LegislativeProcess = () => { return () => observer.disconnect() }, []) - // Scroll drives the rail's highlight, and nothing else. The active chapter is - // the last one whose top has passed under the rail -- i.e. the one the reader - // is currently reading. Because this changes no layout, it cannot oscillate. + // Scroll drives the rail's highlight, and nothing else. Because this changes + // no layout, it cannot reflow the page. + // + // The highlight steps from the current stage rather than being recomputed from + // scratch, using two lines measured from the rail's live bottom edge: + // + // advancing the next chapter's top crossing a line a little below the rail + // lights the next stage, while some of the current chapter is + // still in view + // retreating the previous stage only lights again once the top of its own + // card has come back into view below the rail -- a stage never + // lights while its card's top is off-screen above + // + // The asymmetry is deliberate, and it is also what gives the switch its + // hysteresis: going back up costs a whole card's height. A line that moved + // with the direction of travel was much worse -- it jumped by the width of the + // gap whenever the reader changed direction, so the highlight flickered on the + // smallest nudge. useEffect(() => { let frame = 0 + const evaluate = () => { frame = 0 if (performance.now() < suppressUntilRef.current) return @@ -639,20 +655,30 @@ export const LegislativeProcess = () => { // Once the rail has scrolled away there is nothing to track against. if (railBottom <= 0) return - // Look-ahead: a stage lights up while its chapter is still approaching the - // rail, so the next step is highlighted with a little of the previous one - // still in view. Measured down from the rail's live bottom edge. - const LOOK_AHEAD = 120 + const ADVANCE_AT = railBottom + 120 - const rows = Array.from( + const tops = Array.from( document.querySelectorAll('[id^="learn-process-stage-"]') - ) - let next = 0 - rows.forEach((row, i) => { - if (row.getBoundingClientRect().top <= railBottom + LOOK_AHEAD) next = i + ).map(row => row.getBoundingClientRect().top) + if (!tops.length) return + + setActiveIdx(prev => { + let i = Math.min(prev, tops.length - 1) + + // Scrolling down: step forward while the next chapter's top has crossed + // the advance line. Loops so a fast scroll catches up in one frame. + while (i + 1 < tops.length && tops[i + 1] <= ADVANCE_AT) i++ + + // Scrolling up: only when we did not just advance, step back while the + // previous chapter's own top is back in view below the rail. + if (i === prev) { + while (i > 0 && tops[i - 1] >= railBottom) i-- + } + + return i === prev ? prev : i }) - setActiveIdx(prev => (prev === next ? prev : next)) } + const schedule = () => { if (frame) return frame = requestAnimationFrame(evaluate) From 59ab45a9e992efcf6fac1ee7620baf0cd78b695c Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 11:56:07 -0400 Subject: [PATCH 10/31] Trigger Vercel preview From 9a3b1a5ca3ee85653ae367b405fee599024defb1 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 13:36:48 -0400 Subject: [PATCH 11/31] Land every rail-click chapter cleanly below the sticky nav Clicking Public Hearing through Governor's Action used to leave a ~150px gap between the chapter's header and the rail, while Bill Introduction and Committee Referral landed snug. The cause is the site's own layout: the navbar is sticky-top inside a fixed-height (vh-100) container, so it stays pinned only while that container is in view and then scrolls away with it. Reserving the navbar's height is right for chapters that land while it is still pinned and wrong for the deeper ones, where it is already gone. The scroll target now models that release deterministically. The navbar's bottom follows min(navHeight, containerBottom - scrollY); solving for the scroll that lands a chapter GAP below the rail gives two regimes -- reserve the navbar while it is pinned, don't once it has left -- split at the measured release point rather than a hardcoded chapter index, so it holds on any viewport. Below the 768px breakpoint the navbar swaps to a non-sticky MobileNav that is gone at once, so nothing reserves it there. That is detected by reading the rendered navbar's position rather than duplicating the breakpoint, so it stays in sync with Navbar. Verified at 1280px and 390px: all six chapters land at the same gap, none behind the header, no bounce. --- .../learn/Process/LegislativeProcess.tsx | 74 +++++++++++-------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 72730e5df..023b4ee01 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -703,44 +703,60 @@ export const LegislativeProcess = () => { pendingScrollRef.current = null const row = document.getElementById(rowId(target)) - const header = document.getElementById(headerId(target)) const rail = railRef.current - if (!row || !header || !rail) return + if (!row || !rail) return const reduceMotion = prefersReducedMotion() - const behavior: ScrollBehavior = reduceMotion ? "auto" : "smooth" - const GAP = 16 // Don't let the rail chase the smooth scroll on its way past other chapters. suppressUntilRef.current = performance.now() + (reduceMotion ? 50 : PANEL_TRANSITION_MS + 350) setActiveIdx(target) - // The row's height once its panel has finished opening. The inner content is - // laid out even while the panel is collapsed, so it can be measured up front. - const inner = row.querySelector(".inner") - const panel = document.getElementById(panelId(target)) - const finalRowHeight = - row.getBoundingClientRect().height - - (panel ? panel.getBoundingClientRect().height : 0) + - (inner ? inner.scrollHeight : 0) - - const desiredTop = rail.getBoundingClientRect().bottom + GAP - const top = header.getBoundingClientRect().top - const bottom = top + finalRowHeight - const viewportBottom = window.innerHeight - GAP - - // Positive delta scrolls the page down (content moves up). - let delta = 0 - if (top < desiredTop) { - // Sitting under the rail: bring its top border to just below it. - delta = top - desiredTop - } else if (bottom > viewportBottom) { - // Below the fold: rise only as far as needed, never past the rail. - delta = Math.min(bottom - viewportBottom, top - desiredTop) - } - - if (Math.abs(delta) > 1) window.scrollBy({ top: delta, behavior }) + // Scroll so the chapter's top sits GAP below the rail, in document + // coordinates. The rail sticks beneath the site navbar, which is + // `sticky-top` inside a fixed-height (vh-100) page container -- so the navbar + // only stays pinned while that container is in view, then scrolls away with + // it. Its bottom edge therefore follows navbarBottom(y) = min(navHeight, + // V - y), where V is the container's document height. Solving for the scroll + // that lands the chapter GAP below the rail gives two regimes: + // + // navbar still pinned at the destination -> reserve its height + // navbar already gone (deeper chapters) -> do not + // + // We measure the release point rather than hardcode which chapters are past + // it, so it stays correct on any viewport. + // + // Below the 768px breakpoint the navbar swaps to the non-sticky MobileNav, + // which is gone the instant you scroll -- so nothing reserves it there. We + // detect that by reading the rendered navbar's `position` instead of + // duplicating the breakpoint, so this can't drift out of sync with Navbar. + // + // Sticky live rects are unreliable (they report the unstuck position near + // the top and the stuck one after scrolling), so we work from intrinsic + // heights and the container's document bottom, all constant at any scroll. + const GAP = 16 + const nav = document.querySelector(".main-navbar") + const navHeight = nav ? nav.offsetHeight : 0 + const navSticky = !!nav && getComputedStyle(nav).position === "sticky" + const containerBottom = nav?.parentElement + ? nav.parentElement.getBoundingClientRect().bottom + window.scrollY + : window.innerHeight + const railHeight = rail.offsetHeight + const rowDocTop = row.getBoundingClientRect().top + window.scrollY + + // Desktop: the sticky navbar releases at the container's bottom edge. + // Mobile: the navbar never sticks, so it is effectively released at once. + const releasePoint = navSticky ? containerBottom - navHeight : 0 + const yWithNavbar = rowDocTop - navHeight - railHeight - GAP + const targetY = Math.max( + 0, + yWithNavbar <= releasePoint ? yWithNavbar : rowDocTop - railHeight - GAP + ) + window.scrollTo({ + top: targetY, + behavior: reduceMotion ? "auto" : "smooth" + }) }, [scrollNonce]) // On narrow screens the rail overflows horizontally. Keep the active node From 1144bd7af7582f0e659f8727efebc4cc7d8ca75b Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 13:47:51 -0400 Subject: [PATCH 12/31] Fix two mobile navbar/rail issues Rail: on narrow screens the journey rail scrolls horizontally, and setting overflow-x to auto forces overflow-y to auto as well, which clipped the top of the active node -- it is scaled up 12% and has a shadow. The scroll strip now has vertical padding (with a compensating negative margin so the card height is unchanged) and the connecting line shifts down to match, so the active circle is no longer shaved off. Mobile nav dropdown (app-wide): inside the hamburger the dropdown menu is white, but its items carried .navLink-primary -- white text meant for the top-level links on the dark navbar -- so they were invisible, white on white. A scoped override gives items inside #basic-navbar-nav .dropdown-menu the same dark, readable text the desktop dropdown uses. Desktop is unaffected. --- .../learn/Process/LegislativeProcess.tsx | 8 ++++++++ styles/globals.css | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 023b4ee01..ad814fdeb 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -117,7 +117,15 @@ const RailTrack = styled.ol` &::-webkit-scrollbar { display: none; } + /* Setting overflow-x to auto forces overflow-y to auto as well, which clips + the active node -- it is scaled up 12% and has a shadow. Pad the strip + vertically so it has room; the negative margin keeps the card's height + unchanged. The connecting line shifts down by the same amount to stay + centered on the nodes. */ + padding-block: 0.5rem; + margin-block: -0.5rem; &::before { + top: calc(1.6875rem + 0.5rem); right: auto; width: max(100%, 30rem); } diff --git a/styles/globals.css b/styles/globals.css index e19cd1372..f8c3cab75 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -269,6 +269,24 @@ width: fit-content; } +/* Inside the mobile hamburger, the dropdown menu is white, but its items carry + .navLink-primary (white text, for the top-level links on the dark navbar) — + white on white, invisible. Restore the dark, readable text the desktop + dropdown uses. */ +#basic-navbar-nav .dropdown-menu .dropdown-item, +#basic-navbar-nav .dropdown-menu .navLink-primary { + color: var(--maple-text-body) !important; + font-weight: 400; +} + +#basic-navbar-nav .dropdown-menu .dropdown-item:hover, +#basic-navbar-nav .dropdown-menu .dropdown-item:focus, +#basic-navbar-nav .dropdown-menu .navLink-primary:hover, +#basic-navbar-nav .dropdown-menu .navLink-primary:focus { + color: var(--bs-blue) !important; + background-color: var(--maple-surface-muted); +} + .offcanvas { background-color: var(--bs-body-bg) !important; } From 9743156de43cc5bc28da727a706a0cd354747766 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 13:58:04 -0400 Subject: [PATCH 13/31] Keep the persona tabs on one row with short labels The persona tabs used to stack vertically on narrow screens because their labels ("MAPLE for Individuals", etc.) were too long to sit side by side. The "MAPLE for" prefix is redundant on the tabs, so they now read just "Individuals", "Organizations", and "Legislators" (new tabLabel keys, kept translatable). At that length all three stay on a single row at every width down to 320px, icon above the short label; the full title remains as each tab's accessible name. --- components/learn/WhyUseMaple/WhyUseMaple.tsx | 39 +++++++++++++++----- public/locales/en/forindividuals.json | 1 + public/locales/en/forlegislators.json | 3 +- public/locales/en/fororgs.json | 1 + 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx index 60e377c18..0b5feb61d 100644 --- a/components/learn/WhyUseMaple/WhyUseMaple.tsx +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -85,17 +85,18 @@ export const PERSONAS: Persona[] = [ ] const PersonaGrid = styled.div` - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + display: flex; gap: 0.75rem; margin-bottom: 1.25rem; - - @media (max-width: 48rem) { - grid-template-columns: 1fr; - } ` const PersonaCard = styled.button<{ $color: string; $active: boolean }>` + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; border-radius: var(--maple-radius-lg); padding: 0.875rem 1rem; text-align: left; @@ -104,17 +105,17 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` p.$active ? p.$color : "var(--maple-surface-base)"}; box-shadow: var(--maple-shadow-sm); cursor: pointer; - transition: background-color 0.2s ease, border-color 0.2s ease; + transition: background-color 0.2s ease, border-color 0.2s ease, flex 0.2s ease; .glyph { - display: block; - margin-bottom: 0.25rem; + flex-shrink: 0; color: ${p => (p.$active ? "#fff" : "var(--bs-gray-400, #9ca3af)")}; } .label { font-weight: 700; font-size: 0.8125rem; + line-height: 1.2; margin: 0; color: ${p => (p.$active ? "#fff" : "#374151")}; } @@ -128,6 +129,18 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` outline-offset: 3px; } + /* All three keep their short label on one row. Center and tighten a little + on narrow screens so "Organizations" still fits. */ + @media (max-width: 40rem) { + align-items: center; + text-align: center; + padding: 0.75rem 0.5rem; + + .label { + font-size: 0.75rem; + } + } + @media (prefers-reduced-motion: reduce) { transition: none; } @@ -381,6 +394,8 @@ export const WhyUseMaple = ({ {PERSONAS.map(persona => { const isActive = persona.slug === active.slug + const label = t(`${persona.ns}:title`) + const tabLabel = t(`${persona.ns}:tabLabel`) return ( onSelect(persona.slug)} $color={persona.color} @@ -399,7 +416,9 @@ export const WhyUseMaple = ({ className="glyph" sx={{ fontSize: "1.5rem" }} /> -

    {t(`${persona.ns}:title`)}

    +
    ) })} diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index 8e4be66c4..fe77c8e57 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -1,5 +1,6 @@ { "title": "MAPLE for Individuals", + "tabLabel": "Individuals", "callToAction": { "header": "Why use MAPLE", "title": "Make your voice count in the laws that shape our future.", diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index 38c8a5685..9fb9db5c9 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -1,7 +1,8 @@ { "title": "MAPLE for Legislators", + "tabLabel": "Legislators", "callToAction": { - "header": "Why use MAPLE", + "header": "Why use MAPLE", "title": "Gain free, nonpartisan insight into how constituents are thinking about any proposed measure.", "bodytextOne": "Even before you create your own account, you can search our lightning-fast platform for all testimony submitted by MAPLE users about any bill. Our advanced features let you filter to bills sponsored by your office, bills concerning your city, or bills that you choose to follow.", "bodytextTwo": "Why trust MAPLE? We are a nonpartisan, nonprofit, volunteer-developed civic technology initiative focused on increasing engagement between the legislature and its constituents. We offer our product as an open source public good; we are committed not to charging our users and we will never sell the data from our platform." diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 56a900826..4160cf62b 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -1,5 +1,6 @@ { "title": "MAPLE for Organizations", + "tabLabel": "Organizations", "callToAction": { "header": "Why use MAPLE", "title": "Get your message out and rally your supporters in civic discourse.", From cdb4bfb299cc011e258332382bea765fd3502bc4 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 14:00:49 -0400 Subject: [PATCH 14/31] Show the active-state look on persona-tab hover, instantly Hovering an inactive tab fills it with its persona color and turns the icon and label white -- the same look as the selected tab, no border. The active tab already looks this way. The hover is instant: the card's transition is removed, so the fill and text change together with no fade. --- components/learn/WhyUseMaple/WhyUseMaple.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx index 0b5feb61d..1919eb9ac 100644 --- a/components/learn/WhyUseMaple/WhyUseMaple.tsx +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -105,7 +105,6 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` p.$active ? p.$color : "var(--maple-surface-base)"}; box-shadow: var(--maple-shadow-sm); cursor: pointer; - transition: background-color 0.2s ease, border-color 0.2s ease, flex 0.2s ease; .glyph { flex-shrink: 0; @@ -120,8 +119,15 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` color: ${p => (p.$active ? "#fff" : "#374151")}; } + /* Hover fills the tab with its color and turns the text and icon white -- the + same look as the selected state. The active tab already looks this way. */ &:hover { - border-color: ${p => p.$color}; + background-color: ${p => p.$color}; + + .glyph, + .label { + color: #fff; + } } &:focus-visible { @@ -140,10 +146,6 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` font-size: 0.75rem; } } - - @media (prefers-reduced-motion: reduce) { - transition: none; - } ` const Detail = styled.div` From 96880c4127cbd91881050e8a957b8dfafbf72b7b Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 14:21:44 -0400 Subject: [PATCH 15/31] Make the active mobile nav dropdown item bold white The active item in the mobile hamburger dropdown keeps Bootstrap's filled background (the brand red), but the earlier fix that darkened dropdown text for readability caught the active item too -- dark on red. Restore white, bold text for the active item so it reads against its red background. --- styles/globals.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/styles/globals.css b/styles/globals.css index f8c3cab75..c74e7206b 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -287,6 +287,14 @@ background-color: var(--maple-surface-muted); } +/* The active item keeps Bootstrap's filled background (the brand red), so its + text must be white and bold, not the dark body color the rule above sets. */ +#basic-navbar-nav .dropdown-menu .dropdown-item.active, +#basic-navbar-nav .dropdown-menu .navLink-primary.active { + color: var(--maple-text-inverse) !important; + font-weight: 700; +} + .offcanvas { background-color: var(--bs-body-bg) !important; } From cd92a1fa1c46738878ced42d370f39c6d1e2767e Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 14:30:33 -0400 Subject: [PATCH 16/31] Stop the rail's initial-scroll jank on mobile On mobile the rail's sticky top was bound to --maple-navbar-height, which a scroll handler rewrote every frame while the non-sticky MobileNav scrolled away (82px -> 0 over the first ~96px). Each rewrite forced the browser to recompute the sticky rail, which was the janky initial scroll before the rail "locked". Desktop was unaffected because the sticky navbar keeps the value constant. The navbar has already scrolled away by the time the rail reaches the top on mobile, so the rail just sticks at top: 0 via CSS below the 768px breakpoint, and the per-frame tracking now runs only when the navbar is actually sticky (desktop). No churn, smooth initial scroll; desktop behavior is unchanged. --- .../learn/Process/LegislativeProcess.tsx | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index ad814fdeb..24f64f1de 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -46,14 +46,21 @@ const headerId = (i: number) => `learn-process-header-${i}` const RailWrapper = styled.div` position: sticky; - /* Measured at runtime — see the effect in LegislativeProcess. Falls back to a - sensible constant before the first layout pass. */ + /* Desktop: measured at runtime to sit below the sticky navbar (see the + nav-offset effect). Falls back to a sensible constant before first paint. */ top: var(--maple-navbar-height, 6rem); z-index: 2; margin-inline: -2rem; padding: 0.5rem 2rem 1rem; background-color: var(--maple-surface-learn); + /* Below the 768px nav breakpoint the navbar is non-sticky and has scrolled + away before the rail reaches the top, so the rail simply sticks at 0 -- no + per-frame tracking, which is what made the initial scroll janky here. */ + @media (max-width: 48rem) { + top: 0; + } + @media (max-width: 36rem) { margin-inline: -1rem; padding: 0.5rem 1rem 0.75rem; @@ -571,22 +578,26 @@ export const LegislativeProcess = () => { // rail should not chase the intermediate positions. const suppressUntilRef = useRef(0) - // The rail must sit directly beneath whatever remains of the site navbar. + // The rail must sit directly beneath whatever remains of the sticky site + // navbar, which stays pinned for most of the page on desktop, so the offset + // tracks the navbar's current bottom edge on scroll. // - // A one-off height measurement is not enough: the navbar's `sticky-top` does - // not always keep it pinned (it scrolls away on the mobile navbar, and its - // stickiness can be defeated by an ancestor), which would leave the rail - // floating with a strip of page content visible above it. So the offset - // tracks the navbar's *current* bottom edge on scroll, clamped at zero, and - // the rail hugs the very top once the navbar has scrolled out of view. + // This runs on desktop only. Below the breakpoint the navbar is the + // non-sticky MobileNav: it scrolls away immediately, and the rail does not + // reach the top until well after it is gone, so the rail simply sticks at + // top: 0 (set in CSS). Tracking there would rewrite the sticky offset every + // frame during the navbar's exit -- recomputing the sticky rail each frame -- + // which is exactly the initial-scroll jank on mobile. So we skip it entirely + // when the navbar is not sticky. useLayoutEffect(() => { const scope = scopeRef.current - if (!scope) return + const nav = document.querySelector(".main-navbar") + if (!scope || !nav) return + if (getComputedStyle(nav).position !== "sticky") return let frame = 0 const publishNavOffset = () => { - const nav = document.querySelector(".main-navbar") - const bottom = nav ? nav.getBoundingClientRect().bottom : 0 + const bottom = nav.getBoundingClientRect().bottom scope.style.setProperty( "--maple-navbar-height", `${Math.max(0, bottom)}px` @@ -604,9 +615,8 @@ export const LegislativeProcess = () => { window.addEventListener("scroll", schedule, { passive: true }) window.addEventListener("resize", schedule) - const nav = document.querySelector(".main-navbar") const observer = new ResizeObserver(schedule) - if (nav) observer.observe(nav) + observer.observe(nav) return () => { if (frame) cancelAnimationFrame(frame) From d885c2ec3e0a9b295cebe0575841f72fe45ad3c0 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 14:38:41 -0400 Subject: [PATCH 17/31] Loosen line-height and padding on the AI Research Tools page Bump the body copy to 140% line-height and add more padding inside the section cards and between them, so the MCP setup instructions read less cramped. Scoped to this page: DescrContainer/SectionContainer/SectionTitle are shared with other pages, so the line-height is overridden through a local styled wrapper and the padding via this page's own utility classes, with a note to revisit whether the looser spacing should become a global default. --- components/learn/AiTools/AiTools.tsx | 55 +++++++++++++++++----------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/components/learn/AiTools/AiTools.tsx b/components/learn/AiTools/AiTools.tsx index 65cd4e907..244b858a5 100644 --- a/components/learn/AiTools/AiTools.tsx +++ b/components/learn/AiTools/AiTools.tsx @@ -60,6 +60,19 @@ const ExampleLabel = styled.div` margin-bottom: 0.25rem; ` +// NOTE: This 140% line-height (and the extra card/section padding bumped into +// the JSX classNames below) is deliberately scoped to the AI Research Tools +// page. DescrContainer/SectionContainer/SectionTitle are shared with other +// pages (about/MapleAI, OurTeam), so we override locally rather than editing the +// shared components. TODO: revisit whether this spacing reads better everywhere +// and should be promoted to a global change on those shared components. +const AiToolsBody = styled(Container)` + ${DescrContainer}, + ${StepText} { + line-height: 1.4; + } +` + export const AiTools = () => { const { t } = useTranslation("aiTools") const { t: tLearn } = useTranslation("learn") @@ -75,16 +88,16 @@ export const AiTools = () => { subhead={t("description")} titleSize="2.25rem" /> - + {/* What is it */} - + - {t("section1.title")} - + {t("section1.title")} + {t("section1.desc1")} - + {t("section1.desc2Pre")}{" "} { {/* What you can do */} - + - {t("section2.title")} - + {t("section2.title")} + {t("section2.intro")} - +
    diff --git a/next.config.js b/next.config.js index 35aa9c4f5..154e17d70 100644 --- a/next.config.js +++ b/next.config.js @@ -19,9 +19,9 @@ module.exports = { async redirects() { return [ redirectFirebaseAuthHandlers(), - // The four Learn testimony tabs were consolidated into /learn/testimony. - // These slugs were linked from the footer and from external sites, so - // they redirect permanently rather than 404. Each of the three topic tabs + // Three of the four old Learn testimony tabs were consolidated into + // /learn/testimony. These slugs were linked from the footer and from + // external sites, so they redirect permanently rather than 404. Each tab // became a section of the new page and keeps its old slug as an anchor id // (see ANCHORS in components/learn/Testimony/AboutTestimony.tsx), so the // reader lands on the content that replaced the page they asked for @@ -30,7 +30,6 @@ module.exports = { ...[ "testimony-basics", "role-of-testimony", - "writing-effective-testimony", "communicating-with-legislators" ].map(slug => ({ source: `/learn/${slug}`, diff --git a/pages/learn/writing-effective-testimony.tsx b/pages/learn/writing-effective-testimony.tsx new file mode 100644 index 000000000..e4440a0e0 --- /dev/null +++ b/pages/learn/writing-effective-testimony.tsx @@ -0,0 +1,15 @@ +import { createPage } from "../../components/page" +import WritingTips from "components/learn/Testimony/WritingTips" +import { createGetStaticTranslationProps } from "components/translations" + +export default createPage({ + titleI18nKey: "titles.writing_testimony", + Page: () => +}) + +export const getStaticProps = createGetStaticTranslationProps([ + "auth", + "common", + "footer", + "learn" +]) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ec1f29d0f..663c00874 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -123,7 +123,7 @@ "hearings": "Hearings", "home": "Home", "learnAboutTestimony": "Learn About Testimony", - "legislativeProcess": "About the MA Legislative Process", + "legislativeProcess": "The MA Legislative Process", "legislator": "Legislator", "logo": "Massachusetts Platform for Legislative Engagement home", "main": "Main navigation", @@ -135,8 +135,9 @@ "privacyAndConduct": "Privacy Policy & Code of Conduct", "signOut": "Sign Out", "supportMaple": "Support MAPLE", - "aboutTestimony": "About Testimony", + "aboutTestimony": "How Testimony Works", "aiTools": "AI Research Tools", + "writingTestimony": "Writing Effective Testimony", "testimony": "Testimony", "viewProfile": "View Profile", "whyUseMaple": "Why Use MAPLE", @@ -201,7 +202,8 @@ "why_use_maple": "Why Use Maple?", "ai_tools": "AI Research Tools", "learn_hub": "Learn", - "about_testimony": "How Testimony Works" + "about_testimony": "How Testimony Works", + "writing_testimony": "How to Write Effective Testimony" }, "user_updates": "User Updates", "view": "View", diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index fe77c8e57..b355a8902 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -1,6 +1,7 @@ { "title": "MAPLE for Individuals", "tabLabel": "Individuals", + "personaLabel": "Individuals", "callToAction": { "header": "Why use MAPLE", "title": "Make your voice count in the laws that shape our future.", diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index 9fb9db5c9..963709709 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -1,6 +1,7 @@ { "title": "MAPLE for Legislators", "tabLabel": "Legislators", + "personaLabel": "Legislators", "callToAction": { "header": "Why use MAPLE", "title": "Gain free, nonpartisan insight into how constituents are thinking about any proposed measure.", diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 4160cf62b..6f40c2120 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -1,6 +1,7 @@ { "title": "MAPLE for Organizations", "tabLabel": "Organizations", + "personaLabel": "Organizations", "callToAction": { "header": "Why use MAPLE", "title": "Get your message out and rally your supporters in civic discourse.", diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index 08a482f1a..276d5fc9d 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -40,46 +40,46 @@ { "badge": "who", "headline": "Anyone can submit testimony", - "body": "Legislators value testimony most when it comes from their own constituents. Testimony from MA residents is typically directed to both the committee responsible for the bill and the legislators representing your district." + "body": "Legislators value testimony most when it comes from their own constituents. Testimony from Massachusetts residents is typically directed to both the committee responsible for the bill and the legislators representing you in your district." }, { "badge": "what", - "headline": "Make it distinctive and relevant", - "body": "Write your own text and explain why you are personally interested in an issue. Form letters still count, but an individual, personalized letter will always have greater impact." + "headline": "It should be distinctive and relevant", + "body": "Writing your own text and explaining why you are interested in an issue gives legislators valuable, personalized insight into their constituents. Form letters still count, but a personalized, individual letter will always have greater impact." }, { "badge": "when", - "headline": "Before the committee hearing date", - "body": "Committees generally accept testimony up until the public hearing date for a bill. You can use bill pages on this site to identify relevant dates. Submit before the hearing for the greatest impact." + "headline": "Submit before the hearing date", + "body": "Committees generally accept testimony up until the public hearing date for a bill. You can use Maple's bill pages on this site or this link to identify relevant dates. Submit before the hearing for the greatest impact." }, { "badge": "where", - "headline": "By email to committee chairs", - "body": "MAPLE makes it easy to find a bill you want to testify on and generate an email — which you fully control — to send to the relevant committee personnel." + "headline": "Use MAPLE to submit testimony", + "body": "Browse our bill and ballot question pages to find the measures that matter to you. Select one and follow the prompts to compose your testimony. When you are happy with your testimony, MAPLE sends it to the relevant committee personnel." } ], "why": { - "headline": "To shape the laws that govern your life", + "headline": "Legislators need your input in order to represent you", "body": "If you don't share your perspective, it may not be taken into account when policymakers make decisions about the laws that govern all our lives. By speaking up, you can make the laws of Massachusetts work better for all of us. MAPLE is designed to make it more accessible.", "mattersHeading": "Why it Matters:", "matters": [ { - "title": "Your voice shapes the legislative agenda", - "body": "It can guide what topics and bills the legislature considers, and how they decide to act and vote on each one." + "title": "Your voice is instrumental to the legislative process", + "body": "Testimony can guide the agenda of the legislature, from what topics and bills they consider, to how they decide to act and vote on each measure." }, { - "title": "You give legislators real-world insight", - "body": "Testimony informs legislators of the benefits or negative consequences of proposed policies — context that doesn't always appear in the official record." + "title": "You give lawmakers real-world insight", + "body": "It is important for legislators to understand how communities may be positively or negatively impacted by proposed policies — context that doesn't always appear in the official record." }, { "title": "You can recommend specific changes", - "body": "You can suggest concrete improvements to legislation, whether you generally support or oppose a bill. Specificity makes your testimony more actionable." + "body": "You can suggest concrete improvements to legislation, whether you generally support or oppose a bill. Specificity makes your testimony more actionable to legislators." } ] }, "readyToStart": "Ready to get started?", "how": { - "headline": "To make your voice heard", + "headline": "Making your voice heard", "intro": "There are multiple ways to share your perspective and knowledge with your legislators.", "steps": [ { @@ -102,24 +102,24 @@ } ] }, - "tipsToggle": "Tips for writing effective testimony", - "tipsIntro": "Clearly outline what bill you are testifying about, whether you support or oppose it, why you are concerned about the issue, what policy you would like to see enacted, and what legislative district you live in.", + "tipsLink": "Need help? <0>Read our testimony writing tips", "tips": [ { "label": "Be Timely", - "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill." - }, + "body": "Written testimony should target bills being considered by the legislature and should be submitted before the committee hearing date in order to have the most impact." + }, { "label": "Be Original", "body": "Legislators receive a lot of form letters. These communications are important, but almost always an individual and personalized letter will have greater impact." - }, + }, { "label": "Be Informative", - "body": "Whether you're a longtime advocate or first-time testifier, your testimony is important. Explain why you are concerned about an issue and why you think one policy choice would be better than another." + "body": "Introduce yourself. Explain why you are concerned about an issue and why you think one policy choice would be better than another. Whether you're a longtime advocate or first-time testifier, your testimony is important." }, + { "label": "Be Direct", - "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language." + "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language to provide valuable input." }, { "label": "Be Respectful", @@ -127,13 +127,52 @@ } ] }, + "writingTips": { + "breadcrumb": "Writing Effective Testimony", + "title": "How to Write Effective Testimony", + "subhead": "You don't need legal training or a policy background. You need a clear position, a reason you care, and the district you live in — this page walks through the rest.", + "principles": { + "headline": "Five things to make testimony impactful" + }, + "include": { + "headline": "What every letter should include", + "intro": "Whatever else you write, make sure a reader can answer these five questions after one pass.", + "items": [ + { + "title": "Which bill you are writing about", + "body": "Name the bill by number and title — for example, \"H.1234, An Act relative to…\". Committees sort testimony by bill, so a letter without one is hard to file." + }, + { + "title": "Whether you support or oppose it", + "body": "Say so plainly, in the first line if you can. If your position is conditional, state the condition rather than leaving it implied." + }, + { + "title": "Who you are, and where you live", + "body": "Your name and your city or town. Legislators weigh testimony from their own constituents most heavily, so your district is doing real work here." + }, + { + "title": "Why you care about this", + "body": "This is the part no one else can write. A short, specific account of how the issue touches your life, your work, or your community carries further than a page of general argument." + }, + { + "title": "What you want done", + "body": "Pass it, reject it, or amend it — and if amended, how. Specific asks are the ones a staffer can act on." + } + ] + }, + "cta": { + "headline": "Ready to write?", + "body": "Find a bill you care about and MAPLE will walk you through composing your testimony and sending it to the right committee.", + "button": "Browse bills" + } + }, "aiTools": { "breadcrumb": "AI Research Tools" }, "whyUseMaple": { "breadcrumb": "Why Use MAPLE", "title": "Why Use MAPLE?", - "subhead": "Whether you're a citizen, an organization, or a legislator — MAPLE is for you." + "subhead": "Whether you are a citizen, an organization leader, or a legislator, MAPLE can support you." }, "process": { "breadcrumb": "Legislative Process", @@ -152,7 +191,7 @@ { "num": "01", "title": "A Bill is Introduced", - "lead": "Every law starts as an idea. Any MA resident can petition their legislator to file a bill.", + "lead": "Every law starts as an idea. Any Massachusetts resident can petition their legislator to file a bill.", "body": "Bills can be filed at any time during the two-year session, but most are filed before the session begins — the standard deadline falls in December of the preceding year. Bills filed after that deadline require special approval. They receive a unique identifier — H. for House-originated bills, S. for Senate. The same idea can be filed in both chambers simultaneously. Bills are publicly available on the MA Legislature website and through MAPLE.", "stat": { "value": "6,000+", diff --git a/public/locales/en/learnComponents.json b/public/locales/en/learnComponents.json index a1d91a292..c8158ce1f 100644 --- a/public/locales/en/learnComponents.json +++ b/public/locales/en/learnComponents.json @@ -4,6 +4,6 @@ "resources_intro": "We hope these pages will help you submit effective testimony. You may want to consult these other resources to build a more detailed understanding of the legislative process and how you can contribute.", "find_legislator": "The MA Legislature has an <0>online tool you can use to identify your legislators based on your home address.", "legislative_doc": "The MA Legislature publishes a <0>document on the legislative process.", - "legal_services": "Mass Legal Services published a 2007 guide to The <0>Legislative Process in Massachusetts." + "legal_services": "Mass Legal Services published a 2023 guide to The <0>Legislative Process in Massachusetts." } } From 086f96bffaf87e7b4410d06e1b202b83b23cee9a Mon Sep 17 00:00:00 2001 From: k-g-k Date: Tue, 14 Jul 2026 19:41:20 -0400 Subject: [PATCH 25/31] Guard the Learn icon lists, fix the mobile Learn dropdown, and edit copy Guard the three places where a locale-driven list indexes a fixed array: the About Testimony "why it matters" icons and card badges now skip a missing entry rather than rendering and crashing, and the Writing Effective Testimony principles cycle the stage colours so any number of tips stays coloured. Make the mobile Learn dropdown match the desktop one: pin its menu background to the desktop grey, and drop the custom hover so Bootstrap drives the hover and active states -- fixing a broken look where hovering the selected item left white text on a light background. Also tighten the "what every letter should include" copy. --- components/learn/Testimony/AboutTestimony.tsx | 7 +++-- components/learn/Testimony/WritingTips.tsx | 7 ++++- public/locales/en/learn.json | 10 +++---- styles/globals.css | 26 ++++++++----------- 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/components/learn/Testimony/AboutTestimony.tsx b/components/learn/Testimony/AboutTestimony.tsx index a03a38acd..13930f265 100644 --- a/components/learn/Testimony/AboutTestimony.tsx +++ b/components/learn/Testimony/AboutTestimony.tsx @@ -245,7 +245,7 @@ export const AboutTestimony = () => {
    - + {Badge && }

    {card.headline}

    {card.body}

    @@ -270,10 +270,13 @@ export const AboutTestimony = () => { {t("testimony.why.mattersHeading")}

    {matters.map((item, i) => { + // WHY_ICONS has one icon per matters entry; if copy adds an entry + // beyond the drawn set, that row just goes without an icon rather + // than rendering and crashing the page. const Icon = WHY_ICONS[i] return ( - + {Icon && }

    {item.title}

    {item.body}

    diff --git a/components/learn/Testimony/WritingTips.tsx b/components/learn/Testimony/WritingTips.tsx index 2d612f724..6351bf305 100644 --- a/components/learn/Testimony/WritingTips.tsx +++ b/components/learn/Testimony/WritingTips.tsx @@ -264,7 +264,12 @@ export const WritingTips = () => { {t("writingTips.principles.headline")} {tips.map((tip, i) => ( - + // Cycle the stage colours so any number of tips stays coloured, rather + // than running off the end of the array into undefined. + diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index 276d5fc9d..d329fe6bc 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -136,7 +136,7 @@ }, "include": { "headline": "What every letter should include", - "intro": "Whatever else you write, make sure a reader can answer these five questions after one pass.", + "intro": "Make sure a reader can answer these five questions after reading your testimony.", "items": [ { "title": "Which bill you are writing about", @@ -144,19 +144,19 @@ }, { "title": "Whether you support or oppose it", - "body": "Say so plainly, in the first line if you can. If your position is conditional, state the condition rather than leaving it implied." + "body": "Say so plainly in the first line. If your position is conditional, state the condition rather than leaving it implied." }, { "title": "Who you are, and where you live", - "body": "Your name and your city or town. Legislators weigh testimony from their own constituents most heavily, so your district is doing real work here." + "body": "Your name and your city or town. Legislators weigh testimony from their own constituents most heavily, so including your district is important here." }, { "title": "Why you care about this", - "body": "This is the part no one else can write. A short, specific account of how the issue touches your life, your work, or your community carries further than a page of general argument." + "body": "A short, specific account of how the issue affects your life, your work, or your community. This is what makes your testimony unique and impactful." }, { "title": "What you want done", - "body": "Pass it, reject it, or amend it — and if amended, how. Specific asks are the ones a staffer can act on." + "body": "Pass it, reject it, or amend it — and if amended, how? Being specific allows staffers to act on your feedback." } ] }, diff --git a/styles/globals.css b/styles/globals.css index c74e7206b..f3e94de15 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -262,33 +262,29 @@ } #basic-navbar-nav .dropdown-menu { - background-color: var(--maple-surface-base); + /* Set explicitly to the desktop dropdown's background (Bootstrap's $body-bg, + #eae7e7). Inside the dark hamburger collapse the inherited background comes + out dark, so it must be pinned here to match the desktop menu. */ + background-color: var(--maple-surface-page); box-sizing: border-box; max-width: calc(100vw - 3rem); min-width: 0; width: fit-content; } -/* Inside the mobile hamburger, the dropdown menu is white, but its items carry - .navLink-primary (white text, for the top-level links on the dark navbar) — - white on white, invisible. Restore the dark, readable text the desktop - dropdown uses. */ +/* The mobile hamburger's dropdown items carry .navLink-primary (white text, for + the top-level links on the dark navbar), which would be white-on-white inside + this light menu. Force the dark, readable text the desktop dropdown uses; the + menu background, hover, and active states are then left to Bootstrap so this + menu matches the desktop dropdown exactly. */ #basic-navbar-nav .dropdown-menu .dropdown-item, #basic-navbar-nav .dropdown-menu .navLink-primary { color: var(--maple-text-body) !important; font-weight: 400; } -#basic-navbar-nav .dropdown-menu .dropdown-item:hover, -#basic-navbar-nav .dropdown-menu .dropdown-item:focus, -#basic-navbar-nav .dropdown-menu .navLink-primary:hover, -#basic-navbar-nav .dropdown-menu .navLink-primary:focus { - color: var(--bs-blue) !important; - background-color: var(--maple-surface-muted); -} - -/* The active item keeps Bootstrap's filled background (the brand red), so its - text must be white and bold, not the dark body color the rule above sets. */ +/* The active item fills with the brand red (Bootstrap), including on hover, so + its text stays white and bold rather than the dark body colour above. */ #basic-navbar-nav .dropdown-menu .dropdown-item.active, #basic-navbar-nav .dropdown-menu .navLink-primary.active { color: var(--maple-text-inverse) !important; From 22585bdeed9b0379006ee31b80e595ab07a58b7e Mon Sep 17 00:00:00 2001 From: k-g-k Date: Tue, 14 Jul 2026 19:48:28 -0400 Subject: [PATCH 26/31] Fix the mobile Learn dropdown hover and active-row states Bootstrap's default dropdown hover background inherits from --bs-tertiary-bg, which resolves dark inside the navbar (the desktop dropdown escapes that context via a portal, so it stays light). Relying on it turned the mobile hover dark. Pin the non-active hover to the light highlight the desktop menu uses, and hold the active row's red fill and white text through hover and focus so hovering the selected item no longer falls back to the dark default. --- styles/globals.css | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/styles/globals.css b/styles/globals.css index f3e94de15..a891cb497 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -274,19 +274,33 @@ /* The mobile hamburger's dropdown items carry .navLink-primary (white text, for the top-level links on the dark navbar), which would be white-on-white inside - this light menu. Force the dark, readable text the desktop dropdown uses; the - menu background, hover, and active states are then left to Bootstrap so this - menu matches the desktop dropdown exactly. */ + this light menu. Force the dark, readable text the desktop dropdown uses. */ #basic-navbar-nav .dropdown-menu .dropdown-item, #basic-navbar-nav .dropdown-menu .navLink-primary { color: var(--maple-text-body) !important; font-weight: 400; } -/* The active item fills with the brand red (Bootstrap), including on hover, so - its text stays white and bold rather than the dark body colour above. */ +/* Hover/focus on a non-active item: a light highlight, matching the desktop + dropdown. Pinned because Bootstrap's default hover background inherits from + --bs-tertiary-bg, which resolves dark inside the navbar (the desktop dropdown + escapes that context via a portal, so it stays light). */ +#basic-navbar-nav .dropdown-menu .dropdown-item:hover:not(.active), +#basic-navbar-nav .dropdown-menu .dropdown-item:focus:not(.active), +#basic-navbar-nav .dropdown-menu .navLink-primary:hover:not(.active), +#basic-navbar-nav .dropdown-menu .navLink-primary:focus:not(.active) { + background-color: var(--maple-surface-muted) !important; +} + +/* The active row keeps the brand-red fill and white bold text through hover and + focus, so hovering the selected item does not fall back to the dark default. */ #basic-navbar-nav .dropdown-menu .dropdown-item.active, -#basic-navbar-nav .dropdown-menu .navLink-primary.active { +#basic-navbar-nav .dropdown-menu .dropdown-item.active:hover, +#basic-navbar-nav .dropdown-menu .dropdown-item.active:focus, +#basic-navbar-nav .dropdown-menu .navLink-primary.active, +#basic-navbar-nav .dropdown-menu .navLink-primary.active:hover, +#basic-navbar-nav .dropdown-menu .navLink-primary.active:focus { + background-color: var(--bs-dropdown-link-active-bg) !important; color: var(--maple-text-inverse) !important; font-weight: 700; } From c9453a228c7523e9a600294413a7e7a81d2ba68e Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 17 Jul 2026 10:46:20 -0400 Subject: [PATCH 27/31] Add a call to action to each Why Use MAPLE persona, and underline the resource links Give each persona tab a closing card matching the Writing Effective Testimony page: individuals and organizations get a sign-up card, and legislators get a Browse Testimony button paired with a secondary sign-up. Each card ends with a contact line linking to info@mapletestimony.org. The copy is drawn into a new `cta` block per persona namespace, leaving the legacy `challenge` keys alone. Give the CTA buttons MAPLE's button height in a navy pill, and apply the same treatment to the Writing Effective Testimony button so the two pages match. Underline the Additional Resources links on the legislative process page, matching the link style on the About Testimony page. --- components/AdditionalResources.tsx | 7 +- components/learn/Testimony/WritingTips.tsx | 3 +- components/learn/WhyUseMaple/WhyUseMaple.tsx | 132 ++++++++++++++++++- public/locales/en/forindividuals.json | 6 + public/locales/en/forlegislators.json | 7 + public/locales/en/fororgs.json | 6 + public/locales/en/learn.json | 7 +- 7 files changed, 154 insertions(+), 14 deletions(-) diff --git a/components/AdditionalResources.tsx b/components/AdditionalResources.tsx index cd7f1b9f9..144f99051 100644 --- a/components/AdditionalResources.tsx +++ b/components/AdditionalResources.tsx @@ -47,12 +47,7 @@ const Section = styled.section` a { color: var(--bs-blue); - font-weight: 600; - text-decoration: none; - - &:hover { - text-decoration: underline; - } + text-decoration: underline; } @media (prefers-reduced-motion: reduce) { diff --git a/components/learn/Testimony/WritingTips.tsx b/components/learn/Testimony/WritingTips.tsx index 6351bf305..a3798e121 100644 --- a/components/learn/Testimony/WritingTips.tsx +++ b/components/learn/Testimony/WritingTips.tsx @@ -205,9 +205,10 @@ const Cta = styled.div` margin: 0 auto 1.5rem; } + /* A navy pill, at MAPLE's button height (standard vertical padding). */ a { display: inline-block; - padding: 0.75rem 1.75rem; + padding: var(--maple-space-sm) var(--maple-space-xl); border-radius: var(--maple-radius-pill); background: ${NAVY}; color: var(--maple-text-inverse); diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx index 230cfd8b4..c03a531cc 100644 --- a/components/learn/WhyUseMaple/WhyUseMaple.tsx +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -1,6 +1,7 @@ import { TFunction, useTranslation } from "next-i18next" import { useEffect, useState } from "react" import styled from "styled-components" +import SignInWithButton from "../../auth/SignInWithButton" import { Internal } from "../../links" import LearnBreadcrumb from "../LearnBreadcrumb" import LearnHeader from "../LearnHeader" @@ -33,6 +34,12 @@ export type Persona = { benefits: string[] /** callToAction body keys, in render order. */ body: string[] + /** + * The closing call to action. "signup" opens the sign-up modal; "browse" + * links to a page and may pair it with a secondary sign-up button. Copy comes + * from the persona's `cta` namespace. + */ + cta: { kind: "signup" } | { kind: "browse"; href: string; signup?: boolean } } export const PERSONAS: Persona[] = [ @@ -48,7 +55,8 @@ export const PERSONAS: Persona[] = [ "anyLanguage", "stayInformed" ], - body: ["bodytext"] + body: ["bodytext"], + cta: { kind: "signup" } }, { slug: "for-orgs", @@ -65,7 +73,8 @@ export const PERSONAS: Persona[] = [ "legislativeResearch", "changeNorms" ], - body: ["bodytext"] + body: ["bodytext"], + cta: { kind: "signup" } }, { slug: "for-legislators", @@ -79,7 +88,8 @@ export const PERSONAS: Persona[] = [ "languageAccess", "advancedStatistics" ], - body: ["bodytextOne", "bodytextTwo"] + body: ["bodytextOne", "bodytextTwo"], + cta: { kind: "browse", href: "/testimony", signup: true } } ] @@ -379,6 +389,92 @@ const BenefitBody = ({ ) } +/* Closing call to action, matching the Writing Effective Testimony page's card: + a navy pill button (a browse link, or the sign-up modal trigger) and a contact + line below it. The button covers both the styled and the Bootstrap button + that SignInWithButton renders, whose full-width primary style is overridden. */ +const Cta = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 2.5rem 2rem; + margin-top: 1rem; + text-align: center; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 900; + color: ${NAVY}; + font-size: 1.5rem; + margin-bottom: 0.5rem; + } + + .lede { + color: var(--maple-text-body); + line-height: 1.65; + max-width: 34rem; + margin: 0 auto 1.5rem; + } + + /* One or two navy pills at MAPLE's button height, centred and wrapping on + narrow screens. */ + .cta-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + } + + .cta-action, + a.cta-action { + display: inline-block; + width: auto !important; + padding: var(--maple-space-sm) var(--maple-space-xl); + border-radius: var(--maple-radius-pill); + background: ${NAVY} !important; + border: 2px solid ${NAVY} !important; + color: var(--maple-text-inverse) !important; + font-weight: 700; + text-decoration: none; + cursor: pointer; + } + + .cta-action:hover, + a.cta-action:hover { + background: var(--maple-brand-primary-strong) !important; + border-color: var(--maple-brand-primary-strong) !important; + color: var(--maple-text-inverse) !important; + } + + /* Secondary action: the same pill, outlined rather than filled. */ + .cta-action-secondary, + a.cta-action-secondary { + background: transparent !important; + color: ${NAVY} !important; + } + + .cta-action-secondary:hover, + a.cta-action-secondary:hover { + background: ${NAVY} !important; + color: var(--maple-text-inverse) !important; + } + + .cta-action-wrap { + display: inline-block; + } + + .cta-contact { + margin: 1.5rem 0 0; + font-size: 0.9375rem; + } + + .cta-contact a { + color: ${NAVY}; + font-weight: 600; + text-decoration: underline; + } +` + export const WhyUseMaple = ({ slug, onSelect @@ -511,6 +607,36 @@ export const WhyUseMaple = ({
+ + +

{t(`${active.ns}:cta.headline`)}

+

{t(`${active.ns}:cta.body`)}

+ {active.cta.kind === "browse" ? ( +
+ + {t(`${active.ns}:cta.button`)} + + {active.cta.signup && ( + + )} +
+ ) : ( + + )} +

+ + {t(`${active.ns}:cta.contact`)} + +

+ ) } diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index b355a8902..ea232840f 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -37,5 +37,11 @@ "bodytextTwo": "Creating a MAPLE account is completely free and takes only a couple minutes.", "signUp": "Click here to sign up for your MAPLE account", "contact": "Any questions? Reach out to info@mapletestimony.org" + }, + "cta": { + "headline": "Get started with MAPLE today!", + "body": "Sign up for an account to follow legislation that matters to you, learn different perspectives, and make your voice heard.", + "button": "Sign up for your account", + "contact": "Any questions? Reach out to info@mapletestimony.org" } } diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index 963709709..4e17c7da6 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -36,5 +36,12 @@ "title": "Start using MAPLE today to put your finger on the pulse of your constituents!", "bodytext": "You can browse testimony submitted by constituents across the state on MAPLE, with or without creating your own account.", "contact": "Any questions? We're here to help! Reach out to info@mapletestimony.org" + }, + "cta": { + "headline": "Get started with MAPLE!", + "body": "You can browse testimony submitted by constituents across the state on MAPLE, with or without creating your own account.", + "button": "Browse Testimony", + "signup": "Sign up for an account", + "contact": "Any questions? Reach out to info@mapletestimony.org" } } diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 6f40c2120..2e8d19010 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -52,5 +52,11 @@ "p2": "In addition to all the benefits outlined above, these organizations will have their profiles featured prominently on the MAPLE website during the two-year legislative session. Any organization can contact MAPLE to be considered for membership in the CLO. Organizations will be prioritized to join the CLO based on the size of the constituency they represent.", "p3": "Maple is a nonpartisan project and does not take political positions; membership in the CLO does not indicate endorsement of any political position by MAPLE or its parent organization, members, or collaborators.", "callToAction": "Join the MAPLE coalition-reach out to info@mapletestimony.org" + }, + "cta": { + "headline": "Get started with MAPLE today!", + "body": "Sign up for your organization's account to publish and share your testimony, coordinate with your members and followers, and research legislation.", + "button": "Sign up for your organization", + "contact": "Any questions? Reach out to info@mapletestimony.org" } } diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index d329fe6bc..347243eb0 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -107,16 +107,15 @@ { "label": "Be Timely", "body": "Written testimony should target bills being considered by the legislature and should be submitted before the committee hearing date in order to have the most impact." - }, + }, { "label": "Be Original", "body": "Legislators receive a lot of form letters. These communications are important, but almost always an individual and personalized letter will have greater impact." - }, + }, { "label": "Be Informative", "body": "Introduce yourself. Explain why you are concerned about an issue and why you think one policy choice would be better than another. Whether you're a longtime advocate or first-time testifier, your testimony is important." }, - { "label": "Be Direct", "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language to provide valuable input." @@ -163,7 +162,7 @@ "cta": { "headline": "Ready to write?", "body": "Find a bill you care about and MAPLE will walk you through composing your testimony and sending it to the right committee.", - "button": "Browse bills" + "button": "Browse Bills" } }, "aiTools": { From 627e519809a0cb095385c0c9ebdd2ccc3846a881 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 17 Jul 2026 19:26:41 -0400 Subject: [PATCH 28/31] Restyle the About pages Bring the six About-dropdown pages into the shared Learn layout (breadcrumb + header): Mission & Goals, Our Team, Support MAPLE, How MAPLE Uses AI, In the News, and FAQ. - Restyle cards (offset-pill and white cards), spacing, fonts, and copy across the pages; add closing CTAs on Mission & Goals and FAQ - Reorder and relabel the About dropdown menu - Add an eyebrow prop to LearnBreadcrumb for the "About" trail --- components/AboutPagesCard/AboutPagesCard.tsx | 15 +- components/Faq/FaqCard.tsx | 158 ++++++- components/Faq/FaqPage.tsx | 108 +++-- components/Faq/FaqQandAButton.tsx | 166 +++++-- components/Faq/faqContent.json | 7 + components/GoalsAndMission/GoalsAndMission.js | 33 +- .../GoalsAndMissionCardContent.tsx | 163 +++++-- components/InTheNews/InTheNews.tsx | 53 +-- components/InTheNews/NewsCard.tsx | 49 +- components/Navbar.tsx | 4 +- components/OurTeam/AdvisoryBoard.tsx | 24 +- components/OurTeam/OurTeam.tsx | 143 +++--- components/OurTeam/Partners.tsx | 11 +- components/OurTeam/SteeringCommittee.tsx | 35 +- components/about/MapleAI/MapleAI.tsx | 423 ++++++++++-------- .../about/SupportMaple/SupportMaple.tsx | 173 +++++-- components/learn/LearnBreadcrumb.tsx | 22 +- pages/about/how-maple-uses-ai.tsx | 6 +- pages/about/support-maple.tsx | 8 +- public/locales/en/common.json | 2 +- public/locales/en/goalsandmission.json | 6 +- public/locales/en/inTheNews.json | 4 +- public/locales/en/mapleAI.json | 26 +- public/locales/en/our-team.json | 2 + public/locales/en/partners.json | 6 +- public/locales/en/supportmaple.json | 8 +- 26 files changed, 1070 insertions(+), 585 deletions(-) diff --git a/components/AboutPagesCard/AboutPagesCard.tsx b/components/AboutPagesCard/AboutPagesCard.tsx index 6fbf18b41..9eeede25e 100644 --- a/components/AboutPagesCard/AboutPagesCard.tsx +++ b/components/AboutPagesCard/AboutPagesCard.tsx @@ -4,6 +4,9 @@ import { FC, PropsWithChildren } from "react" const StyledHeader = styled(Card.Header)` transform: translate(0); + font-family: "Nunito", system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 700; + font-size: 1.25rem; &:first-child { border-radius: 10px 10px 0 0; @@ -20,6 +23,12 @@ const StyledHeader = styled(Card.Header)` } ` +const Body = styled(Card.Body)` + font-size: 1rem; + line-height: 1.6; + color: var(--maple-text-body); +` + const AboutPagesCard: FC> = ({ title, children @@ -27,12 +36,12 @@ const AboutPagesCard: FC> = ({ return ( {title} - {children} + {children} ) } diff --git a/components/Faq/FaqCard.tsx b/components/Faq/FaqCard.tsx index 9c9331099..b1929f137 100755 --- a/components/Faq/FaqCard.tsx +++ b/components/Faq/FaqCard.tsx @@ -1,4 +1,6 @@ +import { useId, useState } from "react" import styled from "styled-components" +import { ChevronDownIcon } from "../learn/icons" import { FaqQandAButton } from "./FaqQandAButton" type faqCardProps = { @@ -6,33 +8,143 @@ type faqCardProps = { qAndAs: { disabled?: boolean; question: string; answer: string }[] } -export const FaqCard = ({ heading, qAndAs }: faqCardProps) => { - const FaqCardContainer = styled.div` - margin: 0 2rem; - padding: 2rem 2.5rem; +const Card = styled.section` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + + > button { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 1.5rem 2rem; + background: none; + border: 0; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } + } + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--bs-blue); + font-size: 1.125rem; + margin: 0; + } - @media only screen and (max-width: 600px) { - margin: 0rem; - padding: 2rem 1.5rem; + /* Chevron size and animation copied from the Why Use MAPLE toggles: a single + chevron that rotates rather than swapping icons. No hover circle here -- + the cursor alone is enough to signal the whole header is clickable. */ + .chevron { + flex-shrink: 0; + color: var(--maple-text-muted); + transition: transform 0.25s ease; + } + + > button[aria-expanded="true"] .chevron { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; } - ` + } + + /* Expand/collapse animation copied from the Why Use MAPLE toggles: the row + animates between 0fr and 1fr rather than mounting instantly. The inner + element clips so the padding collapses with it. */ + .items { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; + transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.2s ease, visibility 0s linear 0.35s; + } + + .items[data-open="true"] { + grid-template-rows: 1fr; + opacity: 1; + visibility: visible; + transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.25s ease 0.08s, visibility 0s; + } + + /* No padding here: overflow: hidden only clips overflowing content, not this + element's own padding, so any padding placed directly on it would still + render at full size even while collapsed. Padding lives on .items-content + instead, which is what actually gets clipped away. */ + .items-inner { + min-height: 0; + overflow: hidden; + } + + /* Horizontal inset lives on each question row instead (see FaqQandAButton), + so the divider and the hover background share the same bounds as the row's + own padding rather than the container's. */ + .items-content { + padding: 0 0 1rem; + } + + @media (prefers-reduced-motion: reduce) { + .items { + transition: none; + } + } + + @media (max-width: 36rem) { + > button { + padding: 1.25rem 1.25rem; + } + + .items-content { + padding: 0 0 0.75rem; + } + } +` + +/** A collapsible category of questions. Categories start expanded. */ +export const FaqCard = ({ heading, qAndAs }: faqCardProps) => { + const [open, setOpen] = useState(true) + const panelId = useId() + const questions = qAndAs.filter(q => !q.disabled) return ( - -
{heading ?? ""}
-
- {qAndAs.map( - (key, index) => - typeof key.disabled === "undefined" && ( -
+ + +
+
+
+ {questions.map(q => ( -
- ) - )} - + key={q.question} + question={q.question} + answer={q.answer} + /> + ))} +
+
+
+ ) } diff --git a/components/Faq/FaqPage.tsx b/components/Faq/FaqPage.tsx index fc0a6e64b..42a2d01c8 100644 --- a/components/Faq/FaqPage.tsx +++ b/components/Faq/FaqPage.tsx @@ -1,43 +1,93 @@ +import { useTranslation } from "next-i18next" import styled from "styled-components" -import { Container, Stack } from "../bootstrap" +import LearnBreadcrumb from "../learn/LearnBreadcrumb" +import LearnHeader from "../learn/LearnHeader" +import LearnLayout from "../learn/LearnLayout" import { FaqCard } from "./FaqCard" import content from "./faqContent.json" -export const FaqPage = () => { - const faqData: FaqData = content - const faqKeys: string[] = Object.keys(faqData) +type Category = { + heading: string + qAndA: { disabled?: boolean; question: string; answer: string }[] +} + +const isCategory = (v: unknown): v is Category => + typeof v === "object" && v !== null && "qAndA" in v + +const Cards = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` + +// Closing "still have questions?" call-to-action, on the tinted page background: +// a centered card with a mailto pill button. +const Cta = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 2.5rem 2rem; + margin-top: 1.5rem; + text-align: center; - interface FaqData { - [key: string]: string | FaqDataCard | any + h2 { + font-family: var(--maple-font-heading); + font-weight: 900; + color: var(--bs-blue); + font-size: 1.5rem; + margin-bottom: 0.5rem; } - interface FaqDataCard { - heading: string - qAndA: { question: string; answer: string }[] + p { + color: var(--maple-text-body); + line-height: 1.65; + max-width: 34rem; + margin: 0 auto 1.5rem; } - const FaqH1 = styled.h1` - margin: 1.5rem 0rem 1.5rem 2rem; + a { + display: inline-block; + padding: var(--maple-space-sm) var(--maple-space-xl); + border-radius: var(--maple-radius-pill); + background: var(--maple-brand-primary); + color: var(--maple-text-inverse); + font-weight: 700; + text-decoration: none; + } + + a:hover { + background: var(--maple-brand-primary-strong); + color: var(--maple-text-inverse); + } +` - @media only screen and (max-width: 600px) { - margin: 1rem 0rem 1rem 0rem; - } - ` +export const FaqPage = () => { + const { t } = useTranslation("common") + const categories = Object.values(content).filter(isCategory) return ( - <> - - FAQ - - {faqKeys.map((key, index) => ( - - ))} - - - + + + + + {categories.map(category => ( + + ))} + + +

{content.ctaHeadline}

+

{content.ctaBody}

+ {content.ctaButton} +
+
) } diff --git a/components/Faq/FaqQandAButton.tsx b/components/Faq/FaqQandAButton.tsx index fa093770b..1b3fb6038 100644 --- a/components/Faq/FaqQandAButton.tsx +++ b/components/Faq/FaqQandAButton.tsx @@ -1,52 +1,156 @@ -import { useEffect, useState } from "react" -import { Image, Collapse } from "../bootstrap" -import { useTranslation } from "next-i18next" +import { useId, useState } from "react" +import AddIcon from "@mui/icons-material/Add" +import RemoveIcon from "@mui/icons-material/Remove" +import styled from "styled-components" +import { Internal } from "../links" type faqQandAProps = { question: string answer: string } -export const FaqQandAButton = ({ question, answer }: faqQandAProps) => { - const { t } = useTranslation("common") - const [open, setOpen] = useState(false) +/* Full width: the divider spans the whole card, and the hover background + (painted on the button) reaches the same edges. The inset from the card's + edges is the button's own horizontal padding, not a margin on this row. - useEffect(() => {}, [open]) + An inset-divider alternative was tried and set aside (kept below, commented + out, in case it's wanted again): a wrapped line that fills only its parent's + content box, so padding on the wrapper shortens the visible line without a + margin -- border-top can't be inset this way, since a border draws at the + outer edge of the padding box regardless of that box's own padding. */ +const Item = styled.div` + border-top: 1px solid var(--maple-surface-border); - let supportLink = null + /* .divider-wrap { + padding: 0 2rem; + } - { - question == "How can I support MAPLE?" - ? (supportLink = ( - - {" this page"} - - )) - : null + .divider-line { + height: 1px; + background-color: var(--maple-surface-border); } + @media (max-width: 36rem) { + .divider-wrap { + padding: 0 1.25rem; + } + } */ + + button { + width: 100%; + display: flex; + align-items: center; + gap: 0.875rem; + padding: 1.125rem 2rem; + background: none; + border: 0; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--maple-surface-muted); + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 2px; + } + } + + @media (max-width: 36rem) { + button { + padding: 1.125rem 1.25rem; + } + } + + .badge { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--maple-radius-pill); + background: var(--maple-surface-learn); + color: var(--maple-text-muted); + display: flex; + align-items: center; + justify-content: center; + } + + .question { + flex: 1; + min-width: 0; + font-weight: 600; + color: var(--maple-text-strong); + line-height: 1.45; + } + + .answer { + color: var(--maple-text-muted); + line-height: 1.6; + /* The answer sits outside the button, so its indent has to repeat the + button's own left padding (2rem) plus badge width (1.5rem) and gap + (0.875rem) to land under the question text; the right margin mirrors the + button's right padding. */ + margin: 0 2rem 1.125rem 4.375rem; + } + + .answer a { + color: var(--bs-blue); + text-decoration: underline; + } + + @media (max-width: 36rem) { + .answer { + margin: 0 1.25rem 1.125rem 3.625rem; + } + } +` + +/** + * A single question that expands to reveal its answer. The one about supporting + * MAPLE links to the support page, so that answer is composed rather than plain + * text. + */ +export const FaqQandAButton = ({ question, answer }: faqQandAProps) => { + const [open, setOpen] = useState(false) + const panelId = useId() + + const isSupport = question === "How can I support MAPLE?" + return ( - <> - setOpen(!open)} - aria-controls="example-collapse-text" + + {/* Inset-divider alternative -- pairs with the commented CSS above. */} + {/*
+
+
*/} +
+ {open && ( +

{answer} - {supportLink} + {isSupport && ( + <> + {" "} + {/* eslint-disable-next-line i18next/no-literal-string */} + this page. + + )}

- - + )} + ) } diff --git a/components/Faq/faqContent.json b/components/Faq/faqContent.json index 1010e8ca3..6b1bff45c 100755 --- a/components/Faq/faqContent.json +++ b/components/Faq/faqContent.json @@ -1,4 +1,11 @@ { + "title": "Frequently Asked Questions", + "breadcrumb": "FAQ", + "subhead": "Common questions about MAPLE, testimony, accounts, and privacy.", + "ctaHeadline": "Still have a question?", + "ctaBody": "If you don't see what you are looking for please reach out with any questions.", + "ctaEmail": "admin@mapletestimony.org", + "ctaButton": "Email Us", "card1": { "heading": "General", "qAndA": [ diff --git a/components/GoalsAndMission/GoalsAndMission.js b/components/GoalsAndMission/GoalsAndMission.js index dea7ca66a..3a397b31b 100644 --- a/components/GoalsAndMission/GoalsAndMission.js +++ b/components/GoalsAndMission/GoalsAndMission.js @@ -1,31 +1,26 @@ -import { Container, Row, Col } from "../bootstrap" import AboutPagesCard from "../AboutPagesCard/AboutPagesCard" import { OurGoalsCardContent, OurMissionCardContent } from "../GoalsAndMissionCardContent/GoalsAndMissionCardContent" import { useTranslation } from "next-i18next" +import LearnBreadcrumb from "../learn/LearnBreadcrumb" +import LearnHeader from "../learn/LearnHeader" +import LearnLayout from "../learn/LearnLayout" const GoalsAndMission = () => { - const { t } = useTranslation("goalsandmission") + const { t } = useTranslation(["goalsandmission", "common"]) return ( - - - -

- {t("header")} -

- - - - - - - -
-
+ + + + + + + + + + ) } diff --git a/components/GoalsAndMissionCardContent/GoalsAndMissionCardContent.tsx b/components/GoalsAndMissionCardContent/GoalsAndMissionCardContent.tsx index 70162e874..673d352ee 100644 --- a/components/GoalsAndMissionCardContent/GoalsAndMissionCardContent.tsx +++ b/components/GoalsAndMissionCardContent/GoalsAndMissionCardContent.tsx @@ -2,7 +2,7 @@ import { Row, Col } from "../bootstrap" import Image from "react-bootstrap/Image" import { SignInWithButton } from "../auth" import * as links from "../links" -import { Trans, useTranslation } from "next-i18next" +import { useTranslation } from "next-i18next" import { useAuth } from "components/auth" import styled from "styled-components" @@ -20,20 +20,96 @@ const StepsImage = styled(Image)` } ` +// Goal captions with tighter line spacing than the body default. (Utility +// classes on the element still handle alignment/size/weight/spacing.) +const GoalCaption = styled.figcaption` + line-height: 1.2; +` + +// Closing call-to-action at the bottom of the mission card: a centered headline, +// lede, a primary sign-in pill and a secondary outlined link. +const Cta = styled.div` + margin-top: 2.5rem; + padding-bottom: 1.5rem; + text-align: center; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 900; + color: var(--bs-blue); + font-size: 1.5rem; + margin-bottom: 0.5rem; + } + + .lede { + color: var(--maple-text-body); + line-height: 1.65; + max-width: 34rem; + margin: 0 auto 1.5rem; + } + + .cta-action, + a.cta-action, + button.cta-action { + display: inline-block; + width: auto !important; + padding: var(--maple-space-sm) var(--maple-space-xl); + border-radius: var(--maple-radius-pill); + background: var(--maple-brand-primary) !important; + border: 2px solid var(--maple-brand-primary) !important; + color: var(--maple-text-inverse) !important; + font-weight: 700; + text-decoration: none; + cursor: pointer; + } + + .cta-action:hover, + a.cta-action:hover, + button.cta-action:hover { + background: var(--maple-brand-primary-strong) !important; + border-color: var(--maple-brand-primary-strong) !important; + color: var(--maple-text-inverse) !important; + } + + .cta-action-wrap { + display: inline-block; + } + + .cta-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + } + + /* Secondary action: the same pill, outlined rather than filled. */ + .cta-action-secondary, + a.cta-action-secondary { + background: transparent !important; + color: var(--maple-brand-primary) !important; + } + + .cta-action-secondary:hover, + a.cta-action-secondary:hover { + background: var(--maple-brand-primary) !important; + color: var(--maple-text-inverse) !important; + } +` + const OurGoalsCardContent = () => { const { t } = useTranslation("goalsandmission") return ( <> -
+
{t("goals.overview")}
-
+ {t("goals.increase")} -
+
@@ -44,9 +120,9 @@ const OurGoalsCardContent = () => { src="/doc-with-arrows-from-people.svg" alt="" /> -
+ {t("goals.engage")} -
+
@@ -55,18 +131,18 @@ const OurGoalsCardContent = () => {
-
+ {t("goals.strengthen")} -
+
-
+ {t("goals.encourage")} -
+
@@ -80,12 +156,15 @@ const OurMissionCardContent = () => { return ( <> -
+
{t("mission.overview")}
- + -

+

{t("mission.connect")}

@@ -112,7 +191,8 @@ const OurMissionCardContent = () => { sm={{ span: 12, order: 2 }} md={{ span: 6, order: 1 }} lg={8} - className={`fs-6 fs-sm-5 tracking-tight text-start lh-sm pt-4 pb-3 pb-sm-4 p-md-0`} + className={`fs-6 text-start lh-sm p-3 p-md-4`} + style={{ letterSpacing: "0.005em", fontSize: "0.875rem" }} >

{t("mission.disclosure")} @@ -125,42 +205,39 @@ const OurMissionCardContent = () => {

- - - -

- {t("mission.publish1")} -

-

- {t("mission.publish2")} -

+

{t("mission.publish1")}

+

{t("mission.publish2")}

+
{!authenticated && ( - <> - - -

- {t("mission.submit_now")} -

- -
- - - - - - - + +

{t("mission.ctaHeadline")}

+

{t("mission.ctaBody")}

+
+ + + {t("mission.ctaSecondary")} + +
+
)} ) diff --git a/components/InTheNews/InTheNews.tsx b/components/InTheNews/InTheNews.tsx index 6fd4c04fe..0f1426015 100644 --- a/components/InTheNews/InTheNews.tsx +++ b/components/InTheNews/InTheNews.tsx @@ -1,5 +1,5 @@ import { useState, Fragment } from "react" -import { Col, Row, Container, Badge, Spinner } from "../bootstrap" +import { Col, Row, Badge, Spinner } from "../bootstrap" import Tab from "react-bootstrap/Tab" import Nav from "react-bootstrap/Nav" import Dropdown from "react-bootstrap/Dropdown" @@ -7,6 +7,9 @@ import { useMediaQuery } from "usehooks-ts" import { useTranslation } from "next-i18next" import { NewsCard } from "./NewsCard" import { NewsType, NewsItem, useNews } from "components/db/news" +import LearnBreadcrumb from "../learn/LearnBreadcrumb" +import LearnHeader from "../learn/LearnHeader" +import LearnLayout from "../learn/LearnLayout" type NewsFeedProps = { type: NewsType | null @@ -22,7 +25,7 @@ type TabCounts = { const NewsFeed = ({ type, newsItems }: NewsFeedProps) => { return ( -
+
{newsItems .filter(item => item.type === type || type === null) .map((item, index) => ( @@ -33,7 +36,7 @@ const NewsFeed = ({ type, newsItems }: NewsFeedProps) => { } export const InTheNews = () => { - const { t } = useTranslation("inTheNews") + const { t } = useTranslation(["inTheNews", "common"]) const isMobile = useMediaQuery("(max-width: 768px)") const { result: newsItems, loading } = useNews() @@ -46,11 +49,15 @@ export const InTheNews = () => { : null return ( - -

- {t("title")} -

-
+ + + +
{loading ? (
@@ -72,42 +79,30 @@ export const InTheNews = () => { -
- -
+
-
- -
+
-
- -
+
) : ( - - -
- -
- -
+ )}
- + ) } const TabGroup = ({ counts }: { counts: TabCounts }) => { const { t } = useTranslation("inTheNews") return ( - + {newsTypePlurals .filter(val => counts[val]) .map(val => ( @@ -115,13 +110,13 @@ const TabGroup = ({ counts }: { counts: TabCounts }) => {