From f3bf6d89130a4a1646ff5a09f8e94256229464a7 Mon Sep 17 00:00:00 2001 From: violet Date: Wed, 21 Oct 2026 15:40:23 -0400 Subject: [PATCH] Show what a conference still has to resolve, to the client's mockup The conference page gains the mockup's "What needs to be resolved?" section between Committee and Public Input, with an Unresolved Text tab: a one-sentence gist with a feedback link, the differences as disclosures with a Senate and a House card each (the first open, "Not in the bill" where a chamber's text is left out, noted terms as tooltips), the matching points beside them where there is room, a line naming the reader's own legislator, and the sources as numbered marks with a list at the foot of the card. A conference with nothing written has no section and no tab. The conference YAML gains a gist, chamber texts that may be left out, and term notes; the page loader now passes gist, differences and similarities through. Phone-Free Schools carries lorem ipsum until the real copy is written. The tab row's height is one constant shared by the pinned heading and every section's scroll margin, and the feedback form address moves to components/common. Closes hyphacoop/maple#70 Verified: jest components/conference 41/41 (in the next container); tsc and eslint; test:integration-ci tests/integration/conferences.test.ts 11/11. In Chrome beside the mockup at 19bd101, desktop and a 390px iframe: heading, labels, chamber cards, answer and match text, gist, term tooltip and the pinned heading match by computed style. Not verified: the your-legislator line in the browser (jest only, no signed-in profile); the copy itself, which is lorem ipsum. Co-Authored-By: Claude Opus 5.5 --- .../about/SupportMaple/SupportMaple.tsx | 3 +- components/common.tsx | 2 + .../ConferenceDetails.switching.test.tsx | 5 +- .../conference/ConferenceDetails.test.tsx | 139 +++- components/conference/ConferenceDetails.tsx | 33 +- .../conference/ConferenceDifferences.tsx | 714 ++++++++++++++++++ components/conference/ConferenceTabs.tsx | 6 +- components/conference/input/PublicInput.tsx | 3 +- components/conference/input/YourInput.tsx | 3 +- components/conference/input/icons.tsx | 30 +- components/conference/input/tokens.ts | 11 + components/conference/load.ts | 23 +- components/conference/types.ts | 28 + .../student-learning-and-mental-health.yaml | 104 ++- docs/updating-conferences.md | 2 +- functions/src/conferences/types.ts | 12 +- public/locales/en/conferences.json | 12 + .../conferences/194/test-conference.yaml | 7 + tests/integration/conferences.test.ts | 7 +- 19 files changed, 1110 insertions(+), 34 deletions(-) create mode 100644 components/conference/ConferenceDifferences.tsx diff --git a/components/about/SupportMaple/SupportMaple.tsx b/components/about/SupportMaple/SupportMaple.tsx index 65a8e15b4..93acb8759 100644 --- a/components/about/SupportMaple/SupportMaple.tsx +++ b/components/about/SupportMaple/SupportMaple.tsx @@ -2,6 +2,7 @@ import { ReactNode } from "react" import { useTranslation } from "next-i18next" import styled from "styled-components" import AboutPagesCard from "../../AboutPagesCard/AboutPagesCard" +import { FEEDBACK_URL } from "../../common" import { ArrowRightIcon } from "../../learn/icons" import { Internal } from "../../links" import LearnBreadcrumb from "../../learn/LearnBreadcrumb" @@ -10,8 +11,6 @@ import LearnLayout from "../../learn/LearnLayout" const DONATE_URL = "https://partnersindemocracy.us/?form=FUNMSSLLQEC" const GITHUB_URL = "https://github.com/codeforboston/maple/graphs/contributors" -const FEEDBACK_URL = - "https://docs.google.com/forms/d/e/1FAIpQLScU0l0bs-QLdVqIlh6iwi-Y2kOrSv7eqH5h4klhhtNGqsxCSw/viewform?usp=sharing" const BUG_URL = "https://docs.google.com/forms/d/e/1FAIpQLSebZTHz0XGR4WgPf10yTPXSOuZK2P41HxK0AFGXM57KbFWuDg/viewform?usp=share_link" diff --git a/components/common.tsx b/components/common.tsx index eb8cd258a..3454a7282 100644 --- a/components/common.tsx +++ b/components/common.tsx @@ -2,3 +2,5 @@ export const NEWSLETTER_SIGNUP_URL = "https://cdn.forms-content.sg-form.com/66e4c174-72e5-11f0-847b-fe4d8dd139cf" export const TRAINING_CALENDAR_URL = "https://calendar.google.com/calendar/embed?src=998f62323926f0b0076e7f578d3ca72b1bc94c4efa2f24be57b11f52b1b88595%40group.calendar.google.com&ctz=America%2FNew_York" +export const FEEDBACK_URL = + "https://docs.google.com/forms/d/e/1FAIpQLScU0l0bs-QLdVqIlh6iwi-Y2kOrSv7eqH5h4klhhtNGqsxCSw/viewform?usp=sharing" diff --git a/components/conference/ConferenceDetails.switching.test.tsx b/components/conference/ConferenceDetails.switching.test.tsx index 81849a2cd..e063cd979 100644 --- a/components/conference/ConferenceDetails.switching.test.tsx +++ b/components/conference/ConferenceDetails.switching.test.tsx @@ -93,7 +93,10 @@ const page = (conference: ConferencePageProps["conference"]) => ({ conference, conferences: [phoneFree, privacy], conferees: [], - hearings: [] + hearings: [], + gist: null, + differences: [], + similarities: [] }) describe("moving between conferences", () => { diff --git a/components/conference/ConferenceDetails.test.tsx b/components/conference/ConferenceDetails.test.tsx index 3df256509..e1477475f 100644 --- a/components/conference/ConferenceDetails.test.tsx +++ b/components/conference/ConferenceDetails.test.tsx @@ -191,9 +191,49 @@ const props: ConferencePageProps = { eventId: 5710, startsAt: Date.UTC(2026, 5, 4, 17) } + ], + gist: "This bill regulates how students use technology in schools.", + differences: [ + { + heading: "Social media ban", + senate: null, + house: "Regulates social media companies under Chapter 93M.", + notes: { "Chapter 93M": ["Platforms must bar users under 14."] }, + source: { + organisation: "Example Gazette", + url: "https://www.example.com/explainer", + publishedAt: "2026-09-01" + } + }, + { + heading: "Use or possession", + senate: "Bans use and possession.", + house: "Bans use.", + notes: {}, + source: { + organisation: "Other Radio", + url: "https://example.org/report", + publishedAt: null + } + } + ], + similarities: [ + { + heading: "Phones barred during the school day", + body: "Every district must have a policy.", + source: { + organisation: "Example Gazette", + url: "https://www.example.com/explainer", + publishedAt: "2026-09-01" + } + } ] } +/** What useWide reads: jsdom lays nothing out, so every element is 0 wide. */ +const widen = (px: number) => + jest.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(px) + beforeEach(() => { mockBill.mockImplementation((_court: number, id: string) => texts[id]) }) @@ -340,9 +380,10 @@ describe("ConferenceDetails", () => { it("takes input only while the conference is appointed", () => { const { unmount } = render() - expect( - screen.getByRole("button", { name: "input.share" }) - ).toBeInTheDocument() + // The title's, and the one beside the reader's own legislator. + expect(screen.getAllByRole("button", { name: "input.share" })).toHaveLength( + 2 + ) unmount() const reported = { ...conference, status: "reportFiled" as const } render() @@ -360,6 +401,7 @@ describe("ConferenceDetails", () => { .map(a => [a.textContent, a.getAttribute("href")]) expect(tabs).toEqual([ ["tab_committee", "#committee"], + ["tab_decided", "#decided"], ["tab_input", "#input"], ["tab_text", "#text"] ]) @@ -371,8 +413,9 @@ describe("ConferenceDetails", () => { // jsdom lays nothing out, so each section says where it is, 850px tall. const tops: Record = { committee: 0, - input: 900, - text: 1800 + decided: 900, + input: 1800, + text: 2700 } const rect = jest .spyOn(HTMLElement.prototype, "getBoundingClientRect") @@ -388,19 +431,101 @@ describe("ConferenceDetails", () => { .map(a => a.textContent) expect(lit()).toEqual(["tab_committee"]) // The last section lights too, once a jump has parked it. + tops.decided = -1800 tops.input = -900 tops.text = 0 fireEvent.scroll(document) expect(lit()).toEqual(["tab_text"]) // A section can move with no scroll, when one above it grows: posting // puts Your Input above Public Input. - tops.input = 900 - tops.text = 1800 + tops.decided = 900 + tops.input = 1800 + tops.text = 2700 act(() => resized.forEach(heard => heard())) expect(lit()).toEqual(["tab_committee"]) rect.mockRestore() }) + describe("what needs to be resolved", () => { + afterEach(() => jest.restoreAllMocks()) + + it("opens the first difference, each chamber's text in its own card", () => { + render() + const [first, second] = screen + .getAllByRole("button", { expanded: true }) + .concat(screen.getAllByRole("button", { expanded: false })) + .filter(b => /Social media ban|Use or possession/.test(b.textContent!)) + expect(first).toHaveTextContent("Social media ban") + expect(first).toHaveAttribute("aria-expanded", "true") + expect(second).toHaveAttribute("aria-expanded", "false") + // The Senate bill has nothing on it, so its card says so. + expect(screen.getByText("decided.not_in_bill")).toBeInTheDocument() + expect(screen.getByText(/Regulates social media/)).toBeInTheDocument() + expect(screen.queryByText("Bans use.")).not.toBeInTheDocument() + fireEvent.click(second) + expect(screen.getByText("Bans use.")).toBeInTheDocument() + }) + + it("opens a term's note on hover", () => { + render() + fireEvent.pointerEnter(screen.getByText("Chapter 93M")) + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Platforms must bar users under 14." + ) + }) + + it("lists the matches beside the differences only when there is room", () => { + const { unmount } = render() + expect(screen.queryByText("decided.match")).not.toBeInTheDocument() + unmount() + widen(800) + render() + const row = screen.getByRole("button", { + name: /Phones barred during the school day/ + }) + expect( + screen.queryByText("Every district must have a policy.") + ).toBeNull() + fireEvent.click(row) + expect( + screen.getByText("Every district must have a policy.") + ).toBeInTheDocument() + }) + + it("numbers each source once and lists it with its host", () => { + widen(800) + render() + const items = within( + screen.getByText("decided.sources").nextElementSibling as HTMLElement + ).getAllByRole("listitem") + expect(items).toHaveLength(2) + expect(items[0]).toHaveTextContent("Example Gazette, Sep 1, 2026.") + expect(within(items[0]).getByRole("link")).toHaveTextContent( + "example.com" + ) + expect(items[1]).toHaveTextContent("Other Radio.") + // The match cites the first source again rather than a third. + expect( + screen.getByRole("button", { name: /Phones barred/ }) + ).toHaveTextContent("1") + }) + + it("says the reader's own legislator is deciding", () => { + render() + expect(screen.getByTestId("decided.yours_one")).toHaveTextContent( + "your_representative A. Peisch" + ) + }) + + it("leaves the section and its tab out where nothing is written", () => { + render( + + ) + expect(screen.queryByText("decided.title")).not.toBeInTheDocument() + expect(screen.queryByText("tab_decided")).not.toBeInTheDocument() + }) + }) + describe("bill text", () => { it("shows both texts side by side, Senate first, a paragraph per line", () => { render() diff --git a/components/conference/ConferenceDetails.tsx b/components/conference/ConferenceDetails.tsx index db18486de..cc6ae445a 100644 --- a/components/conference/ConferenceDetails.tsx +++ b/components/conference/ConferenceDetails.tsx @@ -12,10 +12,11 @@ import { takesTestimony } from "../../functions/src/shared" import styled from "styled-components" import { ConferenceByline } from "./ConferenceByline" import { ConferenceCommittee } from "./ConferenceCommittee" +import { ConferenceDifferences } from "./ConferenceDifferences" import { ConferenceHero } from "./ConferenceHero" import { ConferencePicker } from "./ConferencePicker" import { ConferenceRail } from "./ConferenceRail" -import { ConferenceTabs } from "./ConferenceTabs" +import { ConferenceTabs, TABS_H } from "./ConferenceTabs" import { ConferenceText } from "./ConferenceText" import { useAuth } from "../auth" import { authStepChanged } from "../auth/redux" @@ -108,17 +109,19 @@ const TitleBand = styled(Band)` padding-bottom: 0.75rem; ` -/** A section the tabs lead to, parked under the title and the tabs: 4.5rem is - * ConferenceTabs' row and the mockup's 24px under it. */ +/** A section the tabs lead to, parked under the title and the tabs with the + * mockup's 24px under them. */ const Section = styled.div` margin-top: 1.5rem; scroll-margin-top: calc( - var(--maple-navbar-height) + var(--title-h, 5rem) + 4.5rem + var(--maple-navbar-height) + var(--title-h, 5rem) + ${TABS_H} + 1.5rem ); ` -/** In the mockup's order, which goes on to the sections still to come. */ -const SECTIONS = ["committee", "input", "text"] as const +/** In the mockup's order, which goes on to the sections still to come. A + * conference with nothing written on what is unresolved has no such section. */ +const SECTIONS = ["committee", "decided", "input", "text"] as const +const UNDECIDED = SECTIONS.filter(s => s !== "decided") /** * One conference: who is deciding, and the two texts they are deciding @@ -140,6 +143,9 @@ function Details({ conferences, conferees, hearings, + gist, + differences, + similarities, openOnHero = false }: ConferencePageProps & { openOnHero?: boolean }) { const { top, hero, toConference } = useConferenceTop(openOnHero) @@ -160,6 +166,7 @@ function Details({ const followed = useFollowedAuthors(user?.uid) const subject = conferenceName(conference) const taking = takesTestimony(conference.status) + const decided = differences.length > 0 || similarities.length > 0 const close = useCallback(() => setOpen(false), []) const edit = useCallback(() => { @@ -239,7 +246,7 @@ function Details({ $top="calc(var(--maple-navbar-height) + var(--title-h, 5rem))" $z={13} > - +
{/* Keyed so a pinned seat does not outlive its conference. */} @@ -250,6 +257,18 @@ function Details({ hearings={hearings} />
+ {decided && ( +
+ +
+ )} {mine && (
diff --git a/components/conference/ConferenceDifferences.tsx b/components/conference/ConferenceDifferences.tsx new file mode 100644 index 000000000..7b7c8ec22 --- /dev/null +++ b/components/conference/ConferenceDifferences.tsx @@ -0,0 +1,714 @@ +import { Fragment, KeyboardEvent, ReactNode, useRef, useState } from "react" +import { DateTime } from "luxon" +import { Trans, useTranslation } from "next-i18next" +import styled from "styled-components" +import { FEEDBACK_URL } from "../common" +import { shortName } from "../formatting" +import { yoursKey } from "./Conferees" +import { useMyLegislators } from "./hooks" +import { + ArrowRight, + ArrowUpRight, + Check, + ChevronRight, + Star +} from "./input/icons" +import { + brand, + caution, + chamberInk, + ink, + line, + positive, + red, + shadow, + yea +} from "./input/tokens" +import { useWide } from "./input/useWide" +import { TABS_H } from "./ConferenceTabs" +import { Card } from "./styles" +import { + CHAMBERS, + ConfereeView, + DifferenceView, + SimilarityView, + SourceView +} from "./types" + +/** The mockup's card, which keeps its gutters at every width. */ +const Frame = styled(Card)` + --pad-x: 32px; + padding: 24px 32px 32px 34px; +` + +/** The heading holds under the tabs while the section scrolls, on a white + * band run out to the card's edges so what passes under it is covered. */ +const HeadingBand = styled.div` + position: sticky; + top: calc(var(--maple-navbar-height) + var(--title-h, 5rem) + ${TABS_H}); + z-index: 9; + margin: -12px calc(-1 * var(--pad-x)); + padding: 12px var(--pad-x); + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl) var(--maple-radius-xl) 0 0; +` + +const Heading = styled.h2` + position: relative; + left: -4px; + margin: 0; + font-family: var(--maple-font-heading); + font-weight: 400; + font-size: 1.25rem; + line-height: 1.4; + color: ${ink.strong}; + text-wrap: balance; + + @media (min-width: 992px) { + font-size: 1.375rem; + } +` + +const Body = styled.div` + display: flex; + flex-direction: column; + gap: 28px; + padding-top: 24px; + + @media (min-width: 640px) { + gap: 40px; + } +` + +const Gist = styled.p` + margin: 0 0 2px; + padding-left: 16px; + border-left: 2px solid ${line.strong}; + font-size: 0.8125rem; + line-height: 1.65; + color: ${ink.mid}; +` + +const DottedLink = styled.a` + font-weight: 600; + color: var(--maple-brand-primary); + text-decoration: underline dotted; + text-underline-offset: 4px; + &:hover { + color: ${brand.fill}; + } +` + +const Grid = styled.div<{ $wide: boolean }>` + display: grid; + gap: 32px 80px; + align-items: start; + ${({ $wide }) => + $wide && "grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);"} +` + +const List = styled.ul<{ $gap: number }>` + display: flex; + flex-direction: column; + gap: ${({ $gap }) => $gap}px; + margin: 12px 0 0; + padding: 0; + list-style: none; +` + +/** The SENATE and HOUSE labels' small caps, for a column or the sources. */ +const Label = styled.p` + display: flex; + align-items: baseline; + gap: 8px; + margin: 0; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: ${ink.mid}; +` + +const Count = styled.span` + font-variant-numeric: tabular-nums; + color: ${ink.faint}; +` + +/** Turned a quarter by the toggle it sits in, once that is open. */ +const Chevron = styled.span` + display: flex; + flex-shrink: 0; + transition: transform 150ms; + [aria-expanded="true"] > &, + [aria-expanded="true"] > * > & { + transform: rotate(90deg); + } +` + +/** A point's heading, pressed to open its two texts. The chevron carries the + * hover; the words stay as they are. */ +const Toggle = styled.button` + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + margin: -4px -8px; + padding: 4px 8px; + background: none; + border: 0; + border-radius: 8px; + text-align: left; + font-weight: 500; + font-size: 1rem; + line-height: 1.35; + color: ${ink.strong}; + + ${Chevron} { + margin-top: 1px; + color: ${ink.faint}; + } + &:hover ${Chevron} { + color: ${ink.strong}; + } +` + +const Answers = styled.div` + display: flex; + gap: 14px; + margin-top: 14px; + + @media (min-width: 390px) { + padding-left: 27px; + } +` + +/** Each chamber's text on its own surface, outlined in the chamber's ink. */ +const Answer = styled.div<{ $ink: string }>` + flex: 1 1 0; + min-width: 0; + padding: 16px; + background: var(--maple-surface-base); + border: 1px solid ${({ $ink }) => $ink}; + border-radius: var(--maple-radius-xl); +` + +const Chamber = styled(Label)<{ $ink: string }>` + color: ${({ $ink }) => $ink}; +` + +/** Faint italic is for the bare fallback alone: an absence, not content. */ +const AnswerText = styled.p<{ $absent: boolean }>` + margin: 2px 0 0; + font-size: 0.8125rem; + line-height: 1.4; + color: ${({ $absent }) => ($absent ? ink.faint : ink.strong)}; + font-style: ${({ $absent }) => ($absent ? "italic" : "normal")}; + + @media (min-width: 390px) { + font-size: 0.875rem; + } +` + +/** A span rather than a button, so a term of several words breaks across + * lines like the sentence around it. */ +const TermText = styled.span` + position: relative; + cursor: default; + text-decoration: underline dotted ${line.strong}; + text-underline-offset: 4px; +` + +const Note = styled.span<{ $up: boolean }>` + position: absolute; + left: 0; + ${({ $up }) => + $up ? "bottom: 100%; margin-bottom: 8px;" : "top: 100%; margin-top: 8px;"} + z-index: 80; + display: block; + width: 320px; + max-width: 80vw; + padding: 14px; + background: var(--maple-surface-base); + border: 1px solid ${line.strong}; + border-radius: 8px; + box-shadow: ${shadow.popover}; + pointer-events: none; + text-align: left; + font-style: normal; +` + +const NoteTitle = styled(Label).attrs({ as: "span" })` + margin-bottom: 8px; + letter-spacing: 0.07em; +` + +const NoteLine = styled.span` + display: flex; + gap: 8px; + margin-top: 6px; + font-size: 0.8125rem; + line-height: 1.55; + color: ${ink.strong}; + &:first-of-type { + margin-top: 0; + } +` + +const Yours = styled.p` + margin: 48px 0 0; + font-size: 0.875rem; + line-height: 1.6; + color: ${ink.strong}; + + & > svg { + display: inline; + vertical-align: -2px; + margin-right: 8px; + color: ${caution}; + } +` + +const ShareLink = styled.button` + display: inline-flex; + align-items: baseline; + gap: 4px; + padding: 0; + background: none; + border: 0; + font-weight: 600; + color: ${brand.ink}; + text-decoration: underline dotted; + text-underline-offset: 4px; + &:hover { + color: ${brand.fill}; + } + svg { + align-self: center; + margin: 0; + color: currentColor; + } +` + +const OnRecord = styled.span` + font-weight: 600; + color: ${positive}; +` + +const RowToggle = styled.button` + display: flex; + align-items: flex-start; + gap: 6px; + width: 100%; + padding: 5px 0; + background: none; + border: 0; + text-align: left; + /* The mockup's buttons are medium, and the title takes it. */ + font-weight: 500; + + ${Chevron} { + margin-top: 2px; + color: ${line.strong}; + } + &:hover ${Chevron} { + color: ${ink.faint}; + } +` + +const Marks = styled.span` + display: flex; + flex-shrink: 0; + align-items: flex-start; + gap: 10px; +` + +const Tick = styled.span` + display: flex; + justify-content: center; + width: 18px; + margin-top: 3px; + color: ${yea}; +` + +const RowTitle = styled.span` + flex: 1 1 auto; + font-size: 0.8125rem; + line-height: 1.5; + color: ${ink.strong}; +` + +const RowBody = styled.p` + margin: 0; + padding: 0 0 12px 49px; + font-size: 0.8125rem; + line-height: 1.6; + color: ${ink.mid}; +` + +/** The mockup's citation mark. */ +const Mark = styled.sup` + margin-left: 1px; + font-size: 0.55em; + font-weight: 600; + color: ${ink.faint}; +` + +/** The ballot page's bibliography, as the mockup parks it: one sentence per + * source, the host as the link. */ +const Sources = styled.ol` + margin: 12px 0 0; + padding-left: 20px; + font-size: 0.8125rem; + line-height: 1.6; + color: ${ink.strong}; + + li + li { + margin-top: 8px; + } + li::marker { + color: ${ink.faint}; + } + a { + display: inline-flex; + align-items: baseline; + gap: 3px; + color: ${brand.fill}; + text-decoration: underline; + text-underline-offset: 3px; + &:hover { + color: ${red.hover}; + } + } + svg { + align-self: center; + } +` + +/** + * What the two chambers still disagree on, with the points where their texts + * already match beside it: written by people, set out as the mockup's "What + * needs to be resolved?". Left out where a conference has neither. + */ +export function ConferenceDifferences({ + gist, + differences, + similarities, + conferees, + onShare, + posted +}: { + gist: string | null + differences: DifferenceView[] + similarities: SimilarityView[] + conferees: ConfereeView[] + /** Offered beside the reader's own legislator while input is taken. */ + onShare?: () => void + /** The reader is already on the record. */ + posted: boolean +}) { + const { t } = useTranslation("conferences") + const body = useRef(null) + // The mockup's container query: below 720px the matches are left out. + const wide = useWide(body, 720) + const cited = sourcesOf([...differences, ...(wide ? similarities : [])]) + const markFor = (s: SourceView | null) => { + const n = s ? cited.findIndex(c => c.url === s.url) + 1 : 0 + return n ? {n} : null + } + + return ( + + + {t("decided.title")} + + + {gist && ( + + {gist}{" "} + + ]} + /> + + )} + +
+ + {differences.map((d, i) => ( + + ))} + + +
+ {wide && similarities.length > 0 && ( +
+ + + {similarities.map(s => ( + + ))} + +
+ )} +
+ {cited.length > 0 && ( +
+ + + {cited.map(s => ( + + ))} + +
+ )} + + + ) +} + +/** Each source once, in the order the points first cite it. */ +const sourcesOf = (points: { source: SourceView | null }[]) => [ + ...new Map( + points.flatMap(p => (p.source ? [[p.source.url, p.source] as const] : [])) + ).values() +] + +function Difference({ + d, + mark, + defaultOpen +}: { + d: DifferenceView + mark: ReactNode + defaultOpen: boolean +}) { + const { t } = useTranslation("conferences") + const [open, setOpen] = useState(defaultOpen) + return ( +
  • + setOpen(o => !o)}> + + + + + {d.heading} + {mark} + + + {open && ( + + {CHAMBERS.map(chamber => { + const key = chamber === "Senate" ? "senate" : "house" + const text = d[key] + return ( + + {t(key)} + + {text ? ( + + ) : ( + t("decided.not_in_bill") + )} + + + ) + })} + + )} +
  • + ) +} + +/** The text, with each phrase that carries a note made into a term. */ +function Marked({ + text, + notes +}: { + text: string + notes: Record +}) { + const terms = Object.keys(notes).filter(k => text.includes(k)) + if (!terms.length) return <>{text} + const escaped = terms.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + const parts = text.split(new RegExp(`(${escaped.join("|")})`)) + return ( + <> + {parts.map((part, i) => + terms.includes(part) ? ( + + ) : ( + {part} + ) + )} + + ) +} + +/** The height a note can take at its widest, plus its gap. */ +const NOTE_H = 380 + +function Term({ label, note }: { label: string; note: string[] }) { + const ref = useRef(null) + const [open, setOpen] = useState(false) + const [up, setUp] = useState(false) + const show = () => { + const r = ref.current?.getBoundingClientRect() + if (r) { + const below = window.innerHeight - r.bottom + setUp(below < NOTE_H && r.top > below) + } + setOpen(true) + } + const key = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false) + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + open ? setOpen(false) : show() + } + } + return ( + setOpen(false)} + onFocus={show} + onBlur={() => setOpen(false)} + onKeyDown={key} + > + {label} + {open && ( + + {label} + {note.map(n => ( + + + • + + {n} + + ))} + + )} + + ) +} + +/** The one line about the reader rather than the bills: one of the people + * deciding is theirs. */ +function YourLegislators({ + conferees, + onShare, + posted +}: { + conferees: ConfereeView[] + onShare?: () => void + posted: boolean +}) { + const { t } = useTranslation("conferences") + const mine = useMyLegislators() + const yours = conferees.filter(c => mine.has(c.memberCode)) + if (!yours.length) return null + return ( + + + + {yours.length > 1 ? ( + , ]} + /> + ) : ( + ]} + /> + )} + {" "} + {posted ? ( + {t("input.yours_on_record")} + ) : ( + onShare && ( + + {t("input.share")} + + + ) + )} + + ) +} + +function Similarity({ s, mark }: { s: SimilarityView; mark: ReactNode }) { + const [open, setOpen] = useState(false) + return ( +
  • + setOpen(o => !o)}> + + + + + + + + + + {s.heading} + {mark} + + + {open && {s.body}} +
  • + ) +} + +function Source({ source }: { source: SourceView }) { + const { t } = useTranslation("conferences") + return ( +
  • + {source.organisation} + {source.publishedAt && `, ${published(source.publishedAt)}`}.{" "} + {t("decided.available")}{" "} + + {new URL(source.url).hostname.replace(/^www\./, "")} + + +
  • + ) +} + +/** An ISO date as the citation prints it: "Sep 1, 2026". */ +const published = (iso: string) => + DateTime.fromISO(iso, { zone: "utc" }).toLocaleString(DateTime.DATE_MED) diff --git a/components/conference/ConferenceTabs.tsx b/components/conference/ConferenceTabs.tsx index baba4adba..0e37aacc8 100644 --- a/components/conference/ConferenceTabs.tsx +++ b/components/conference/ConferenceTabs.tsx @@ -8,8 +8,10 @@ const ink = { strong: "#141413", mid: "#73726c" } const rule = "rgba(0, 0, 0, 0.16)" const brand = "#1f3b61" -/** The row's height, its tabs' padding and rules and one line of text, is - * what ConferenceDetails parks a section under, with the mockup's 24px. */ +/** The row's height: its tabs' padding and rules and one line of text. What + * holds under it, and what a jump parks under it, is placed by this. */ +export const TABS_H = "3rem" + const Row = styled.nav` display: flex; gap: 22px; diff --git a/components/conference/input/PublicInput.tsx b/components/conference/input/PublicInput.tsx index 0c4744ef9..cd460ffc2 100644 --- a/components/conference/input/PublicInput.tsx +++ b/components/conference/input/PublicInput.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react" import { useTranslation } from "next-i18next" import styled from "styled-components" +import { TABS_H } from "../ConferenceTabs" import { ConferenceTestimony } from "components/db" import { useInputContext } from "./context" import { Entry } from "./Entry" @@ -24,7 +25,7 @@ const PAGE_SIZE = 5 const Section = styled.section` scroll-margin-top: calc( - var(--maple-navbar-height) + var(--title-h, 5rem) + 4.5rem + var(--maple-navbar-height) + var(--title-h, 5rem) + ${TABS_H} + 1.5rem ); ` diff --git a/components/conference/input/YourInput.tsx b/components/conference/input/YourInput.tsx index f08c034ed..ef480087d 100644 --- a/components/conference/input/YourInput.tsx +++ b/components/conference/input/YourInput.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import { useTranslation } from "next-i18next" import styled from "styled-components" +import { TABS_H } from "../ConferenceTabs" import { ConferenceTestimony } from "components/db" import { Entry } from "./Entry" import { EntryModal } from "./EntryModal" @@ -10,7 +11,7 @@ import { ink, line, shadow } from "./tokens" const Section = styled.section` scroll-margin-top: calc( - var(--maple-navbar-height) + var(--title-h, 5rem) + 4.5rem + var(--maple-navbar-height) + var(--title-h, 5rem) + ${TABS_H} + 1.5rem ); ` diff --git a/components/conference/input/icons.tsx b/components/conference/input/icons.tsx index c41641051..b67e3b692 100644 --- a/components/conference/input/icons.tsx +++ b/components/conference/input/icons.tsx @@ -1,15 +1,20 @@ import { ReactNode } from "react" /** The mockup's line icons (Lucide, ISC), drawn at its sizes. */ -function Icon({ size = 16, children }: { size?: number; children: ReactNode }) { +function Icon({ + size = 16, + strokeWidth = 2, + fill = "none", + children +}: Props & { children: ReactNode }) { return ( ( @@ -124,3 +129,20 @@ export const SquarePen = (p: Props) => ( ) +export const ArrowRight = (p: Props) => ( + + + + +) +export const ArrowUpRight = (p: Props) => ( + + + + +) +export const Star = (p: Props) => ( + + + +) diff --git a/components/conference/input/tokens.ts b/components/conference/input/tokens.ts index 5ef5b0505..3920cfe48 100644 --- a/components/conference/input/tokens.ts +++ b/components/conference/input/tokens.ts @@ -36,3 +36,14 @@ export const shadow = { raised: "0 1px 2px rgba(20, 20, 19, 0.05)", popover: "0 8px 24px rgba(20, 20, 19, 0.1), 0 1px 3px rgba(20, 20, 19, 0.06)" } + +/** The chamber inks, matched in luminance: the Senate blue darkened to the + * House amber. */ +export const chamberInk = { + Senate: "#2562b9", + House: "#8a5a00" +} + +export const yea = "#2f6b46" +export const caution = "#ecb72e" +export const positive = "#2e7d32" diff --git a/components/conference/load.ts b/components/conference/load.ts index 973e90554..a22f684f3 100644 --- a/components/conference/load.ts +++ b/components/conference/load.ts @@ -1,5 +1,6 @@ import { districtId } from "functions/src/districts/normalize" import type { Branch } from "../../functions/src/common" +import type { ConferenceSource } from "../../functions/src/conferences/types" import { Conference, getConferenceForBill, @@ -15,7 +16,8 @@ import { ConferenceIndexProps, ConferencePageProps, ConferenceSummary, - HearingView + HearingView, + SourceView } from "./types" const summarize = (c: Conference): ConferenceSummary => ({ @@ -29,6 +31,10 @@ const summarize = (c: Conference): ConferenceSummary => ({ status: c.status }) +/** As props: Next refuses undefined, so what is left out arrives as null. */ +const sourceView = (s?: ConferenceSource): SourceView | null => + s ? { ...s, publishedAt: s.publishedAt ?? null } : null + /** The member's cell on the map: their district id, or null where the map has * no such seat. */ const seatFor = (branch: Branch, district: string) => { @@ -124,7 +130,20 @@ async function pageFor( ? [{ id: h.id, eventId: h.eventId, startsAt: h.startsAt }] : [] ) - .sort((a, b) => a.startsAt - b.startsAt) + .sort((a, b) => a.startsAt - b.startsAt), + gist: conference.gist ?? null, + differences: conference.differences.map(d => ({ + heading: d.heading, + senate: d.senate ?? null, + house: d.house ?? null, + notes: d.notes ?? {}, + source: sourceView(d.source) + })), + similarities: conference.similarities.map(d => ({ + heading: d.heading, + body: d.body, + source: sourceView(d.source) + })) } } diff --git a/components/conference/types.ts b/components/conference/types.ts index dc3162e05..e6c3ad72a 100644 --- a/components/conference/types.ts +++ b/components/conference/types.ts @@ -38,11 +38,39 @@ export const personKey = (c: ConfereeView) => c.seat ?? c.memberCode /** A linked hearing, without its videos. */ export type HearingView = Pick +/** Where an authored point came from. */ +export type SourceView = { + organisation: string + url: string + publishedAt: string | null +} + +/** One point the chambers disagree on; a chamber's text is null where its + * bill has nothing on it. */ +export type DifferenceView = { + heading: string + senate: string | null + house: string | null + notes: Record + source: SourceView | null +} + +/** One point where the two texts match. */ +export type SimilarityView = { + heading: string + body: string + source: SourceView | null +} + export type ConferencePageProps = { conference: ConferenceSummary conferences: ConferenceSummary[] conferees: ConfereeView[] hearings: HearingView[] + /** What the bill is, in one sentence; null where none is written. */ + gist: string | null + differences: DifferenceView[] + similarities: SimilarityView[] } /** The index: every conference, active first, with its conferees by diff --git a/conferences/194/student-learning-and-mental-health.yaml b/conferences/194/student-learning-and-mental-health.yaml index 1cac3b6a4..5179b7955 100644 --- a/conferences/194/student-learning-and-mental-health.yaml +++ b/conferences/194/student-learning-and-mental-health.yaml @@ -2,6 +2,9 @@ id: student-learning-and-mental-health name: Phone-Free Schools court: 194 title: "An Act to promote student learning and mental health " +# LOREM IPSUM: gist, differences and similarities are placeholders until the +# sensemaking is written. +gist: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore. senateBillId: S2581 houseBillId: H5366 canonicalBillId: S2581 @@ -23,5 +26,102 @@ conferees: branch: House hearingIds: - hearing-5710 -differences: [] -similarities: [] +differences: + - heading: Lorem ipsum dolor + senate: Not in the Senate bill. Lorem ipsum dolor sit amet, consectetur adipiscing elit. + house: Sed do eiusmod tempor under the Consectetur Adipiscing Chapter, ut labore et dolore magna aliqua. + notes: + Consectetur Adipiscing Chapter: + - Ut enim ad minim veniam, quis nostrud exercitation. + - Duis aute irure dolor in reprehenderit in voluptate. + - Excepteur sint occaecat cupidatat non proident. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: Sit amet curriculum + house: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: Consectetur pilot + house: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + - heading: Adipiscing or possession + senate: Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt. + house: Mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem. + source: + organisation: Ipsum Public Radio + url: https://example.org/ipsum-report + publishedAt: "2026-09-14" + - heading: Elit exceptions + senate: Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit. + house: Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur. + source: + organisation: Ipsum Public Radio + url: https://example.org/ipsum-report + publishedAt: "2026-09-14" + - heading: Tempor hearing + senate: Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit. + source: + organisation: Dolor Daily + url: https://example.net/dolor + publishedAt: "2026-10-02" + - heading: Incididunt dates + senate: Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse. + house: At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium. + source: + organisation: Dolor Daily + url: https://example.net/dolor + publishedAt: "2026-10-02" +similarities: + - heading: Lorem ipsum barred during the day + body: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: Dolor issues guidance and a model + body: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: Sit amet takes over past a deadline + body: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore. + - heading: Consectetur filed every year + body: Identical in both bills. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: Adipiscing told every year + body: "Senate: excepteur sint occaecat. House: cupidatat non proident, sunt in culpa." + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: Exceptions for sed do eiusmod + body: Qui officia deserunt mollit anim id est laborum. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: A guaranteed way to tempor + body: Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: No incididunt for ut labore + body: Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" + - heading: Magna aliqua reports back + body: Senate by lorem 2027; House by ipsum 2028. + source: + organisation: Lorem Gazette + url: https://example.com/lorem-explainer + publishedAt: "2026-09-01" diff --git a/docs/updating-conferences.md b/docs/updating-conferences.md index 08a272d06..d0ba2c8a6 100644 --- a/docs/updating-conferences.md +++ b/docs/updating-conferences.md @@ -16,7 +16,7 @@ This drafts each conference that doesn't have a file yet. Finish each draft by h 1. Resolve every `TODO` (conferee member codes, the partner bill). 2. Check `status` against the conference report bill, because the guess stops at "Reported by". 3. Add `hearingIds` (`hearing-{EventId}`) from the hearing list the script prints. -4. Write `differences` and `similarities`, each with a `source`. +4. Write the `gist`, then `differences` and `similarities`, each with a `source`. Leave out a chamber's text when its bill doesn't touch the point. ## Updating a conference diff --git a/functions/src/conferences/types.ts b/functions/src/conferences/types.ts index 2f596a370..c6236fb1a 100644 --- a/functions/src/conferences/types.ts +++ b/functions/src/conferences/types.ts @@ -1,6 +1,7 @@ import { Array, Boolean, + Dictionary, Literal as L, Number, Optional, @@ -21,12 +22,15 @@ export const ConferenceSource = Record({ }) /** One point the chambers disagree on, with a short blob per chamber. Written - * by journalists, never computed from the texts. */ + * by journalists, never computed from the texts. A chamber left out has + * nothing on it in its bill. */ export type ConferenceDifference = Static export const ConferenceDifference = Record({ heading: String, - senate: String, - house: String, + senate: Optional(String), + house: Optional(String), + /** Phrases in either blob that open a note, each with its points. */ + notes: Optional(Dictionary(Array(String), String)), source: Optional(ConferenceSource) }) @@ -68,6 +72,8 @@ export const Conference = withDefaults( /** A short name for headings and lists ("Phone-Free Schools"), as the * press refers to it. Pages fall back to the title. */ name: Optional(String), + /** What the bill is, in one sentence, above what is unresolved. */ + gist: Optional(String), senateBillId: Id, houseBillId: Id, /** The bill whose history carries the appointment actions, one of the diff --git a/public/locales/en/conferences.json b/public/locales/en/conferences.json index 1f48b4f17..689a0b5d7 100644 --- a/public/locales/en/conferences.json +++ b/public/locales/en/conferences.json @@ -73,8 +73,20 @@ "text_pdf": "Read the PDF", "tabs_label": "Sections of this conference committee", "tab_committee": "Committee", + "tab_decided": "Unresolved Text", "tab_input": "Public Input", "tab_text": "Bill Text", + "decided": { + "title": "What needs to be resolved?", + "gist": "Below are some of the places where the House and Senate text differ. Notice something missing or wrong? <0>Send us feedback.", + "differ": "Where bill texts differ", + "match": "Where bill texts match", + "not_in_bill": "Not in the bill", + "yours_one": "{{who}} is on this committee, <0>{{name}}.", + "yours_both": "Both of your legislators are on this committee, <0>{{a}} and <1>{{b}}.", + "sources": "Sources", + "available": "[Online]. Available:" + }, "input": { "share": "Share your input", "title": "Public Input", diff --git a/tests/fixtures/conferences/194/test-conference.yaml b/tests/fixtures/conferences/194/test-conference.yaml index df56ff9a2..1924f9c8f 100644 --- a/tests/fixtures/conferences/194/test-conference.yaml +++ b/tests/fixtures/conferences/194/test-conference.yaml @@ -2,6 +2,7 @@ id: test-conference name: Test Conference court: 194 title: Test conference committee +gist: This bill regulates how students use technology in schools. senateBillId: S9901 houseBillId: H9901 canonicalBillId: S9901 @@ -23,6 +24,12 @@ differences: organisation: Example Gazette url: https://example.com/explainer publishedAt: "2026-09-01" + - heading: Device lockout pilot + house: DESE runs a pilot of device lockout technology. + notes: + device lockout technology: + - Renders devices inoperable on school grounds. + - 911 is always reachable. similarities: - heading: Phones barred during the school day body: Every district must have a policy prohibiting student use of personal electronic devices. diff --git a/tests/integration/conferences.test.ts b/tests/integration/conferences.test.ts index f0ae9a47d..8431123e1 100644 --- a/tests/integration/conferences.test.ts +++ b/tests/integration/conferences.test.ts @@ -72,8 +72,13 @@ it("writes the document from YAML", async () => { expect(doc?.senateBillId).toBe("S9901") expect(doc?.houseBillId).toBe("H9901") expect(doc?.hearingIds).toEqual(["hearing-9901"]) - expect(doc?.differences).toHaveLength(1) + expect(doc?.gist).toBe( + "This bill regulates how students use technology in schools." + ) + expect(doc?.differences).toHaveLength(2) expect(doc?.differences[0].source.organisation).toBe("Example Gazette") + expect(doc?.differences[1]).not.toHaveProperty("senate") + expect(doc?.differences[1].notes["device lockout technology"]).toHaveLength(2) expect(doc?.similarities).toHaveLength(1) expect(doc?.passSenateVersionCount).toBe(0) })