Skip to content

Cosign conference input from its card, with cosigns grouped under it - #2319

Open
fastfadingviolets wants to merge 19 commits into
codeforboston:mainfrom
hyphacoop:cosign-ui
Open

fastfadingviolets wants to merge 19 commits into
codeforboston:mainfrom
hyphacoop:cosign-ui

Conversation

@fastfadingviolets

Copy link
Copy Markdown
Contributor

This builds the cosign UI on the data layer in cosign-data: a reader can cosign an individual's or an organization's input on a conference from its card, through the client's two-step panel, and edit their cosign's note afterwards. A cosigned card now shows who stands behind it, in the mockup's band and tally floor, with its cosigns listed under it and the card brought back up the feed when cosigned. Grouping cosigns under their card is a departure from the mockup's flat feed, where each cosign trailed as its own card, and it waits on the client's review. Along the way the server says why it refused a cosign, a legislator's testimony can no longer be cosigned, and the card is sized down on phones as the mockup draws it.

Closes hyphacoop#97
Closes hyphacoop#98

What's in it

Not verified

🤖 Generated with Claude Code

fastfadingviolets and others added 16 commits October 27, 2026 14:20
Draft 1 of the cosign feature requirements: triggers, functional and
non-functional requirements, failure behaviour and what is out of scope.
The UI it describes is the client designer's /cosign-viz mockup, pinned at
k-g-k/maple-conference@19bd101.

Closes #91

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cosign is conference testimony that names the publication it seconds,
by id and author, in two new fields that are both set or both null. It
is published through the same path as other testimony: publish reads
the parent in its transaction, refuses a parent that is gone, the
cosigner's own, itself a cosign, or on another conference, and copies
the parent's position on first publish and keeps it on every edit. A
published cosign stays a cosign of that parent, and plain testimony
stays plain. A cosign's note may be empty, and it takes no attachment;
other testimony still needs content. The Firestore rules already allow
an empty draft, so they are unchanged. Existing testimony has no
reference and reads as before.

Closes #93

Verified: yarn test:integration-ci on the six suites CI runs (83 passed),
including new cosign cases for filing, note edits keeping the copied
position after the parent changes, a double submit leaving one cosign,
and each refusal; functions unit tests from src (128 passed); type
checks (root errors only in untracked local scratch scripts); prettier
check; lint on the changed files.
Not verified: a cosign published against the dev backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
publishedAt moves with every edit, so nothing on a publication said when
it was first filed. Publish now sets firstPublishedAt on a first
publication and keeps it through edits. Editing older testimony that
lacks it leaves it missing rather than stamping the edit time, which
would claim the edit as the first publication. Nothing fills it on older
testimony: the cosign tally is its only reader, and every cosign is new.

Verified: yarn test:integration-ci on the six CI suites, including first
publication kept through an edit and older testimony left without it;
functions type check; root type check; prettier; lint on the changed
files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cosigned testimony now carries how many have cosigned it, how many
distinct Senate and House districts they come from, and when the latest
cosign was first published. A trigger on published testimony recounts
them from the cosigns whenever a write changes what a cosign contributes,
inside a transaction that reads the parent and every cosign of it. Retried
or reordered events therefore land on the same figures, and an edit to a
note writes nothing. A cosigner with no district on file counts toward the
total only. The recount reads cosigns through a new collection-group index
on cosignOfId.

Publish replaces the publication on every version, so it now carries the
tally across when the parent's author edits. It also records the author's
districts, which it never had: it read profile fields that don't exist
rather than the district on the profile's senator and representative.

Closes #94

Verified: yarn test:integration-ci on the six CI suites, with Typesense
running as in CI, including the tally following cosigns published from
shared and distinct districts and with none, a note edit not counting as
a new cosign, an admin deleting the newest cosign, the parent's author
editing, and districts recorded on plain testimony; cosignTally unit
tests and the rest of functions src (132 passed); functions and root
type checks; prettier; lint on the changed files.
Not verified: the trigger or the cosignOfId index on the dev backend, or
under a burst of concurrent cosigns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deleting testimony now deletes its cosigns, each through the same
performDeleteTestimony as any other delete, so each cosign comes off the
conference's totals and its cosigner's draft loses its published version.
The sweep runs after the parent is gone, when publish already refuses new
cosigns, and runs even if the parent was already deleted, so deleting it
again finishes a cascade that stopped partway.

Moderation removal now deletes inside resolveReport through the same
function. It used to resolve the report and then call a Next API route
that deleted the publication without touching the bill or conference
counts. The route came from codeforboston#1046, written while the
deleteTestimony callable was disabled by policy and could only delete
the caller's own testimony. The admin version of the callable merged the
same day, but the moderation page kept calling the route, so moderator
removals have left counts high since. The route, its client
helper and the extra archive copy it wrote are gone, since publish
already archives every version.

The cosign tally trigger reads the parent first and stops when it is
gone, rather than reading every remaining cosign on each delete of a
cascade.

Closes #95

Verified: yarn test:integration-ci on the CI suites with Typesense,
including a parent with two cosigns deleted by an admin and
removed by a moderator (no cosigns left, drafts cleared, conference
totals back to zero), one cosign deleted leaving the parent and the
other, and the existing moderation case now checking the publication is
gone; testimony and moderation suites again after the tally change (72
passed); functions unit tests from src (132 passed); functions type
check; root type check (errors only in untracked local scratch scripts);
prettier check; eslint on the changed files.

Not verified: the cascade or the moderation removal against the dev
backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report list and detail showed an archive id read from
resolution.archiveTestimonyId, a field nothing writes; resolveReport
wrote archivedTestimonyId instead. Since removal no longer writes a
separate archive copy, that value was only the testimony id the report
already carries, so the column, its label and the field are gone.
RemoveTestimony also loses imports and a type it never used, among them
firebase-admin, a server package, in a browser component.

Verified: moderation integration suite (5 passed); root type check
(errors only in untracked local scratch scripts); functions type check;
eslint on the changed files; prettier check.

Not verified: the moderation report pages in a browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The trigger that records a notification event for each new publication
crashed when a publication lacked the court, bill title or author:
court.toString() threw, and add() rejects undefined fields. Each crash
restarts the emulator's function worker. It now skips a publication
whose event would have an undefined field, logging which ones.

On every other write it queried notificationEvents before checking the
version, so each cosign tally update paid a query. It now returns
before the query when the version is unchanged.

The handler is split out from the trigger, as the ballot question
trigger does, so it can be unit tested.

Closes #114

Verified: unit tests cover a complete creation, creations missing
billTitle, court or authorUid, a tally-only update (no database call)
and a version change. The testimony integration suite passes with no
crash from this trigger, writes returning at the version check and one
incomplete fixture skipped.
Not verified: on the dev project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deleting testimony used to delete its cosigns inside the same call, one
transaction after another. If that stopped partway, for instance when a
parent with many cosigns ran the callable past its timeout, the parent was
gone while some cosigns stayed published and still counted on the
conference. Nothing could finish the job: the admin delete needs a
publication to delete, and resolving the report again returned
"testimony-already-removed" before reaching the cosigns, so the report
also stayed open.

Cosigns are now deleted by a trigger on any published testimony's
deletion, retried until it succeeds, like the cosign tally. Each cosign
still goes through performDeleteTestimony. Resolving a removal whose
testimony is already gone now records the resolution instead of
stopping, since the delete is what had already happened.

Closes #95

Verified: yarn test:integration-ci on testimony.test.ts and
moderation.test.ts with Typesense (75 passed), including admin and
moderator deletes of a parent with two cosigns, and a removal resolved
after its delete had already gone through; the emulator log shows the
cascade trigger with no errors. yarn --cwd functions jest src (138
passed), functions tsc, eslint and Prettier on the changed files.

Not verified: a cascade actually interrupted partway and retried (the
test starts from a finished delete); behaviour on dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Version numbers run across every publication a person makes on a bill,
ballot question or conference, including deleted ones. Publish read any
version past 1 as an edit, so a cosign filed after the person's earlier
cosign was deleted with its parent was refused for lacking an edit
reason, and after five such deletions the conference was closed to them.

An edit is now a publish that replaces a current publication: only then
is an edit reason required. The five-edit limit counts the publication's
own archived versions rather than the version number. Archives from
before July 2022 carry no publication id, so a publication that old is
limited only on its later edits.

Verified: yarn test:integration-ci on testimony.test.ts and
moderation.test.ts with Typesense (75 passed), including a cosign refiled
after a cascade publishing as version 2 with no edit reason, then taking
five edits to version 7 and refusing a sixth on the edit limit. yarn
--cwd functions jest src (138 passed), functions tsc, eslint and
Prettier on the changed files.

Not verified: the limit on a publication with pre-July-2022 archives;
the detail page's history and "edited" flag for a refiled publication,
which still show the deleted one's versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cosign panel tells a reader that the input they are cosigning has been
removed, or that the conference has stopped taking input. Both refusals are
failed-precondition, and a gone parent shares not-found with a gone draft,
so the error code cannot tell them apart. fail() now takes details, and
publish sends { refusal: "cosignParentGone" | "conferenceClosed" } with
those two refusals.

The rules for what can be cosigned move to a module with no dependencies,
so the website offers Cosign by the rule that publishing keeps: an
individual's or an organization's testimony, never a legislator's, a cosign,
or one's own. A legislator's testimony was cosignable before. The draft's
cosign shape lives there too, so the website's draft type can share it.

Verified: yarn test:integration-ci on the six suites CI runs (97 pass,
including the new legislator refusal and the gone-parent refusal's
details); yarn --cwd functions jest src (138); functions tsc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The testimony edit hook ignored a watched draft or publication that stopped
existing, so a page kept what another tab, or now a cosign's cascade, had
deleted: the conference panel reopened as an edit of a gone publication,
asking for an edit reason, and saves failed against the deleted draft. A
snapshot that the server, not the local cache, reports gone now goes
through the hook's own delete and discard.

Verified: on the local emulators, deleting the signed-in user's cosign and
draft behind the open conference page, then filing plain input from Share
without an edit reason, which posted.
Not verified: the bill and ballot question pages, which share the hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reader can cosign an individual's or an organization's input on a
conference from its card, or from the card opened whole. The panel fills
the window with the input being signed beside it: the reader says they
have read it, then adds an optional note or says they are adding none, and
lands in Your Input, which names what they cosigned. Editing a posted
cosign changes its note only, with a reason. A signed-out reader goes to
sign in; Cosign is offered only while the reader has no input of their own
here, posted or drafted; and the two refusals the server words are shown
in the panel. The share-with-the-committee choice in the mockup is left
for its own issue, since nothing reads it yet. The letter sits in the well
our bill text uses, lighter than the mockup's grey.

Both forms share one draft hook held by the page, and its autosave, so
switching between them never files two drafts, and a quick post no longer
saves twice. Below lg the panel is now as wide as the window, where a
narrow phone used to cut off the plain form's edge.

Closes #97

Verified: on the local emulators (seedLocal), beside the pinned mockup at
about 1280px and 390px: the card's Cosign, both steps including the
unticked Continue and the empty-note ask, filing with and without a note
(the parent's tally went from 3 to 4), the landing, editing a cosign,
deleting the parent and closing the conference mid-cosign, a signed-out
Cosign opening sign-in, and plain Share, Review and Post unchanged;
npx jest components/conference (new tests pass; load.test.ts fails as it
does on main); tsc, prettier and lint.
Not verified: the edit-cosign view, which the mockup does not draw, by the
designer; widths other than about 1280px and 390px; the dev site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local seed adds four cosigns of its sample testimony, with and without
notes, from cosigners with districts, so the cosign panel and Your Input can
be laid beside the mockup with a parent that already has a count. The
functions emulator's trigger keeps each parent's tally, as it would on dev.
Every seeded testimony now carries firstPublishedAt, as publishing sets it.

Verified: yarn firebase-admin run-script seedLocal --env local on fresh
emulators, after which the Massachusetts Medical Society sample carried
cosignCount 3 from 2 Senate districts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client's mockup brings the card's furniture down with its type: the
portrait to 34px under 767px and 26px under 450px, the account marks to
14px and 13px, and the position chip to 10px with less padding under
450px. Ours kept the desktop sizes at every width. The sizing sits on
the card's own portrait rather than the shared disc, so the cosign
panel's portraits keep theirs.

Verified: computed sizes in a 390px frame beside the mockup at
k-g-k/maple-conference@19bd101: portrait 26px with 10px letters, marks
13px, chip 10px with 6px padding; desktop unchanged at 40px.
Not verified: the 767px tier, between phone and desktop widths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cosigned card now carries the mockup's band ("N constituents have
cosigned this", "including you" with a way to your own input, and the
Cosign button) and its floor (the count, the Senate and House districts
cosigners come from, and the last-cosigned date). Its cosigns are listed
under it, a tier quieter than the card, newest first, three shown and
the rest behind "Show N more cosigns".

