You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
SPA: empty states as invitations — icon, one sentence, the primary action #655
Part of the SPA look-and-feel pass (design review 2026-09-02). Sibling slices: numeric typesetting, elevation hierarchy, all-caps labels, table layout, dashboard rework, visual identity.
Problem
Every empty state is a muted sentence: <p className="muted">{t("noFlocksMessage")}</p> (web/src/routes/FlocksPage.tsx, Dashboard.tsx, and the same shape on Customers, Sales, Stock, Inventory, Feed, Water, Expenses, Products, Grades, Users). A first-run farm sees a column of grey one-liners with the action they need sitting in the page header somewhere else. An empty screen should be an invitation to act.
Proposal
One EmptyState component: icon (lucide, already a dependency), one sentence saying what will appear here and why it is empty, and the screen's primary action as a button right there (the same handler the page-head button uses). Variant for "filtered to nothing" (offer "Clear filters") vs "nothing exists yet" (offer "New …"). Role-aware: a ReadOnly user gets the sentence only, no action.
Acceptance
EmptyState used on every list screen's empty branch; no bare <p className="muted"> empty states remain (grep-pinned by a test or a lint).
Filtered-empty vs truly-empty distinguished on the screens that have filters.
Action visible only when the role may perform it (matches the page-head gate).
Copy in the interface's voice — states what to do, no apology, no mood — en / es / tl inline.
Vitest tests for the component and one integration test per variant (frontend tests in the same PR).
Delivered: one EmptyState component — lucide icon, one sentence, and the screen's own primary action as a real button using the same handler as the page-head, not a duplicate. Two variants where a screen has filters. Role-aware by construction: the caller passes action only when the role may perform it, reusing the page-head's existing gate rather than re-deriving the check. Rolled out to 13 sites, pinned by web/src/routes/emptyStates.guard.test.ts, strings in en/es/tl.
The classification was the real work. 43 sites match <p className="muted">{t(...)}</p>; only 13 are empty states. The rest are loading text, static intros, per-row detail, dialog-scoped sub-lists whose action does not match a page-head handler, and ledger panels with no create action. Converting one of those would have been a regression, so the classification was produced and reviewed before any edit. The guard pins the classified list and says so in its comment — pinning every muted paragraph would be wrong.
Three defects found while shipping, all fixed:
Duplicate action buttons. The first pass rendered the empty state's button and the page-head button, same accessible name — 45 existing tests failed on ambiguous getByRole. Fixed by withholding the page-head button exactly when its own empty state already offers that action.
Both variants said the same sentence. The icon and action differed, but the sentence — the part that tells a user why the screen is empty — did not. A farm with zero orders and no filter read "No orders match."; a filtered Flocks view read "No flocks yet." Six screens, both directions of the error. Six new keys across three locales, and the guard now asserts the two branches resolve to different keys — verified failing on all six before the fix.
The status filter was invisible to the empty state.SalesPage tested customerFilter alone while statusFilter was also sent to the API, so a status-only filter showed the unfiltered variant and "Clear filters" left the list empty. All eleven converted screens were then walked for the same shape; no others had it.
Acceptance not fully met, recorded rather than dropped: Feed/Water/History's filtered-vs-empty branches carry lighter per-screen test coverage than Flocks/Stock/Sales — same shape, not independently pinned. ExpensesPage has no filtered variant because its month picker is always set (see #667).
Activity
docs: fix increment for the empty-state variant copy (#655)
fix(web): tell an empty screen apart from a filtered one in words, no…
feat(web): one-line provenance, bounded date filters, and empty state…
mforce commentedon Sep 2, 2026
Shipped in #668, merged as
80b53f4b.Delivered: one
EmptyStatecomponent — lucide icon, one sentence, and the screen's own primary action as a real button using the same handler as the page-head, not a duplicate. Two variants where a screen has filters. Role-aware by construction: the caller passesactiononly when the role may perform it, reusing the page-head's existing gate rather than re-deriving the check. Rolled out to 13 sites, pinned byweb/src/routes/emptyStates.guard.test.ts, strings in en/es/tl.The classification was the real work. 43 sites match
<p className="muted">{t(...)}</p>; only 13 are empty states. The rest are loading text, static intros, per-row detail, dialog-scoped sub-lists whose action does not match a page-head handler, and ledger panels with no create action. Converting one of those would have been a regression, so the classification was produced and reviewed before any edit. The guard pins the classified list and says so in its comment — pinning every muted paragraph would be wrong.Three defects found while shipping, all fixed:
getByRole. Fixed by withholding the page-head button exactly when its own empty state already offers that action.SalesPagetestedcustomerFilteralone whilestatusFilterwas also sent to the API, so a status-only filter showed the unfiltered variant and "Clear filters" left the list empty. All eleven converted screens were then walked for the same shape; no others had it.Acceptance not fully met, recorded rather than dropped: Feed/Water/History's filtered-vs-empty branches carry lighter per-screen test coverage than Flocks/Stock/Sales — same shape, not independently pinned. ExpensesPage has no filtered variant because its month picker is always set (see #667).
feat(web): Expenses and Audit keep a clear-filters control while rows…
chore(main): release 0.1.0 (#542)