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 aff5f4cfc..dad36fc34 100644 --- a/components/conference/ConferenceDetails.test.tsx +++ b/components/conference/ConferenceDetails.test.tsx @@ -195,9 +195,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]) }) @@ -375,9 +415,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() @@ -395,6 +436,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"] ]) @@ -406,8 +448,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") @@ -423,19 +466,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 && ( + + + {t("decided.match")} + {similarities.length} + + + {similarities.map(s => ( + + ))} + + + )} + + {cited.length > 0 && ( + + {t("decided.sources")} + + {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 49a7b2a19..e371c9586 100644 --- a/components/conference/load.ts +++ b/components/conference/load.ts @@ -1,6 +1,7 @@ import { districtId } from "functions/src/districts/normalize" import { currentGeneralCourt } from "functions/src/shared" import type { Branch } from "../../functions/src/common" +import type { ConferenceSource } from "../../functions/src/conferences/types" import { Conference, getConferenceForBill, @@ -21,7 +22,8 @@ import { ConferenceIndexProps, ConferencePageProps, ConferenceSummary, - HearingView + HearingView, + SourceView } from "./types" const summarize = (c: Conference): ConferenceSummary => ({ @@ -35,6 +37,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) => { @@ -147,7 +153,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 c3d06c9ed..eaffc53bc 100644 --- a/public/locales/en/conferences.json +++ b/public/locales/en/conferences.json @@ -78,8 +78,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 feedback0>.", + "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}}0>.", + "yours_both": "Both of your legislators are on this committee, <0>{{a}}0> and <1>{{b}}1>.", + "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) })