This departs from the mockup's flat feed, where each cosign is a card
at its own date and noteless ones trail at the end: a card now ranks at
its latest cosign, so cosigning brings it back up, and its cosigns go
with it. Filters and paging take a card and its cosigns as one unit.
Cosigns without a note are listed only with "Show cosigns without
content" ticked, and counted either way. The local seed gains two more
noted cosigns so a card shows the cap.

Closes #98

Verified: band and floor beside the mockup at
k-g-k/maple-conference@19bd101 by computed style at desktop, and in a
390px frame; on local emulators, cosigning a card as the seeded test
user brought it to the top with "including you", its cosign listed
under it, and View input landed on Your Input; the noteless setting,
the expander and the badge count in the browser; components/conference
jest (the grouped feed's ordering, units, filters, paging, cap, noteless
setting and districts).
Not verified: the client's review of the grouped list, which is our
design; the 767px tier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
maple-dev Ready Ready Preview Oct 28, 2026 6:29pm UTC

Request Review

fastfadingviolets and others added 3 commits October 28, 2026 14:24
At 900px and under, the floor hid its "Last cosigned" date so the card's
foot line could carry it, but only a narrow card has a foot line. A card
600px or wider in such a window (no rail, ~700px) showed the date nowhere.
Entry now hands the floor the card's width, as it does its cosigns, and the
floor hides the date only on a narrow card.

Verified: npx jest components/conference passes apart from load.test.ts,
which fails on main from a stale fixture.
Not verified: the date by eye on a wide card in a narrow window, or at phone
width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed save left the Post or Cosign button disabled for good and a timed
autosave stuck on "Saving". A post pressed while the edit hook was still
loading the draft had its save dropped and waited forever; it now saves
again once the draft settles. A flush made while a write is in flight no
longer starts a second one, so a quick Agree then Cosign can't add two
drafts. Delete Draft comes back when the fields return to what is kept.

The compose form, which stays mounted behind a closed panel, no longer
takes a cosign draft for its own: it does not seed from one, and its draft
check refuses one. Before, it could publish a cosign draft as a cosign, or
once refusing them, autosave a cosign's note as the reader's own input
after every page load.

A cosign posted without words reopens with "no words" only when no note
has been drafted since, so that note is not hidden and wiped. Signing out
clears the page's record of the draft, so a signed-out visitor sees Cosign.

Verified: npx jest components/conference passes apart from load.test.ts
(stale fixture, fails on main); InputForm.test fails without the seeding
fix. On local emulators, a cosign draft with a note survived a reload as a
cosign. yarn check-types clean in the changed files.
Not verified: failed saves and the dropped-save post by hand; sign-out with
a draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reader has one draft on a conference. With a note drafted cosigning one
input, agreeing to cosign another wrote over that note without a word. Cosign
stays on every card, but the agreeing step now says that continuing deletes
the note on the other cosign, naming it when it is still in the feed.

Verified: npx jest components/conference passes apart from load.test.ts
(stale fixture, fails on main), with tests for warning and for staying
quiet over this parent's draft or one without words. On local emulators:
drafted a note cosigning Fight for the Future, reloaded, pressed Cosign on
Massachusetts Medical Society, and the warning named Fight for the Future.
Not verified: the warning at phone width; the wording with the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fastfadingviolets

Copy link
Copy Markdown
Contributor Author

What it looks like:

image

This one has 3 cosigns with notes and one without.

image

Cosigning.

image

Declining to provide a note

This branch was successfully deployed

1 active deployment
Preview – maple-dev — 0e4d811e Deployed Oct 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Show cosigns in Public Input, grouped under what they cosign Cosign from a Public Input card, to the client's mockup

1 participant