feat: add committee charter card - #2218
Conversation
Jira: LFXV2-2659 Link: https://linuxfoundation.atlassian.net/browse/LFXV2-2659 Signed-off-by: Uros <umarolt@contractor.linuxfoundation.org> Signed-off-by: Uroš Marolt <uros@marolt.me>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Essentials Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
PR SummaryMedium Risk Overview Editors get Add/Edit charter via a new Shared types gain Reviewed by Cursor Bugbot for commit f219f2e. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Pull request overview
Adds committee charter viewing, audit metadata, and URL editing to the About tab, dependent on committee-service PR #203.
Changes:
- Adds shared charter read/write types.
- Adds charter display states and editing dialog.
- Wires charter updates and rendering tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Summary / Findings |
|---|---|
packages/shared/src/interfaces/committee.interface.ts |
Defines charter data contracts. |
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.ts |
Adds charter editing output and tooltip support. |
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.spec.ts |
Tests charter states; nit (1 vote): removal tooltip content is not asserted. |
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.html |
Renders charter states; moderate (2 votes): removal details are inaccessible to keyboard users. Nit (1 vote): repeated signal reads use prohibited non-null assertions. |
apps/lfx-one/src/app/modules/committees/components/charter-dialog/charter-dialog.component.ts |
Implements the URL form; moderate (2 votes): validation does not match the API URL and length contract. |
apps/lfx-one/src/app/modules/committees/components/charter-dialog/charter-dialog.component.html |
Provides charter editing UI; moderate (2 votes): URL input lacks an accessible label. |
apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.ts |
Opens the dialog and persists updates. |
apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.html |
Connects the charter edit event. |
Suppressed comments (2)
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.html:214
- The template re-reads
committee().charterwith non-null assertions after the condition. This violates the repository's explicit ban on template non-null assertions (docs/reviews/frontend-checklist.md:349-354) and makes the branch depend on repeated signal reads remaining identical. Alias the URL in the@ifand render that value directly.
@if (committee().charter?.url) {
<div class="flex-1 flex flex-col gap-1 min-w-0">
<a
[href]="committee().charter!.url"
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.spec.ts:77
- The test named as covering the removal tooltip only checks underline classes and never asserts the tooltip text, remover, or date. A regression that drops or misattributes the audit detail would still pass; exercise the tooltip directive and assert
Removed by Alice Example on Aug 1, 2026.
const el = emptyState();
expect(el).not.toBeNull();
expect(el!.textContent).toContain('No charter yet');
expect(el!.className).toContain('underline');
expect(el!.className).toContain('decoration-dotted');
expect(link()).toBeNull();
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Mirror backend charterURLPattern/MaxLength(2048) exactly in the URL validator (accepts bare-host URLs, rejects whitespace/over-length) - Add accessible label for the charter URL input - Make the "removed charter" tooltip keyboard-reachable - Guard against writing a no-op removal when a charter was never set Signed-off-by: Uroš Marolt <uros@marolt.me>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 237d16c. Configure here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
packages/shared/src/interfaces/committee.interface.ts:512
updated_bydoes not have theCommitteeUsercontract. The linked upstream PR defines this publicly visible audit user with optionalavatar,name, andusernameonly, deliberately omittingemail;CommitteeUserrequiresemail,name, andusername. This makes the shared read model and its fixtures claim data the API cannot return. Introduce a matching public-audit-user interface and update the charter fixtures to omit email.
A static id= on lfx-input-text lands on both the custom-element host and the native input it renders, so label[for] resolved to the non-labelable host instead of the input. Bind it with [id] instead, matching the pattern already used in committee-settings.component.html. Signed-off-by: Uroš Marolt <uros@marolt.me>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
packages/shared/src/interfaces/committee.interface.ts:512
- This does not mirror the dependency's response contract: committee-service PR #203 returns an email-less
PublicAuditUserwhoseavatar,name, andusernamefields are optional, whileCommitteeUserrequiresemail,name, andusername. The current type therefore promises PII and required fields that public committee responses deliberately never provide, and the tests already mask the mismatch by inventing an email. Model the public audit shape withoutemail.
apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.ts:565
- The new write path is not exercised by the added component tests: they instantiate only
CommitteeAboutComponentandCharterDialogComponent, while noCommitteeViewComponentunit spec exists. This leaves the{ charter: { url } }payload, the never-set no-op guard, and the post-save refresh unwatched. Add an About-tab E2E that saves and clears a charter, asserts the PUT body/refreshed card state, and verifies an unchanged empty value sends no PUT.
if (url === (committee.charter?.url ?? '')) {
return;
}
this.committeeService.updateCommittee(committee.uid, { charter: { url } }).subscribe({
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.html:214
- The new template re-reads
committee()and uses non-null assertions even though this repository explicitly disallows!in templates (docs/reviews/frontend-checklist.md:349-353). Capture the truthy URL in the@ifalias and bind that value; this also keeps the guard and the rendered value from coming from separate signal reads.
[href]="committee().charter!.url"
The active-charter "Last updated by" span omitted the date entirely, while the sibling removed-charter tooltip correctly rendered it. Signed-off-by: Uroš Marolt <uros@marolt.me>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
packages/shared/src/interfaces/committee.interface.ts:512
- The linked committee-service contract returns
updated_byasPublicAuditUser: it intentionally omitsemail, andavatar,name, andusernameare all optional. Typing it asCommitteeUsertherefore misrepresents the actual public GET payload and forces consumers/tests to invent an email that will never be returned. Model the public audit shape so callers cannot assume those fields exist.
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.html:214
- This block adds template non-null assertions here and again on lines 219 and 224, contrary to the repository rule in
docs/reviews/frontend-checklist.md:349-353. Capturecommittee().charteronce with@if (...; as charter), then branch oncharter.urland readcharter.url/charter.updated_atwithout assertions.
[href]="committee().charter!.url"
The charter card and dialog each have isolated unit-test coverage, but nothing exercised editCharterRequested -> openEditCharter -> saveCharter end to end -- the PUT payload, the post-save refresh, and the no-op guard on an unchanged value were all unverified. Add two About-tab e2e specs mirroring org-profile.spec.ts's stubbed-PUT pattern: one adds a charter and asserts the PUT body, refetch, and rendered attribution; the other dirties the dialog without changing the value and asserts the no-op guard holds (no PUT, no toast). Signed-off-by: Uroš Marolt <uros@marolt.me>
yarn format:check was failing on this file after the prior commit -- Prettier collapses the two-line template-string span back onto one line. Prettier --write output, no behavior change. Signed-off-by: Uroš Marolt <uros@marolt.me>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.html:214
- The new link re-reads the signal with non-null assertions here and in its text binding.
docs/reviews/frontend-checklist.md:349-353explicitly disallows!in changed templates because a prior condition does not narrow a later signal invocation. Use optional access (or alias the charter in the@if) instead.
<a
[href]="committee().charter!.url"
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.spec.ts:76
- This test is named as covering the removal tooltip, but it only checks underline classes; the remover/date text could disappear or regress while the test remains green. Assert the accessible tooltip text so the key set-then-removed behavior is actually pinned.
const el = emptyState();
expect(el).not.toBeNull();
expect(el!.textContent).toContain('No charter yet');
expect(el!.className).toContain('underline');
expect(el!.className).toContain('decoration-dotted');
yarn format:check runs from repo root (broader line-width/wrapping than the app-scoped prettier invocation I checked against locally), and flagged the two long object literals in the new charter specs. Prettier --write output, no behavior change. Signed-off-by: Uroš Marolt <uros@marolt.me>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
apps/lfx-one/e2e/committee-about-tab.spec.ts:197
- The empty-string clear path remains uncovered: the two integration cases exercise adding a URL and saving an unchanged URL, but neither clears an existing charter and verifies the
{ charter: { url: '' } }PUT plus the refreshed removed state. Because clearing is a distinct write semantic central to this feature, add a case that performs that flow end to end.
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.html:214
- The new branch introduces template non-null assertions here and again for the URL text/date, contrary to the repository rule in
docs/reviews/frontend-checklist.md:349-353. Aliascommittee().charterwith@if (...; as charter)and then branch oncharter.url, so all three reads use the narrowed alias without!.
[href]="committee().charter!.url"
packages/shared/src/interfaces/committee.interface.ts:512
- The upstream charter contract uses an email-less
PublicAuditUser:avatar,name, andusernameare all optional, andemailis never returned. ReusingCommitteeUserhere promises required fields that public responses do not contain, while the new fixtures mask the mismatch by adding email; it also leaves the attribution text blank when the optional object has no name/username. Please introduce the matching dedicated type, remove email from the fixtures, and retain a display fallback.
updated_by?: CommitteeUser;
The charter's updated_by field reused CommitteeUser (email/username/name all required) for a value returned from the anonymous-accessible committee GET endpoint. Upstream models this as a distinct public-audit-user type with no email and every field optional. Add PublicAuditUser and retype CommitteeCharter.updated_by to it, add a "someone" fallback to the active-charter attribution line (already used on the removed-charter tooltip), and drop the invented email field from the affected unit/e2e fixtures. Signed-off-by: Uroš Marolt <uros@marolt.me>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
apps/lfx-one/src/app/modules/committees/components/charter-dialog/charter-dialog.component.spec.ts:90
- This test establishes that blank input is valid but never verifies the removal result. A regression in
save()could close withundefinedor another value while this test still passes, breaking the{ charter: { url: '' } }clear contract. Click Save and assert that the dialog closes with the empty string.
apps/lfx-one/src/app/modules/committees/components/charter-dialog/charter-dialog.component.ts:30 Validators.maxLengthcounts UTF-16 code units, but the upstream Goa validator enforces this limit withutf8.RuneCountInString. Consequently, a contract-valid URL containing non-BMP path characters can be rejected here (for example, 1,020 emoji produce 1,040 upstream runes but 2,060 JavaScript code units), so this does not mirror the API contract exactly. Use a code-point-aware length validator and retain themaxlengtherror key used by the template.
apps/lfx-one/src/app/modules/committees/components/committee-about/committee-about.component.html:214- The charter URL is writer-controlled, and the upstream contract accepts any HTTP(S) host, but this binds the raw value directly to
href. That bypasses the repository's user-provided-link guard (ValidExternalUrlPipe/normalizeToUrl; seeapps/lfx-one/src/app/shared/pipes/valid-external-url.pipe.ts:8-13), allowing localhost/private-network links to be published to viewers. Gate the value throughvalidExternalUrland render a non-link fallback when it is rejected.
The @if guard on updated_by hid the whole "Last updated by ... on ..." row whenever updated_by was missing, even though updated_at is always present and the upstream contract explicitly permits no principal to be recorded (writer tests produce this state directly). Render the row unconditionally off the active-charter branch and fall through the existing name/username/"someone" chain with optional chaining. Adds a regression test covering the updated_by-absent case. Signed-off-by: Uroš Marolt <uros@marolt.me>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
apps/lfx-one/src/app/modules/committees/components/charter-dialog/charter-dialog.component.html:19
- When this validator disables Save, the error is only visual: it is neither a live region nor associated with the wrapped input, so screen-reader users receive no validation feedback. Mark the dynamically inserted message as an alert (the established pattern in the committee forms) so it is announced when the URL becomes invalid.
audigregorie
left a comment
There was a problem hiding this comment.
Code Review Summary
Well-constructed, well-tested feature — I verified the cross-repo contract claims against lfx-v2-committee-service#203 directly and they hold (URL pattern ^$|^https?://[^\s/$.?#][^\s]*$, MaxLength 2048, write shape { url }, public-audit-user field parity, and the writer's same-URL no-op carry-forward, which is what makes the BFF's full-replace mergedData echo of the read-shape charter object safe on unrelated updates). Findings: 1 Critical (template ! assertions), 1 Major (rel missing noreferrer), 2 Minor.
Outside the diff
- Deploy-order hold is load-bearing. lfx-v2-committee-service#203 is still OPEN. Against the current upstream, a charter save returns 200, shows the "Charter updated" toast, and silently persists nothing (unknown field ignored on decode). The PR body already flags this — reinforcing that this must not merge/deploy before the backend lands.
- Branch is 3 commits behind
main; no overlapping files, so a rebase should be trivial.
What's done well
- The removed-state tooltip is a textbook fix for the
tooltip-on-non-focusable-hostKB pattern (tabindex="0"+role="note"+aria-label+tooltipEvent="both"). - The
[id]-bound-vs-static fix onlfx-input-text(duplicate id on host + native input breakinglabel[for]) with a regression test asserting id uniqueness — a genuinely subtle a11y catch. - The never-set no-op guard in
saveCharter, plus an e2e proving dirty-but-unchanged doesn't PUT; validator parity with upstream pinned by dedicated specs.
| @if (committee().charter?.url) { | ||
| <div class="flex-1 flex flex-col gap-1 min-w-0"> | ||
| <a | ||
| [href]="committee().charter!.url" | ||
| target="_blank" | ||
| rel="noopener" | ||
| class="text-sm text-blue-600 hover:text-blue-800 break-all" | ||
| data-testid="committee-about-charter-link"> | ||
| {{ committee().charter!.url }} | ||
| </a> | ||
| <span class="text-xs text-gray-400" data-testid="committee-about-charter-updated-by" | ||
| >Last updated by {{ committee().charter?.updated_by?.name || committee().charter?.updated_by?.username || 'someone' }} on | ||
| {{ committee().charter!.updated_at | date: 'MMM d, y' }}</span | ||
| > | ||
| </div> | ||
| } @else if (committee().charter; as charter) { | ||
| <span | ||
| class="text-gray-400 text-sm italic flex-1 underline decoration-dotted" | ||
| [pTooltip]=" | ||
| 'Removed by ' + (charter.updated_by?.name || charter.updated_by?.username || 'someone') + ' on ' + (charter.updated_at | date: 'MMM d, y') | ||
| " | ||
| tooltipPosition="top" | ||
| tooltipEvent="both" | ||
| tabindex="0" | ||
| role="note" | ||
| [attr.aria-label]=" | ||
| 'Removed by ' + (charter.updated_by?.name || charter.updated_by?.username || 'someone') + ' on ' + (charter.updated_at | date: 'MMM d, y') | ||
| " | ||
| data-testid="committee-about-charter-empty" | ||
| >No charter yet</span | ||
| > | ||
| } @else { | ||
| <span class="text-gray-400 text-sm italic flex-1" data-testid="committee-about-charter-empty">No charter yet</span> | ||
| } | ||
| @if (canEdit()) { |
There was a problem hiding this comment.
Critical — Non-null assertions (charter!) in the template (3×)
Per docs/reviews/frontend-checklist.md §14.5 (CRITICAL for ! in templates) and KB typescript-correctness/non-null-assertion-on-async-result: [href]="committee().charter!.url", {{ committee().charter!.url }}, and {{ committee().charter!.updated_at | date }} rely on the enclosing @if for safety. It can't crash as-is, but the checklist's prescribed fix is @if (x; as y) aliasing — which the very next branch (@else if (committee().charter; as charter)) already demonstrates. Restructuring to alias charter at the outer level also collapses the repeated committee().charter?. chains in the attribution line:
| @if (committee().charter?.url) { | |
| <div class="flex-1 flex flex-col gap-1 min-w-0"> | |
| <a | |
| [href]="committee().charter!.url" | |
| target="_blank" | |
| rel="noopener" | |
| class="text-sm text-blue-600 hover:text-blue-800 break-all" | |
| data-testid="committee-about-charter-link"> | |
| {{ committee().charter!.url }} | |
| </a> | |
| <span class="text-xs text-gray-400" data-testid="committee-about-charter-updated-by" | |
| >Last updated by {{ committee().charter?.updated_by?.name || committee().charter?.updated_by?.username || 'someone' }} on | |
| {{ committee().charter!.updated_at | date: 'MMM d, y' }}</span | |
| > | |
| </div> | |
| } @else if (committee().charter; as charter) { | |
| <span | |
| class="text-gray-400 text-sm italic flex-1 underline decoration-dotted" | |
| [pTooltip]=" | |
| 'Removed by ' + (charter.updated_by?.name || charter.updated_by?.username || 'someone') + ' on ' + (charter.updated_at | date: 'MMM d, y') | |
| " | |
| tooltipPosition="top" | |
| tooltipEvent="both" | |
| tabindex="0" | |
| role="note" | |
| [attr.aria-label]=" | |
| 'Removed by ' + (charter.updated_by?.name || charter.updated_by?.username || 'someone') + ' on ' + (charter.updated_at | date: 'MMM d, y') | |
| " | |
| data-testid="committee-about-charter-empty" | |
| >No charter yet</span | |
| > | |
| } @else { | |
| <span class="text-gray-400 text-sm italic flex-1" data-testid="committee-about-charter-empty">No charter yet</span> | |
| } | |
| @if (canEdit()) { | |
| @if (committee().charter; as charter) { | |
| @if (charter.url) { | |
| <div class="flex-1 flex flex-col gap-1 min-w-0"> | |
| <a | |
| [href]="charter.url" | |
| target="_blank" | |
| rel="noopener" | |
| class="text-sm text-blue-600 hover:text-blue-800 break-all" | |
| data-testid="committee-about-charter-link"> | |
| {{ charter.url }} | |
| </a> | |
| <span class="text-xs text-gray-400" data-testid="committee-about-charter-updated-by" | |
| >Last updated by {{ charter.updated_by?.name || charter.updated_by?.username || 'someone' }} on | |
| {{ charter.updated_at | date: 'MMM d, y' }}</span | |
| > | |
| </div> | |
| } @else { | |
| <span | |
| class="text-gray-400 text-sm italic flex-1 underline decoration-dotted" | |
| [pTooltip]=" | |
| 'Removed by ' + (charter.updated_by?.name || charter.updated_by?.username || 'someone') + ' on ' + (charter.updated_at | date: 'MMM d, y') | |
| " | |
| tooltipPosition="top" | |
| tooltipEvent="both" | |
| tabindex="0" | |
| role="note" | |
| [attr.aria-label]=" | |
| 'Removed by ' + (charter.updated_by?.name || charter.updated_by?.username || 'someone') + ' on ' + (charter.updated_at | date: 'MMM d, y') | |
| " | |
| data-testid="committee-about-charter-empty" | |
| >No charter yet</span | |
| > | |
| } | |
| } @else { | |
| <span class="text-gray-400 text-sm italic flex-1" data-testid="committee-about-charter-empty">No charter yet</span> | |
| } |
Semantics are identical (set / set-then-removed / never-set), and the removed-state branch keeps working because the outer charter alias is in scope in the inner @else. Heads-up: this range overlaps the separate one-line rel suggestion on line 216 — if you apply both, fold the noopener noreferrer change into whichever you apply second.
| <a | ||
| [href]="committee().charter!.url" | ||
| target="_blank" | ||
| rel="noopener" |
There was a problem hiding this comment.
Major — target="_blank" link with rel="noopener" but no noreferrer
KB security/window-open-no-noopener flags <a target="_blank"> without the full rel="noopener noreferrer" pair, and the repo convention is ~110 uses of the full pair vs 3 bare noopener. noopener alone does close the reverse-tabnabbing vector (so this isn't the KB's Critical severity in effect), but without noreferrer every click leaks the full committee page URL — project/committee slug included — to the external charter host via the Referer header.
| rel="noopener" | |
| rel="noopener noreferrer" |
(Overlaps the larger restructure suggestion on lines 211–245 — apply one, then fold the other's delta in by hand.)
| } @else if (committee().charter; as charter) { | ||
| <span | ||
| class="text-gray-400 text-sm italic flex-1 underline decoration-dotted" | ||
| [pTooltip]=" |
There was a problem hiding this comment.
Minor — the "Removed by … on …" attribution string is built twice
The same interpolated string is duplicated in pTooltip (here) and attr.aria-label (line 236) — a future copy tweak to one silently desynchronizes the sighted tooltip from the screen-reader announcement. Consider a single computed (e.g. charterRemovedLabel) that both attributes bind to, so the strings can't drift.
| // treats an empty value as valid regardless of the pattern, so clearing the field (the | ||
| // charter-removal signal) is never blocked by this validator. | ||
| public charterForm = new FormGroup({ | ||
| url: new FormControl(this.url, [Validators.pattern(/^https?:\/\/[^\s/$.?#][^\s]*$/), Validators.maxLength(2048)]), |
There was a problem hiding this comment.
Minor — contract-bound URL pattern + maxLength inline in the component
This regex and the 2048 limit deliberately mirror upstream charterURLPattern/MaxLength(2048) — contract-bound validation patterns in this repo live in @lfx-one/shared/constants (SLACK_INCOMING_WEBHOOK_URL_PATTERN in committees.constants.ts, LINKEDIN_PROFILE_PATTERN in validation.constants.ts). Consider extracting CHARTER_URL_PATTERN / CHARTER_URL_MAX_LENGTH there so the upstream contract has one canonical home. (Counter-precedent exists — the website pattern is inline in committee-manage.component.ts — so this is a judgment call, but the contract-mirroring comment here argues for the stronger precedent.)

Summary
CharterDialogComponent(single URL field, reusing the samewebsite-field URL pattern and empty-string-is-valid semantics as removal).charter?: CommitteeCharterto the sharedCommitteeinterface and write-onlycharter?: { url: string }toCommitteeUpdateData.committee.service.ts'supdateCommittee) needs no changes —charterrides through the existing full-replace...committeeDataspread untouched, and the backend's write-only payload type only ever readsurl, silently ignoring any extra fields.Linked ticket: LFXV2-2659
Depends on: linuxfoundation/lfx-v2-committee-service#203 — the backend PR that adds the
charterfield to the committee resource. This PR readscommittee.charter, which doesn't exist until that one is merged and deployed; please don't merge/deploy this before that lands.Test plan
yarn format:check/yarn lint:checkcleanyarn test— 96 files, 1924 tests passing, including a new spec covering the three charter card states (never set / set-then-removed / set) and edit-button visibilityyarn buildclean