Skip to content

SPA: empty states as invitations — icon, one sentence, the primary action #655

Closed

Description

@mforce

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).
  • tools/simulation/ui/ specs that assert on the old empty-state text are updated (read the callers, Daily entry: require grading to reconcile sellable eggs before submit #394).

Siblings: #650 #651 #652 #653 #654 #656 #657

Activity

  1. mforce commented on Sep 2, 2026

    @mforce
    OwnerAuthor

    Shipped in #668, merged as 80b53f4b.

    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:

    1. 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.
    2. 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.
    3. 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions