Skip to content

refactor(cards): give every registered card and variant its own error boundary - #301

Merged
fx merged 2 commits into
mainfrom
refactor/0041-errorboundary-decision
Jul 30, 2026
Merged

refactor(cards): give every registered card and variant its own error boundary#301
fx merged 2 commits into
mainfrom
refactor/0041-errorboundary-decision

Conversation

@fx

@fx fx commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Summary

Completes PR 1 — ErrorBoundary decision of docs/changes/0041-card-conventions-and-reference.md, which is that change's last open task, so this closes the change: **Status:** complete in the change document, status: complete in docs/index.yml, and the row in docs/index.md.

The decision the change document left open — enforce the per-card boundary convention or retire it — is settled in favour of enforcing, with the reasoning recorded in the change document's Design Decisions and in the entity-cards spec.

The count, verified rather than taken

The change document claims sixteen registered components lack a boundary. That is still accurate: the registry maps 23 domains onto 20 distinct components (the action family's four domains share ActionCard), of which four wrapped — WeatherCard, MediaPlayerCard, PersonCard, VacuumCard — and sixteen did not.

Two holes the per-component count does not show, both closed here. ClimateCard's dial variant had no boundary and is what the loader pins every climate card placed before change 0017 onto, so it is the presentation most climate cards actually render. And WeatherCard counted as wrapping only because each of its four variants does, but those sit below the dispatcher and cannot catch a throw in its option read, its store subscriptions, or the CardConfig.Modal subtree it renders as the variant's sibling. Counting registered components and the variants registered against them, 17 of 27 render targets were unprotected.

Why enforce rather than retire

The paths GridView's EntityErrorBoundary does not cover are concrete, not hypothetical. CardConfig.tsx renders LightCard and BinarySensorCard directly in its configuration preview — two of the sixteen — and all 20 families have a Storybook story. On the preview path the nearest boundary above a throwing card was Dashboard's, so one card's render error blanked the entire dashboard rather than one tile; in a story there was no boundary at all. Retiring would have traded a real safeguard for consistency with the majority that happened to be unprotected.

One helper, not sixteen copies

The established shape is a nine-line rationale comment plus a four-line wrapper function. Repeating it twenty times would be twenty copies of one contract — the duplication REVIEW.md calls this repo's largest defect class — and the copies were already drifting: VacuumCard's cited a count of nine families and an issue this change supersedes. withCardErrorBoundary (src/components/cardErrorBoundary.tsx) holds the rationale once and each card spends one call. The four that already wrapped are converted to it in the same PR, because leaving two shapes for one convention is a smaller version of the state this change exists to end.

The helper wraps in EntityErrorBoundary rather than the base ErrorBoundary. On the dashboard the card's own boundary is now the inner one, so it — not GridView's — decides what a failed tile looks like, and the base fallback is a 600px-wide panel with a 400px minimum height that would overflow a 1×1 cell and cover its neighbours. The card also knows its tier, which GridView's wrapper does not, so EntityErrorBoundary gained an optional tier and the fallback now degrades with the cell. This one was caught by the local codex review pass, not by me.

Tests

src/components/__tests__/cardErrorBoundary.test.tsx builds its table from domainToCard and each entry's variants rather than naming cards, so a card or variant added without a boundary fails on the commit that adds it — the same reasoning as the reference-path check in PR 2, applied to the convention instead of to the citations. Every case renders the card bare, because a boundary asserted underneath another boundary proves nothing about the case this one exists for. It asserts the fallback rendered rather than merely that nothing threw: "did not throw" is also what a card that quietly rendered looks like.

27 containment cases, 27 tile-shape cases (the glance tier has no room for the message or Retry, so a fallback ignoring the tier renders both), the registry-enumeration guard, and 2 display-name cases.

The memo-placement requirement is pinned by the existing cardSpanMemo.test.tsx, whose three cards (LightCard, BinarySensorCard, ButtonCard) are all newly wrapped and whose load-bearing by-value span comparator still behaves identically through the boundary.

Gates

Gate Result
npm test exit 0 — 236 files, 5103 tests passed (exit status read directly, not the summary line)
npm run lint exit 0 — 0 errors, 1 pre-existing MigrationCutoffs warning
npm run typecheck exit 0
npm run test:coverage exit 0 — every changed line in src/ covered
npm run build-storybook exit 0 — the TDZ-cycle risk, since the Storybook entry reaches cards before the registry

Mutation probes

Each probe verified the mutation applied (git diff --quiet), read the result, restored, and re-grepped for content.

Probe Result
LightCard loses its boundary red — 2 failures, both LightCard cases
ClimateCard + dial lose theirs red — 2 failures, the card and the variant
WeatherCard dispatcher loses its own red — 2 failures, both weather cases
InputTextCard loses its boundary red
the fallback stops honouring the tier red — all 27 tile-shape cases
the fallback reverts to the base boundary's dialog red — 54 of 55
display name loses the memo type hop red — the memoized-card case only

Pre-PR review

codex review --base main — 2 findings, both fixed: the 400px fallback overflowing a tile (above), and spec over-constraint, which is why the spec requirement now states observable containment rather than prescribing the wrapper and the memo arrangement. Those live in AGENTS.md and the helper's doc comment instead, per REVIEW.md → Specification Altitude.

Local CodeRabbit (cr --agent --base main --committed) — 1 finding, addressed: innerName's metadata type is now an explicit NameCarrier interface naming the memo exotic shape, which also removed a cast.

Out of scope

GridView's own boundary is untouched, and no flat card file was restructured into a folder — both explicitly out of scope in the change document.

Summary by CodeRabbit

  • New Features

    • Added consistent error isolation for entity cards and their variants.
    • Card rendering failures now show a recoverable error tile identifying the affected entity and respecting the card’s layout tier.
    • Improved error fallback behavior for compact card presentations.
  • Tests

    • Added comprehensive coverage across registered cards and variants to verify error handling and fallback display.

Copilot AI review requested due to automatic review settings July 30, 2026 09:47
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 381173b0-53a0-4749-8bc3-0a56164c91a1

📥 Commits

Reviewing files that changed from the base of the PR and between 9baef35 and a72cbb6.

📒 Files selected for processing (32)
  • AGENTS.md
  • docs/changes/0041-card-conventions-and-reference.md
  • docs/index.md
  • docs/index.yml
  • docs/specs/entity-cards/index.md
  • src/components/ActionCard/index.tsx
  • src/components/AlarmCard/index.tsx
  • src/components/BinarySensorCard/index.tsx
  • src/components/ButtonCard/index.tsx
  • src/components/CameraCard/index.tsx
  • src/components/ClimateCard/index.tsx
  • src/components/CoverCard/index.tsx
  • src/components/ErrorBoundary.tsx
  • src/components/FanCard/index.tsx
  • src/components/InputBooleanCard.tsx
  • src/components/InputDateTimeCard.tsx
  • src/components/InputNumberCard.tsx
  • src/components/InputSelectCard.tsx
  • src/components/InputTextCard.tsx
  • src/components/LightCard/index.tsx
  • src/components/LockCard/index.tsx
  • src/components/MediaPlayerCard/index.tsx
  • src/components/PersonCard/index.tsx
  • src/components/SensorCard/index.tsx
  • src/components/VacuumCard/index.tsx
  • src/components/WeatherCard/WeatherCardDefault.tsx
  • src/components/WeatherCard/WeatherCardDetailed.tsx
  • src/components/WeatherCard/WeatherCardMinimal.tsx
  • src/components/WeatherCard/WeatherCardModern.tsx
  • src/components/WeatherCard/index.tsx
  • src/components/__tests__/cardErrorBoundary.test.tsx
  • src/components/cardErrorBoundary.tsx
📝 Walkthrough

Walkthrough

Registered cards and variants now use withCardErrorBoundary around memoized implementations. EntityErrorBoundary forwards card tiers to fallback rendering, and registry-driven tests verify containment, fallback output, and wrapper naming. Documentation records the completed convention.

Changes

Card error-boundary standardization

Layer / File(s) Summary
Shared boundary helper and tier plumbing
src/components/cardErrorBoundary.tsx, src/components/ErrorBoundary.tsx
Adds the shared wrapper, forwards entityId and tier, derives display names, and renders tier-aware fallbacks.
Registered card export wiring
src/components/{Action,Alarm,BinarySensor,Button,Camera,Climate,Cover,Fan,Light,Lock,Sensor}Card/*
Wraps registered card exports and variants outside memoization while preserving static dimensions and variant metadata.
Specialized and presentation card wiring
src/components/{Input*,MediaPlayer,Person,Vacuum,WeatherCard}/*
Replaces direct or local boundary wrappers with the shared export-level helper.
Registry coverage and documentation
src/components/__tests__/cardErrorBoundary.test.tsx, docs/*, AGENTS.md
Adds registry-driven fallback and display-name tests and records the completed card-boundary convention.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CardRegistry
  participant CardExport
  participant withCardErrorBoundary
  participant EntityErrorBoundary
  participant ErrorDisplay
  CardRegistry->>CardExport: select registered card or variant
  CardExport->>withCardErrorBoundary: render wrapped memoized card
  withCardErrorBoundary->>EntityErrorBoundary: pass entityId and tier
  EntityErrorBoundary->>ErrorDisplay: render recoverable fallback on render error
Loading

Possibly related PRs

  • fx/liebe#220: Provides the tier-aware ErrorDisplay and layout-tier plumbing used by this boundary flow.
  • fx/liebe#246: Refactors the FanCard implementation whose export is wrapped in this change.
  • fx/liebe#252: Adds the ActionCard component family whose export wiring is updated here.

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding per-card and per-variant error boundaries across registered cards.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/0041-errorboundary-decision

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enforces the repo’s “per-card/per-variant error boundary” convention by introducing a shared withCardErrorBoundary helper and applying it across all registered entity cards and variants, with a registry-driven test guard and corresponding spec/change-doc updates (including closing change 0041).

Changes:

  • Add withCardErrorBoundary (wrapping cards in EntityErrorBoundary, outside memo) and apply it to all registry-dispatched cards and variants.
  • Extend EntityErrorBoundary to accept an optional tier and forward it to ErrorDisplay so fallbacks can degrade by layout tier when available.
  • Add a registry-enumerating test to prevent new cards/variants from being added without boundaries; update entity-cards spec + docs indexes and close change 0041.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/components/cardErrorBoundary.tsx New shared helper that wraps cards/variants in EntityErrorBoundary and sets a useful displayName.
src/components/ErrorBoundary.tsx Adds optional tier to EntityErrorBoundary and forwards it to ErrorDisplay.
src/components/tests/cardErrorBoundary.test.tsx New registry-driven guard test asserting every dispatched card/variant contains its own boundary and uses the tile fallback.
src/components/WeatherCard/index.tsx Wraps the dispatcher component itself with withCardErrorBoundary (in addition to per-variant boundaries).
src/components/WeatherCard/WeatherCardDefault.tsx Switches variant export to use withCardErrorBoundary.
src/components/WeatherCard/WeatherCardDetailed.tsx Switches variant export to use withCardErrorBoundary.
src/components/WeatherCard/WeatherCardMinimal.tsx Switches variant export to use withCardErrorBoundary.
src/components/WeatherCard/WeatherCardModern.tsx Switches variant export to use withCardErrorBoundary.
src/components/ClimateCard/index.tsx Wraps both the default climate presentation and the dial variant with withCardErrorBoundary.
src/components/MediaPlayerCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/VacuumCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/PersonCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/SensorCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/LightCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/BinarySensorCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/ButtonCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/ActionCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/AlarmCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/FanCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/CoverCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/LockCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/CameraCard/index.tsx Switches card export to use withCardErrorBoundary.
src/components/InputBooleanCard.tsx Switches card export to use withCardErrorBoundary.
src/components/InputNumberCard.tsx Refactors export to wrap the memoized component via withCardErrorBoundary.
src/components/InputSelectCard.tsx Refactors export to wrap the memoized component via withCardErrorBoundary.
src/components/InputTextCard.tsx Refactors export to wrap the memoized component via withCardErrorBoundary.
src/components/InputDateTimeCard.tsx Refactors export to wrap the memoized component via withCardErrorBoundary.
docs/specs/entity-cards/index.md Records the containment contract and documents the two-boundary model (renderer + per-card).
docs/changes/0041-card-conventions-and-reference.md Marks PR 1 task complete and closes the change document (status complete).
docs/index.yml Flips change 0041 status to complete.
docs/index.md Flips change 0041 row to complete.
AGENTS.md Updates the “Entity Card Registration” instructions to require withCardErrorBoundary outside memo.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/components/ErrorBoundary.tsx Outdated
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@2b8ead5). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #301   +/-   ##
=======================================
  Coverage        ?   87.29%           
=======================================
  Files           ?      226           
  Lines           ?     8235           
  Branches        ?     2789           
=======================================
  Hits            ?     7189           
  Misses          ?      823           
  Partials        ?      223           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/__tests__/cardErrorBoundary.test.tsx`:
- Around line 125-137: The test case around the glance-tier Card recovery path
should verify the intended retry behavior rather than only confirming the inline
button is hidden. If glance tiles support recovery, open the fallback tile,
assert the modal exposes Retry, and verify activating it triggers the boundary
reset; otherwise narrow the assertion to the documented read-only behavior.
- Around line 146-164: Add a test alongside the existing withCardErrorBoundary
displayName cases that rerenders a wrapped memoized card with changed props and
verifies the inner card updates. Ensure the assertion distinguishes the boundary
being outside memo, while preserving the existing naming coverage.

In `@src/components/cardErrorBoundary.tsx`:
- Around line 77-90: Update withCardErrorBoundary in
src/components/cardErrorBoundary.tsx (lines 77-90) to accept a fallback/default
tier and pass props.tier ?? defaultTier to EntityErrorBoundary; update the
ActionCard usage in src/components/ActionCard/index.tsx (lines 351-353) to
provide glance as that default tier.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b6d1e671-f37e-4b7a-9b36-68b797c46fec

📥 Commits

Reviewing files that changed from the base of the PR and between dfd7dc1 and 9baef35.

📒 Files selected for processing (32)
  • AGENTS.md
  • docs/changes/0041-card-conventions-and-reference.md
  • docs/index.md
  • docs/index.yml
  • docs/specs/entity-cards/index.md
  • src/components/ActionCard/index.tsx
  • src/components/AlarmCard/index.tsx
  • src/components/BinarySensorCard/index.tsx
  • src/components/ButtonCard/index.tsx
  • src/components/CameraCard/index.tsx
  • src/components/ClimateCard/index.tsx
  • src/components/CoverCard/index.tsx
  • src/components/ErrorBoundary.tsx
  • src/components/FanCard/index.tsx
  • src/components/InputBooleanCard.tsx
  • src/components/InputDateTimeCard.tsx
  • src/components/InputNumberCard.tsx
  • src/components/InputSelectCard.tsx
  • src/components/InputTextCard.tsx
  • src/components/LightCard/index.tsx
  • src/components/LockCard/index.tsx
  • src/components/MediaPlayerCard/index.tsx
  • src/components/PersonCard/index.tsx
  • src/components/SensorCard/index.tsx
  • src/components/VacuumCard/index.tsx
  • src/components/WeatherCard/WeatherCardDefault.tsx
  • src/components/WeatherCard/WeatherCardDetailed.tsx
  • src/components/WeatherCard/WeatherCardMinimal.tsx
  • src/components/WeatherCard/WeatherCardModern.tsx
  • src/components/WeatherCard/index.tsx
  • src/components/__tests__/cardErrorBoundary.test.tsx
  • src/components/cardErrorBoundary.tsx

Comment thread src/components/__tests__/cardErrorBoundary.test.tsx
Comment thread src/components/__tests__/cardErrorBoundary.test.tsx
Comment thread src/components/cardErrorBoundary.tsx
@fx
fx force-pushed the refactor/0041-errorboundary-decision branch from 9baef35 to a2ebbe9 Compare July 30, 2026 10:01
@fx
fx requested a review from Copilot July 30, 2026 10:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.

… boundary

Settles the enforce-or-retire question the change document leaves open, in
favour of enforcing. Verified against the tree first: the registry maps 23
domains onto 20 distinct components — the action family's four domains share
one card — of which four wrapped and sixteen did not, the count the change
document claims and still accurate.

Enforcing rather than retiring, because the paths GridView does not cover
are concrete. CardConfig.tsx renders LightCard and BinarySensorCard
directly in its preview, both of them among the sixteen, and every family
has a story. On the preview path the nearest boundary above a throwing card
was the Dashboard's, so one card's render error blanked the whole dashboard
rather than one tile; in a story there was none at all.

The established shape is a nine-line rationale comment plus a four-line
wrapper, so repeating it twenty times would have been twenty copies of one
contract — and the copies were already drifting, VacuumCard's citing a
count of nine families. withCardErrorBoundary holds the rationale once and
each card spends one call; the four that already wrapped are converted to
it so one convention has one shape.

The helper wraps in EntityErrorBoundary rather than the base ErrorBoundary.
On the dashboard the card's own boundary is now the inner one, so it — not
GridView's — decides what a failed tile looks like, and the base fallback is
a 600px-wide panel with a 400px floor that would overflow a one-cell tile
and cover its neighbours. The card knows its tier, which GridView's wrapper
does not, so the fallback can also degrade with the cell.

Two holes the per-component count did not show. ClimateCard's dial variant
had no boundary and is what the loader pins every climate card placed
before change 0017 onto. WeatherCard counted as wrapping because each of
its four variants does, but those sit below the dispatcher and cannot catch
a throw in its option read, its store subscriptions or the CardConfig.Modal
subtree it renders as the variant's sibling.

The test builds its table from the registry and each entry's variants
rather than naming cards, so a card or variant added without a boundary
fails on the commit that adds it. It asserts the fallback rendered, not
merely that nothing threw: "did not throw" is also what a card that quietly
rendered looks like.

Completes PR 1 of docs/changes/0041-card-conventions-and-reference.md and
closes the change.
@fx
fx force-pushed the refactor/0041-errorboundary-decision branch from a2ebbe9 to 7b3d5e1 Compare July 30, 2026 10:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

docs/specs/entity-cards/index.md:425

  • In this spec line, “GridView's wrapper does not know” is inaccurate: GridView computes tier for each item, it just doesn’t pass it into the outer EntityErrorBoundary. Rewording avoids implying GridView can’t access the tier value.
Two boundaries stand over a card, at different altitudes, and both render the same thing. `EntityErrorBoundary` (`ErrorBoundary.tsx`) is what `GridView` wraps each tile in, rendering an `ErrorDisplay` card with a retry that resets it. Beneath that, `withCardErrorBoundary` (`cardErrorBoundary.tsx`) puts one inside each card, outside the card's `memo` — the only boundary on the paths `GridView` does not own, and on the dashboard the inner of the two, which is why it renders the tile-shaped fallback rather than the base `ErrorBoundary`'s dialog. It also passes the `tier`, which `GridView`'s wrapper does not know. The base `ErrorBoundary` underneath both supports a custom fallback and collapsible stack details.

@fx
fx merged commit 3f0f723 into main Jul 30, 2026
8 checks passed
@fx
fx deleted the refactor/0041-errorboundary-decision branch July 30, 2026 10:30
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.

2 participants