Skip to content

Catalogue Browse (V2) — /catalogue

The stringer-facing catalogue surface — browse, search, filter, edit, and submit-for-moderation. Owned by Mira. Cross-cuts: V2 scope M6 + M7 + M17, data-model § Racket / String / CatalogueSubmission, add-stringjob § racket + string pickers, admin-catalogue-moderation (the M17 admin side this surface feeds), client-management-v2 (per-client racket history lives there, NOT here), settings-v2 (stringer-default settings, not catalogue), v3-vision § auto-update.

Source requirement

  • V2 stringer catalogue browse epic — racket-book#147 — V2 today: catalogue is reachable only via the M9 add-stringjob picker. No way to browse / curate / submit-for-moderation outside an active job. Mira authors the spec; Pax + Juno implement in a later round.
  • M6 + M7 — Single Racket entity + Strings catalogue. The schema (app/db/models/catalogue.py) has shipped; the stringer-side browse surface is the missing piece.
  • M17 — Admin-moderated promotion via request-queue. The producer side (stringer submits a CatalogueSubmission) lives on this page; the consumer side (admin-catalogue-moderation) shipped Round 5.
  • app/db/models/catalogue.py — three-tier visibility (catalogue_private, catalogue_shared, one_off); Racket.owner_client_profile_id, Racket.created_by_stringer_id, String.created_by_stringer_id. The visibility predicate in the chokepoint composer (app.db.tenancy._racket_predicate / _string_predicate) is the cross-tenant read gate.
  • add-stringjob § new-racket / new-string mini-flows + issue #144 — Pax-A is wiring the inline-from-add-stringjob create endpoints (/orders/new/_new-racket / _new-string). This page reuses the same field shapes; the create modal here is the same form.
  • Sibling V2 schema-hook — racket-book#145 — adds Racket.source + String.source enum (manual | imported) in V2 with no UI; this spec reserves a future-filter slot for it.
  • V3 epic — racket-book#146 — auto-update from external feeds. The imported source rows would land via that future surface; this spec must not preclude an "auto-update status" / "last imported at" UI extension.

Goal

Three commitments, in tension-resolution order:

  1. The browse surface is the curation home. The add-stringjob picker is transactional (find one item to attach to one order); the browse surface is curatorial (skim my inventory, edit a typo, submit a private string for promotion). Stefan asked for both — this page is the curatorial half. It is not a replacement for the picker.
  2. Visibility is the load-bearing axis. The three-tier model (catalogue_private / catalogue_shared / one_off) is invisible to a stringer using add-stringjob — they just see "rackets I can pick." On the browse surface, visibility is first-class: the filter chip controls the view, the per-row chip declares the tier, and the submit-for-moderation flow is the explicit transition from private → shared. Hiding visibility here would make the moderation flow incoherent.
  3. Forward-compat without forward-clutter. source (#145) and auto-update (#146) get reserved structural slots — a filter-chip-row that admits a future "Source" segment, a row-card that admits a future "imported" badge — but no V2 UI surfaces them. The spec calls out exactly where they land in V3 so Pax + Juno don't re-architect.

Surface decision — tabs vs split routes vs unified

The epic explicitly defers this to Mira. Three options were on the table:

Option What it looks like Pros Cons
A. Unified single list /catalogue — one mixed list with rackets + strings interleaved; type chip per row. One URL; one search box. Mixed-shape rows (racket has head_size + pattern; string has gauge) make per-row layout messy; sort axes diverge ("head size" doesn't apply to strings); search relevance gets weird ("Pure Aero" should rank above "RPM Pure" only if we know we're searching rackets).
B. Tabs at one route /catalogue?kind=rackets / ?kind=strings — single page, two tab-pills, JS-toggled list. One landing surface (/catalogue keeps semantic primacy); cheap to build. Tab state lives in the URL anyway (deep-linking + back-button correctness); without JS, tabs are just two links — same as option C.
C. Split routes /catalogue/rackets + /catalogue/strings; /catalogue 302s to one of them (or shows a chooser landing). Clean URLs; clean per-route templates; clean per-route routing logic; mirrors the admin-catalogue-moderation ?kind=racket\|string filter chip pattern. Two URLs; the /catalogue root needs a decision (chooser or redirect).

Decision: Option C — split routes with a /catalogue chooser landing.

Rationale

  1. Racket and String are structurally different shapes. Racket has head_size, string_pattern, version, owner_client_profile_id; String has gauge only. A single list can't surface both without per-row type-branching that defeats the whole point of a list. The admin moderation queue (admin-catalogue-moderation § Stage 2) already separates rackets from strings; we mirror that.
  2. The picker pattern (add-stringjob) already separates them — the M9 picker for rackets is _rackets, for strings is _strings, two different modal endpoints. The browse surface is downstream of that mental model.
  3. /catalogue (root) is a meaningful URL. Stefan navigating to /catalogue deserves a deliberate landing — a small chooser ("Rackets · 47 entries · Strings · 23 entries") that gives him a one-tap path into either side. Cheaper than a 302 (a chooser surfaces the counts, which is useful at-a-glance), more deliberate than tabs.
  4. Visibility filter is per-kind. "Show me my private rackets" and "show me my private strings" are independent decisions; sharing one filter chip across a mixed list creates a "no items match — but you have private rackets only, just not private strings" empty-state-confusion that's avoided when the kind is structural.
  5. V3 auto-update (#146) will likely diverge per kind — racket models update by year (Wilson Pro Staff X 2024 → 2025); string models update by spec (RPM Soft 1.30 → 1.25 variant). Splitting routes today leaves room for divergent V3 surfaces tomorrow.

TODO(stefan): confirm split-routes-with-chooser. Alternative: tabs at /catalogue (option B). Mira leans split because the per-shape divergence is real; Stefan flips trivially if he prefers the single-URL surface.

Surfaces

1. /catalogue                          — chooser landing (counts + 2 CTAs)
2. /catalogue/rackets                  — racket list (search, filter, sort)
3. /catalogue/rackets/{id}             — racket detail (read-only OR editable)
4. /catalogue/rackets/{id}/edit        — racket edit (own only)
5. /catalogue/rackets/{id}/submit      — submit-for-moderation modal (own private only)
6. /catalogue/strings                  — string list (mirror of #2)
7. /catalogue/strings/{id}             — string detail (mirror of #3)
8. /catalogue/strings/{id}/edit        — string edit (mirror of #4)
9. /catalogue/strings/{id}/submit      — submit-for-moderation modal (mirror of #5)

A new catalogue entry is NOT created from /catalogue — that affordance lives in the add-stringjob new-racket / new-string mini-flows per #144 (the M9 picker creates new catalogue rows inline when "the racket I want isn't here" / "the string I want isn't here"). The browse surface has a "+ New" button on each list page for completeness, but it routes to the same modal Pax-A is building for #144 — same shape, just opened from a different entry point.

TODO(stefan): confirm the "no orphan-catalogue creation outside add-stringjob" stance. Alternative: a standalone create form on /catalogue/rackets/new that creates a catalogue_private Racket without an order. Mira leans on the add-stringjob mini-flow path: orphan catalogue rows that never get used are dead inventory; the rare "I want to enter my new racket before I string it" case is acceptable extra friction (open add-stringjob, draft a job, save the racket via the inline create — even if the order is later abandoned, the catalogue row persists).

Surface 1 — /catalogue (chooser landing)

A small two-card landing. The point is to give Stefan a one-tap-path to either side with the counts visible at-a-glance. Mobile-first; same shape on lg — the cards just sit side-by-side on wide screens.

Viewport sm 375 px

┌───────────────────────────────────────┐
│ ←  Catalogue                          │  Header: back to dashboard
├───────────────────────────────────────┤
│                                       │
│  Browse your inventory of rackets and │  text-body slate-600 — one-line
│  strings.                             │  context line
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  🎾 Rackets                       │ │  H2 + icon
│ │                                   │ │
│ │  47 visible to you                │ │  text-small slate-700
│ │  · 12 private                     │ │  text-tiny slate-500
│ │  · 32 shared                      │ │
│ │  ·  3 one-off                     │ │
│ │                                   │ │
│ │                       Browse  →   │ │  CTA
│ └───────────────────────────────────┘ │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  ⫻ Strings                        │ │
│ │                                   │ │
│ │  23 visible to you                │ │
│ │  ·  4 private                     │ │
│ │  · 19 shared                      │ │
│ │                                   │ │
│ │                       Browse  →   │ │
│ └───────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

Viewport lg 1280 px

┌───────────────────────────────────────────────────────────────────┐
│ ←  Catalogue                                                      │
├───────────────────────────────────────────────────────────────────┤
│                                                                   │
│  Browse your inventory of rackets and strings.                    │
│                                                                   │
│  ┌──────────────────────────────┐  ┌──────────────────────────────┐│
│  │  🎾 Rackets                  │  │  ⫻ Strings                   ││
│  │  47 visible to you           │  │  23 visible to you           ││
│  │  · 12 private · 32 shared ·  │  │  · 4 private · 19 shared     ││
│  │    3 one-off                 │  │                              ││
│  │                  Browse  →   │  │                  Browse  →   ││
│  └──────────────────────────────┘  └──────────────────────────────┘│
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

Component breakdown

Component Notes
Header back-arrow Returns to dashboard.
Context line "Browse your inventory of rackets and strings." text-body slate-600. One line.
Kind card bg-white border border-slate-200 rounded-xl p-5. Whole card is the tap target. H2 with icon (🎾 / ⫻ — same icons as admin-catalogue-moderation § list view). Visible-count + per-tier breakdown. CTA "Browse →" right-aligned.
Visible-count line text-small slate-700. Counts the rows the chokepoint visibility predicate would return for the current stringer.
Per-tier breakdown text-tiny slate-500, three rows on sm (private / shared / one-off for rackets; private / shared for strings — strings have no one_off path because there is no Order.string_id ownership column for an "instance"; the V2 schema admits one-off strings via the order-attached predicate, but for stringers without orders that lift the catalogue row, the count is typically zero).

Why no + New from the chooser? Adding a catalogue row needs context (which kind? owned by which client for racket?). The add-stringjob mini-flow has that context naturally. From the chooser, we'd render a "which kind?" picker → "which client?" picker → form — three taps to where the user could've been with one tap into the list page. The list page's "+ New" button is the right entry point.

Surface 2 — /catalogue/rackets (racket list)

The XLSX-parity racket-inventory surface. Searchable, filterable by visibility + last-strung, sortable.

Viewport sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│ ←  Rackets                  [+ New]   │  Header: back + new-racket CTA
├───────────────────────────────────────┤
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search rackets                 │ │  Search input
│ └───────────────────────────────────┘ │
│                                       │
│  Visibility                           │  Filter chip group label
│  [ All 47 ] [ Private 12 ]            │
│  [ Shared 32 ] [ One-off 3 ]          │  Multi-row chip flow
│                                       │
│  Last strung                          │  Filter chip group label
│  [ Any ] [ Last 30 days ]             │
│  [ This year ] [ Never ]              │
│                                       │
│  Sort:  ◉ Recent activity             │  Sort radio
│         ◯ Make / model (A→Z)          │
│                                       │
│  47 rackets                           │  H3 + count
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Babolat Pure Aero 98           │ │  Make + model
│ │     V8 · 100 sq in · 16×19        │ │  Spec line
│ │     owned by Lukas Müller         │ │  Owner line
│ │     [Shared] last strung 2026-04-30│ │  Visibility chip + last-strung
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Wilson Blade 98                │ │
│ │     V8 · 98 sq in · 16×19         │ │
│ │     owned by Anna Bauer           │ │
│ │     [Private] last strung 2026-04-28│
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Yonex EZONE 98                 │ │
│ │     2024 · 98 sq in · 16×19       │ │
│ │     owned by Stefan (myself)      │ │
│ │     [Private] last strung 2026-04-30│
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  Show more (10 of 47)             │ │  Pagination
│ └───────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

Viewport lg 1280 px

┌───────────────────────────────────────────────────────────────────┐
│ ←  Rackets                                            [+ New]     │
├───────────────────────────────────────────────────────────────────┤
│                                                                   │
│  [🔍 Search rackets                  ]                            │
│                                                                   │
│  Visibility:  [All 47] [Private 12] [Shared 32] [One-off 3]       │
│  Last strung: [Any] [Last 30 days] [This year] [Never]            │
│  Sort: [Recent activity ▾]                                        │
│                                                                   │
│  47 rackets                                                       │
│                                                                   │
│  ┌─────────────────┬─────────┬──────────┬────────┬──────────────┐ │
│  │ Make / model    │ Spec    │ Owner    │ Visib. │ Last strung  │ │
│  ├─────────────────┼─────────┼──────────┼────────┼──────────────┤ │
│  │ Babolat Pure A. │ 100·16×19│ Lukas M.│ Shared │ 2026-04-30   │ │
│  │ Wilson Blade 98 │  98·16×19│ Anna B. │ Private│ 2026-04-28   │ │
│  │ Yonex EZONE 98  │  98·16×19│ Stefan  │ Private│ 2026-04-30   │ │
│  │ ⋯               │         │          │        │              │ │
│  └─────────────────┴─────────┴──────────┴────────┴──────────────┘ │
│                                                                   │
│                                            [ Show more (10/47) ]  │
└───────────────────────────────────────────────────────────────────┘

The columns "Spec / Owner / Visibility / Last strung" are mobile-stacked-into-the-card on sm; on lg they're a real table with sortable column headers.

Component breakdown

Component Notes
Header back-arrow Returns to /catalogue chooser.
+ New CTA Opens the same new-racket modal Pax-A is building for #144 (/orders/new/_new-racket) — but rendered in a "no order context" mode (the client_profile_id field becomes a picker for the new-racket-owner ClientProfile rather than being passed through from the order being drafted). On save, the new Racket lands as catalogue_private; the user is returned to the list with a "Saved." toast. No order is created.
Search input Placeholder "Search rackets". Matches against Racket.make + model + version (case-insensitive substring, ILIKE). HTMX live-filter, 200 ms debounce — same shape as client list search.
Visibility filter chips [All N] [Private N] [Shared N] [One-off N] — single-select; default "All". Filters by visibility = .... The "All" chip is the default URL state (/catalogue/rackets with no query string).
Last-strung filter chips [Any] [Last 30 days] [This year] [Never] — single-select; default "Any". Computed via the same MAX(orders.strung_at) subquery the M9 picker uses (orders.py § list_clients_for_picker) — but joining on Order.racket_id instead of Order.client_profile_id. "Never" maps to MAX(strung_at) IS NULL.
Sort selector Two options on sm (radio pills): "Recent activity" (default — MAX(orders.strung_at) DESC NULLS LAST) and "Make / model (A→Z)" (ORDER BY make, model). On lg, every column header is sort-clickable.
Racket row Avatar — racket icon (🎾) on slate-100 background. H3 with make + model. Spec line version · head_size sq in · string_pattern (skips NULL fields). Owner line owned by {ClientProfile name}. Visibility chip + last-strung line. Whole row is the tap target.
Visibility chip Per-row pill: [Private] (slate-100 / slate-700), [Shared] (indigo-50 / indigo-700), [One-off] (amber-50 / amber-700). Tier-coloured for at-a-glance recognition.
Last-strung line "last strung YYYY-MM-DD" (Babel format_date) or "never strung" (slate-500 italic). Computed per-row via the same MAX subquery as the filter.
+ New CTA (Top of header.) See above.
Pagination "Show more (N of M)" — same shape as client list pagination. Default page size 50 (matches the client list); HTMX hx-swap="beforeend".
Empty state — no rackets "You don't have any rackets in your catalogue yet. Add one through your next stringjob." + CTA "+ Add stringjob" routing to /orders/new.
Empty state — search/filter no match "No rackets match your filters." + "Reset filters" link.

Search behaviour

  • Match scope: Racket.make, Racket.model, Racket.version (concatenated for the match) — same as the M9 picker per issue #144.
  • Match type: case-insensitive substring (ILIKE %q%). No fuzzy matching.
  • Debounce: 200 ms (matches client list + add-stringjob picker).
  • Search interacts with filters: filters AND search compose (visibility = 'private' AND make+model+version ILIKE %q%). Resetting filters doesn't reset the search; resetting search doesn't reset filters.

Sort decision

Default sort: "Recent activity" — most-recently-strung first. Rationale matches client list sort — Stefan's most-frequent catalogue use is "find the racket I just strung." Make/model alphabetical is the secondary sort.

TODO(stefan): confirm default sort. Alternative: alphabetical first. Mira leans recent-first for the same reason as the client list.

Surface 3 — /catalogue/rackets/{id} (racket detail)

The full-context view of one Racket. Renders read-only when created_by_stringer_id != self; renders editable affordances (Edit / Submit-for-moderation) when created_by_stringer_id == self.

Viewport sm 375 px

┌───────────────────────────────────────┐
│ ←  Babolat Pure Aero 98               │  Header — back + page title
├───────────────────────────────────────┤
│                                       │
│  🎾  Babolat Pure Aero 98             │  H1 + icon
│  V8 · 100 sq in · 16×19               │  Spec summary
│                                       │
│  ─── Specs ───                        │  Group label
│                                       │
│  Make:           Babolat              │  Spec list — text-small
│  Model:          Pure Aero 98         │
│  Version:        V8                   │
│  Head size:      100 sq in            │
│  String pattern: 16 × 19              │
│  Year:           2023                 │
│  Serial:         PA98_2023_25         │  (only if set)
│  Instance label: PA98 #1              │  (only if set)
│  Currently in rotation: Yes           │
│                                       │
│  ─── Origin ───                       │
│                                       │
│  Visibility:    [Private]             │  Chip
│  Owner:         Lukas Müller →        │  Tap → /clients/{id}
│  Created by:    you                   │
│  Created at:    2024-08-15            │
│  Last strung:   2026-04-30            │  Or "never strung"                 │
│  Times strung:  12                    │
│                                       │
│  ─── Actions ───                      │  Owner only
│                                       │
│   ┌──────────────────────────────┐    │
│   │  Edit                        │    │  Primary CTA
│   └──────────────────────────────┘    │
│                                       │
│   ┌──────────────────────────────┐    │
│   │  Submit for shared catalogue │    │  Visible iff Private + own
│   └──────────────────────────────┘    │
│   Promotes this entry so other        │  text-tiny slate-500
│   stringers can pick it from their    │
│   catalogue.                          │
│                                       │
│  ─── Order history ───                │  Section
│                                       │
│  12 stringjobs on this racket         │  Summary
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ ✅ #2026-0042 · 2026-04-30        │ │  Order card
│ │   24 kg · CHF 43                  │ │
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  Show more (10 of 12)             │ │
│ └───────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

Viewport lg 1280 px

Two-column with specs + origin + actions on the left (~40%, sticky), order history on the right (~60%) — same shape as client-detail § lg viewport.

Component breakdown

Specs panel

<dl> with <dt> (slate-500) + <dd> (slate-700). Skips NULL fields entirely (no "Version: —" row when version IS NULL).

Field Source Notes
Make Racket.make Required column.
Model Racket.model Required column.
Version Racket.version Optional. Skipped when NULL.
Head size Racket.head_size (sq in) Optional. "100 sq in" via Babel format_unit.
String pattern Racket.string_pattern Optional. "16 × 19" — the column stores "16x19" text; the UI inserts the ×.
Serial number Racket.serial_number Optional. Monospace.
Instance label Racket.instance_id Optional. Free text — "PA98 #1".
Year Racket.year Optional.
Currently in rotation Racket.current Bool — "Yes" / "No".

Origin panel

Field Notes
Visibility Same per-row chip as the list page ([Private] / [Shared] / [One-off]).
Owner Racket.owner_client_profile_id → ClientProfile name. Tap navigates to /clients/{owner_client_profile_id}.
Created by "you" if created_by_stringer_id == self, else "(another stringer)" — we don't surface a name across tenants per the privacy invariant. The line still tells Stefan "this isn't mine to edit."
Created at Racket.created_at ISO date.
Last strung MAX(orders.strung_at) for Order.racket_id == this.id AND Order.stringer_id == self. ISO date or "never strung."
Times strung COUNT(orders) for Order.racket_id == this.id AND Order.stringer_id == self AND strung_at IS NOT NULL.

Same-tenant restriction on order-derived metrics: "last strung" and "times strung" are computed only over the current stringer's orders, not cross-tenant. A catalogue_shared racket strung 100 times by other stringers shows "times strung: 0" if the current stringer has never used it. This is consistent with the M9 picker's last_strung_at shape (per app/services/orders.py § list_clients_for_picker — comment: "if a Rule-3-shared CP has Rule-3 invisible orders strung by ANOTHER stringer, we render 'no previous jobs'").

Actions panel — owner-only

Renders only when created_by_stringer_id == self. For non-owner views (e.g. a catalogue_shared racket created by another stringer, or a one_off racket reachable through an inbound Rule #1 share's order), the section is omitted entirely; the page renders specs + origin + order history without the action buttons.

Button Visibility Notes
Edit Always (when owner) Routes to /catalogue/rackets/{id}/edit. Modal form.
Submit for shared catalogue When visibility == 'catalogue_private' AND no PENDING CatalogueSubmission exists for this row Opens the submit-for-moderation modal.
Submission pending When a PENDING CatalogueSubmission exists for this row Read-only chip "Submission pending review · {N days ago}" — replaces the Submit button. Tap opens a small read-only sheet showing the submission's submitted_at and a link "View moderation status" (which routes to the V2.x stringer-side view of CatalogueSubmission — out of this spec).
Submitted (decided) When the most recent CatalogueSubmission is promoted or rejected The Submit button stays available IF the row is still catalogue_private (i.e. rejected — re-submission is allowed per the schema's uq_catalogue_submissions_one_pending_per_target partial unique). On promoted, the row's visibility is now catalogue_shared, so the submit button is moot and is omitted.

TODO(stefan): confirm the "no V2 stringer-side view of decided submissions" punt. The schema (CatalogueSubmission) supports it; the UI is queued for a future round (mentioned in design § Future rounds as the producer side of the moderation queue). The compromise this spec adopts: the pending-state chip exists; the decided-state full view is deferred. Stefan to flag if he wants the decided-state view surfaced now.

Order history

Per-racket order list, paginated. Shape matches client-detail § order history but keyed off Order.racket_id instead of Order.client_profile_id. Default page size 20.

The order history is stringer-scoped: only the current stringer's orders against this racket are visible (chokepoint applies). On a catalogue_shared racket, the section reads "0 stringjobs on this racket" if the current stringer has never used it; on a same-tenant racket with 12 of the stringer's stringjobs, all 12 render.

Surface 4 — /catalogue/rackets/{id}/edit (racket edit modal)

Modal form. Renders only when created_by_stringer_id == self; non-owners hitting the URL get a 403 with a "this catalogue entry is read-only" page.

   ╔═══════════════════════════════╗
   ║  Edit racket                  ║
   ║                               ║
   ║  Make *                       ║
   ║  [Babolat                ]    ║
   ║                               ║
   ║  Model *                      ║
   ║  [Pure Aero 98           ]    ║
   ║                               ║
   ║  Version                      ║
   ║  [V8                     ]    ║
   ║                               ║
   ║  Head size (sq in)            ║
   ║  [100                    ]    ║
   ║                               ║
   ║  String pattern               ║
   ║  [16x19                  ]    ║
   ║                               ║
   ║  Year                         ║
   ║  [2023                   ]    ║
   ║                               ║
   ║  Serial number                ║
   ║  [PA98_2023_25           ]    ║
   ║                               ║
   ║  Instance label               ║
   ║  [PA98 #1                ]    ║
   ║                               ║
   ║  Owner *                      ║
   ║  [Lukas Müller           ▾]   ║  Client picker (per add-stringjob shape)
   ║                               ║
   ║  ☑ Currently in rotation      ║
   ║                               ║
   ║  ⓘ Visibility:  [Private]     ║
   ║  Use "Submit for shared       ║  text-tiny slate-500
   ║  catalogue" on the detail     ║
   ║  page to promote this entry.  ║
   ║                               ║
   ║  ┌───────────┐ ┌────────────┐ ║
   ║  │ Cancel    │ │ Save       │ ║
   ║  └───────────┘ └────────────┘ ║
   ╚═══════════════════════════════╝

Component breakdown

Field Type Notes
Make <input maxlength="80" required> Per Racket.make SAString(80).
Model <input maxlength="120" required> Per Racket.model SAString(120).
Version <input maxlength="40"> Optional.
Head size <input type="number" inputmode="numeric" min="50" max="200"> Sq in. Optional.
String pattern <input maxlength="20"> Free text — "16x19" / "18x20" / "16x18-tied".
Year <input type="number" inputmode="numeric" min="1980" max="2099"> Optional.
Serial number <input maxlength="80"> Optional. Monospace.
Instance label <input maxlength="80"> Optional.
Owner Picker — same shape as the add-stringjob client picker Required. Default-filled with the current owner; the user can re-assign to a different ClientProfile in their tenant. No re-assignment to a ClientProfile from another tenant (chokepoint enforces).
Currently in rotation <input type="checkbox"> Maps to Racket.current.
Visibility (read-only display) Chip + hint Visibility is not editable on this form — the only path from catalogue_privatecatalogue_shared is the moderation queue. The chip + hint communicates that.

Visibility editability — explicit non-affordance

Visibility is the load-bearing axis (per Goal #2) and is governed by the moderation queue. Therefore:

  • The edit form does NOT expose visibility as an editable field.
  • A stringer cannot self-promote catalogue_privatecatalogue_shared (M17's whole point — "no unilateral flag-flip by stringers"). The path is "Submit for shared catalogue" → admin promotes.
  • A stringer CAN flip catalogue_privateone_off is NOT an offered transition — one_off is set only at order-creation time per the schema's intent ("visibility = one_off (order-attached only)" per catalogue.py line 245). The UI doesn't surface a catalogue_privateone_off transition; once created, the row stays in its tier until moderated.
  • A stringer cannot demote a catalogue_shared row back to catalogue_private — that's an admin-only operation (V2 doesn't expose it; it would require a UI on the admin-catalogue-moderation surface).

TODO(stefan): confirm the "no demote / no shared→private revert" stance. Edge case: a stringer mistakenly submits a typo'd Racket ("Babolat Pyre Aero 98") that gets promoted; the only V2 path is "edit the typo on the shared row" (per the edit form, since make/model are editable on own rows). If Stefan wants a "request demote" flow, that's a V2.x admin polish — not in this spec.

Validation

Rule Inline message
Make empty "Make is required."
Make > 80 chars "Make is too long (max 80)."
Model empty "Model is required."
Model > 120 chars "Model is too long (max 120)."
Version > 40 chars "Version is too long (max 40)."
Head size out of range (50–200) "Head size must be between 50 and 200 square inches."
Year out of range (1980–2099) "Year must be between 1980 and 2099."
Serial > 80 chars "Serial number is too long (max 80)."
Instance label > 80 chars "Instance label is too long (max 80)."
Owner empty "Owner is required."

Submit-for-moderation flow

Triggered from the racket-detail page's "Submit for shared catalogue" button. Renders only when visibility == 'catalogue_private' AND the row's created_by_stringer_id == self. Creates a CatalogueSubmission row with target_type = 'racket', target_id = this.id, status = 'pending'.

Submit modal

   ╔═══════════════════════════════╗
   ║                               ║
   ║  Submit for shared catalogue? ║  H2
   ║                               ║
   ║  Babolat Pure Aero 98 will be ║  text-body
   ║  reviewed by an admin and, if ║
   ║  promoted, become visible to  ║
   ║  every stringer.              ║
   ║                               ║
   ║  Note for the admin (optional)║  text-small slate-700
   ║ ┌─────────────────────────┐   ║
   ║ │                         │   ║  Textarea
   ║ │                         │   ║
   ║ └─────────────────────────┘   ║
   ║  e.g. "I've used this with    ║  text-tiny slate-500 — placeholder
   ║  several clients this season" ║
   ║                               ║
   ║  ⓘ Until the admin decides,   ║  text-tiny slate-500
   ║  this racket stays visible to ║
   ║  you only.                    ║
   ║                               ║
   ║  ┌───────────┐ ┌────────────┐ ║
   ║  │ Cancel    │ │ Submit     │ ║  Primary
   ║  └───────────┘ └────────────┘ ║
   ╚═══════════════════════════════╝

Behaviour

  • Note is optional (consistent with admin-catalogue-moderation Stage 3 which renders the note when present). Maps to CatalogueSubmission.notes. (Note: the Pax-A schema names this field — the column doesn't currently exist; see decision-log below.)
  • Submit creates the CatalogueSubmission row in pending state, fires the admin notification (catalogue_submission_pending), closes the modal, and re-renders the detail page with the "Submission pending review" chip replacing the Submit button. Toast: "Submitted for review."
  • Cancel dismisses the modal; nothing changes.
  • Already-pending guard (server-side, defensive): if a PENDING submission already exists for this target_id (per the partial unique uq_catalogue_submissions_one_pending_per_target), the server returns a 409 + the modal renders an inline error "A submission for this racket is already pending review." (This shouldn't happen via the UI — the Submit button is hidden when pending exists — but defends against double-submit / replay.)
  • Re-submit after reject is allowed per the schema; the Submit button re-appears on the detail page when the most recent submission is rejected AND visibility is still catalogue_private. Pre-fills the note textarea with the prior submission's note (if any) — small kindness for the "fix the typo and resubmit" path.

Note schema gap

The admin-catalogue-moderation § Stage 3 spec renders CatalogueSubmission.notes as a free-text block ("Stringer's note"). The current app/db/models/catalogue.py does not declare a notes column on CatalogueSubmission — only reject_reason (admin-side) and reviewed_by_admin_id / reviewed_at exist.

TODO(stefan): the CatalogueSubmission.notes column needs to land before this submit modal can ship. Mira flagged this as a schema gap on review of admin-catalogue-moderation.md vs the model file. Pax to add a nullable notes text column on CatalogueSubmission (no migration of existing rows needed — they're all NULL post-add). Out of this spec's scope but a blocking sibling for #147 implementation. Mira's recommendation: file a sibling issue or include in #147's implementation MR.

Surface 5 — /catalogue/strings (string list)

Symmetric to the racket list. The structural difference: String has fewer columns (manufacturer, model, gauge), no owner_client_profile_id, no current rotation flag.

Viewport sm 375 px

┌───────────────────────────────────────┐
│ ←  Strings                  [+ New]   │
├───────────────────────────────────────┤
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search strings                 │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Visibility                           │
│  [ All 23 ] [ Private 4 ]             │
│  [ Shared 19 ]                        │  Note: no One-off chip — see below
│                                       │
│  Last used                            │
│  [ Any ] [ Last 30 days ]             │
│  [ This year ] [ Never ]              │
│                                       │
│  Sort:  ◉ Recent activity             │
│         ◯ Manufacturer / model (A→Z)  │
│                                       │
│  23 strings                           │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ ⫻ Luxilon ALU Power               │ │
│ │     1.25 mm                       │ │  Spec line — gauge only
│ │     [Shared] last used 2026-04-30 │ │  Visibility chip + last-used
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ ⫻ Solinco Hyper-G                 │ │
│ │     1.20 mm                       │ │
│ │     [Private] last used 2026-04-25│ │
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
└───────────────────────────────────────┘

Component breakdown

The list page's components mirror Surface 2 with adjustments:

Component Difference from racket list
Search match scope String.manufacturer + model + gauge (concatenated). Mirrors the M9 string-picker per issue #144.
Visibility filter chips [All N] [Private N] [Shared N]no [One-off N] chip rendered by default. Strings can technically have visibility = 'one_off' per the schema (a string spec attached to one Order, never reused), but the count is typically zero for a stringer with normal usage; the chip would be visual noise. Server-side rule: if count(visibility = 'one_off') > 0, render the chip; otherwise omit. Same convention as the racket list page (the racket list always renders one-off because rackets-as-instances have a more legitimate one_off shape).
Last-used filter Replaces "Last strung" — naming follows the verb that fits a string (you "use" a string in a stringjob; you "string" a racket with a string). Same query: MAX(orders.strung_at) joining Order.main_string_id OR Order.cross_string_id against String.id.
Owner column Removed. Strings have no owner_client_profile_id. The list page card has 4 lines instead of 5 (no "owned by ..." line).
Currently in rotation field Removed. No analogue on String.

lg-fallback table columns

| Manufacturer / model | Gauge | Visib. | Last used  |

Owner column omitted; one fewer column than the racket list.

Surface 6 — /catalogue/strings/{id} (string detail)

Mirror of Surface 3 with the same shape adjustments as the list:

  • Specs panel has: Manufacturer, Model, Gauge. No head size, string pattern, year, serial, instance label, current.
  • Origin panel omits the Owner row.
  • Actions panel is identical to Surface 3 (Edit + Submit-for-moderation conditional logic).
  • Order history — joins on Order.main_string_id == this.id OR Order.cross_string_id == this.id. The order card adds a small "main" / "cross" label so the user sees which side of the racket the string was used on. (V2 reality: ~89% of cases use the same string on both sides per UC-2; the per-side label still distinguishes the rare BYO-cross / mixed setups.)

Edit modal (Surface 7)

Same modal shape as racket edit, with three fields instead of nine:

Field Type Notes
Manufacturer <input maxlength="80" required> Per String.manufacturer SAString(80).
Model <input maxlength="120" required> Per String.model SAString(120).
Gauge <input maxlength="20"> Optional. Free text — "1.25" / "17g".

Validation rules mirror the racket edit form for the matching fields; no head-size / year / pattern / etc.

V3 forward-compat hooks

Per epic #147 + the sibling-issue cross-cuts: surfaces where V3 features will land on the catalogue browse without re-architecting.

Hook 1 — source filter (#145 column lands V2; UI is V3)

Per issue #145, the V2 schema-hook adds Racket.source and String.source enum columns (manual | imported), default manual. No V2 UI surfaces them. The browse list pages reserve a structural slot:

  • The filter-chip area on /catalogue/rackets and /catalogue/strings admits a third filter row below "Last strung" / "Last used":
  Visibility:  [All 47] [Private 12] [Shared 32] [One-off 3]
  Last strung: [Any] [Last 30 days] [This year] [Never]
  Source:      [Any] [Manual] [Imported]                       ← V3 only
  • In V2 the "Source" row is NOT rendered. The _filter_chips template carries an explicit <!-- v3-source-filter --> HTML comment where Pax + Juno render the row conditionally on a feature flag once V3 ships.
  • The detail-page Origin panel admits a future "Source" row:
  Source:        Imported (Wilson manufacturer feed)            ← V3 only
  Last imported: 2026-12-15                                     ← V3 only

Same posture: comment-marker reserved; not rendered in V2.

Hook 2 — V3 auto-update (#146 — imported rows + last-imported-at + conflict resolution)

Per issue #146, V3 adds periodic ingestion from manufacturer feeds. The browse surface becomes the natural display point for:

  • Per-row "imported" badge on the list rows: [Imported] chip next to [Shared] (same chip style; teal-50 / teal-700 to differentiate). Indicates the row originated from a feed, not from a manual stringer entry.
  • Per-row "stale" indicator when an imported row's source feed has been updated more recently than the local copy (V3 conflict-resolution surface; out of V2 scope).
  • A per-row "merge with newer" CTA on shared+imported rows where the manufacturer feed has a newer version (V3-only).

V2 posture: all three are unimplemented; the row card has explicit space reserved. The right-edge of the visibility-chip area on the list-page row card admits one additional chip without breaking the layout (verified at sm 375 px — three chips fit on one line).

Hook 3 — Bulk operations (V3, not in #147 scope)

Bulk-edit / bulk-submit-for-moderation / bulk-archive operations are V3 territory (out of V2 per epic #147 non-goals). The list page's per-row tap target is the only interactive affordance; no checkboxes for multi-select. V3 will need to retrofit checkboxes — the current row layout reserves left-edge space (16 px) where a checkbox would land.

Non-goals

Per epic #147 + Mira's sweep:

  • Admin moderation UI — shipped Round 5; lives at /admin/catalogue/queue. The submit-for-moderation flow on this page feeds that queue; the queue itself is admin-only.
  • Auto-update from external feeds — V3 (#146). See Hook 2 above.
  • Per-client racket history. Lives on /clients/{id} § order history (per-client, all rackets). The /catalogue/rackets/{id} detail page's order history is per-racket (cross-client). The two surfaces are distinct projections of the same Order table.
  • Catalogue-row deletion. Per Racket.owner_client_profile_id ondelete=RESTRICT and the FK chain to Order, deleting a catalogue row that has orders would orphan the order. V2 doesn't offer a "delete this racket" affordance; the soft equivalent is Racket.current = false (sets the in-rotation flag — preserves the row + order history). Strings have no current flag; for V2, a stringer who created a typo'd catalogue_private String simply leaves the row in place (it doesn't appear in the M9 picker prominently if never used). A "remove this string from my catalogue" affordance is V2.x polish, not in this spec.
  • Cross-stringer catalogue search — the chokepoint already shows shared rows from all stringers; there is no "show me all stringers' private rackets" view (privacy invariant; ADR-0004).
  • Catalogue-row commenting / discussion. Out of V2; the CatalogueSubmission.notes field is a one-shot stringer→admin message, not a thread.
  • Tag / category metadata on catalogue rows ("power racket" / "control racket" / "polyester" / "multifilament"). Out of V2; no schema, no UI. V3 candidate.
  • Stringer-side full view of decided submissions (the producer-side mirror of admin-catalogue-moderation § archive). Mentioned in design § Future rounds as the producer-side complement; out of #147 scope.

Interaction states

State What renders
Chooser landing — initial Two cards with counts.
Chooser landing — empty (no catalogue rows) Zero counts on both cards; the cards still render with "0 visible to you" + a hint "Your first racket / string lands here when you save your first stringjob."
List — initial load First 50 rows + filter chips + sort selector + search.
List — searching / filtering HTMX swap of the row region as the user types or taps a chip. 200 ms debounce on search; immediate on chip.
List — show more New 50 rows append; "Show more" CTA refreshes with new "(N of M)".
List — empty (no rows for filter) "No {rackets / strings} match your filters." + "Reset filters" link.
List — empty (no rows at all) "You don't have any {rackets / strings} in your catalogue yet." + CTA "+ Add stringjob".
Detail — initial load (own row) Specs + Origin + Actions panel + Order history (first 20 orders).
Detail — initial load (shared row, not own) Same shape MINUS the Actions panel. The page surfaces the spec + the order history (this stringer's only).
Detail — no order history "No stringjobs on this {racket / string} yet." + CTA "+ Add stringjob" routing to /orders/new with the catalogue row pre-selected.
Detail — submission pending Actions panel shows "Submission pending review · {N days ago}" chip in place of the Submit button.
Detail — submission rejected, re-submit available Submit button re-appears. The "Decisions" history (a small expandable below the Actions panel — "Show prior submissions") lists the rejected submission with its reject_reason. For V2: the prior-submissions history is rendered as a small <details> element; the inline content is server-rendered HTML (no extra HTMX).
Edit modal — open Modal pre-filled with current row values.
Edit modal — save success Modal closes; detail page re-renders with the new values; toast: "Saved."
Edit modal — validation error Inline red-700 per affected field; modal stays open.
Submit modal — open Modal renders with empty notes textarea (or pre-filled with prior rejected submission's notes).
Submit modal — submit success Modal closes; detail page re-renders with the "Submission pending review" chip; toast: "Submitted for review."
Submit modal — already-pending 409 Inline red-700 "A submission for this {racket / string} is already pending review."; modal stays open.
403 — non-owner hits edit URL Plain page: "This catalogue entry is read-only — only the stringer who created it can edit." + back link to detail page.
404 — catalogue id doesn't exist or chokepoint hides it Plain "Not found" page; identical to the chokepoint's tenant-isolation 404 elsewhere.

Validation rules (UI surface; canonical server-side)

Rule Inline message
Make / Manufacturer empty (edit) "{Make / Manufacturer} is required."
Make / Manufacturer > 80 chars "Too long (max 80)."
Model empty "Model is required."
Model > 120 chars "Too long (max 120)."
Version > 40 chars "Version is too long (max 40)."
Head size out of range (50–200) "Head size must be between 50 and 200 sq in."
Year out of range (1980–2099) "Year must be between 1980 and 2099."
Serial > 80 chars "Serial number is too long (max 80)."
Instance label > 80 chars "Instance label is too long (max 80)."
Owner empty (racket only) "Owner is required."
Gauge > 20 chars "Gauge is too long (max 20)."
Submit modal — already pending (409) "A submission for this {racket / string} is already pending review."

Accessibility

  • Heading order: <h1> per page (chooser: "Catalogue"; list: "Rackets" / "Strings"; detail: catalogue row's display name) → <h2> per section ("Specs", "Origin", "Actions", "Order history"). The list-page H1 is visually rendered in the back-arrow header; the chooser-page H1 is similarly rendered.
  • Search input: <input type="search">, aria-label="Search rackets" / "Search strings".
  • Filter chips: <fieldset role="group" aria-label="Filter by visibility"> + <button aria-pressed="true|false"> per chip. Single-select (radio-like behaviour); clicking a chip flips its aria-pressed and clears the others.
  • Sort selector: same pattern as client list sort<fieldset> with two radios on sm; column-header <button> inside <th> on lg table view; aria-sort flips between ascending / descending / none.
  • Catalogue row tap targets: whole row is a single focusable region; aria-label describes the row, e.g. "Open Babolat Pure Aero 98 — V8, 100 sq in, 16 by 19, owned by Lukas Müller, Private, last strung 2026-04-30."
  • Visibility chip: aria-label on the chip describes the tier semantically: "Private to you" / "Shared catalogue" / "One-off entry, attached to one order." The decorative chip text is for sighted users; the aria-label disambiguates for screen readers.
  • Modals: role="dialog" aria-modal="true" aria-labelledby="modal-title". Focus moves to the first input on open; ESC dismisses (Cancel-equivalent); focus trap inside the modal until close.
  • Non-owner detail view: the omission of the Actions panel is signaled by the page structure — there's no "you can't edit this" warning, just the absence of the buttons. Screen-reader users navigating the page hear "Specs", "Origin", "Order history" — no "Actions" — which is correct.
  • Submit-for-moderation modal: the destructive-ish nature (it's a one-way request — admin reviews, can't be cancelled by the stringer mid-review) is communicated via copy ("will be reviewed by an admin and, if promoted, become visible to every stringer"), not via destructive-styling colours. The button is primary indigo, not red.
  • Hit targets: every interactive element ≥ 44 × 44 px; row hit targets are full-row.
  • Color contrast: every text/background pair meets WCAG 2.1 AA. The visibility chips: slate-700 on slate-100 (10.5:1), indigo-700 on indigo-50 (8.59:1), amber-700 on amber-50 (4.92:1). All pass.
  • prefers-reduced-motion: disables HTMX swap fades; "Show more" appends instantly; modal transitions become instant.
  • Without JS: every link still works; modals become full pages (/catalogue/rackets/{id}/edit is a real page, not just a modal); search reverts to a <form method="get"> that re-renders the list with the filter applied.

HTMX / progressive-enhancement seams

  • List search: hx-get="/catalogue/rackets?q=..." hx-trigger="keyup changed delay:200ms" hx-target="#catalogue-list" hx-swap="innerHTML". Without JS: regular <form method="get"> submit.
  • Filter chip tap: hx-get="/catalogue/rackets?visibility=private" hx-target="#catalogue-list". Without JS: each chip is a regular <a href="?visibility=private"> link.
  • List sort: column-header click on lghx-get="/catalogue/rackets?sort=..." hx-target="#catalogue-list". Without JS: <a href="?sort=..."> link.
  • List pagination: hx-get="/catalogue/rackets?page=N" hx-target="#catalogue-list" hx-swap="beforeend". Without JS: <a href="?page=N#catalogue-list"> link.
  • Detail page initial load: server-rendered GET /catalogue/rackets/{id}.
  • Detail order pagination: hx-get="/catalogue/rackets/{id}?page=N" hx-target="#order-list" hx-swap="beforeend". Without JS: anchor link.
  • Edit modal — open: hx-get="/catalogue/rackets/{id}/edit" hx-target="#modal-portal" hx-swap="innerHTML". Without JS: separate /catalogue/rackets/{id}/edit page.
  • Edit modal — save: regular <form method="post" action="/catalogue/rackets/{id}/edit">. HTMX upgrade returns a 303 redirect; without JS the browser follows.
  • Submit-for-moderation modal — open: hx-get="/catalogue/rackets/{id}/submit" hx-target="#modal-portal". Without JS: separate /catalogue/rackets/{id}/submit page.
  • Submit modal — submit: regular <form method="post" action="/catalogue/rackets/{id}/submit">. HTMX upgrade returns a 303; without JS the browser follows back to /catalogue/rackets/{id}.
  • + New (list page): hx-get="/catalogue/rackets/new" hx-target="#modal-portal" (re-uses the same Pax-A endpoint /orders/new/_new-racket mounted at this path with the no-order context flag). Without JS: full-page form at /catalogue/rackets/new.

The page's fundamental contract is "regular form POSTs". HTMX swaps are surgical optimisations.

i18n affordance

String Type Catalogue key
"Catalogue" (chooser H1) {% trans %} catalogue.chooser.title
"Browse your inventory of rackets and strings." {% trans %} catalogue.chooser.subtitle
"Rackets" / "Strings" (chooser cards + list H1s) {% trans %} catalogue.kind.{rackets,strings}
"{N} visible to you" / per-tier breakdown {% trans %} (Babel format_decimal) catalogue.chooser.{visible,private,shared,one_off}
"Browse →" CTA {% trans %} catalogue.chooser.cta
"Search rackets" / "Search strings" placeholder {% trans %} catalogue.list.search.placeholder.{rackets,strings}
"Visibility" / "Last strung" / "Last used" / "Sort:" filter labels {% trans %} catalogue.list.filter.{visibility,last_strung,last_used,sort}
"All" / "Private" / "Shared" / "One-off" chip labels {% trans %} catalogue.visibility.{all,private,shared,one_off}
"Any" / "Last 30 days" / "This year" / "Never" chip labels {% trans %} catalogue.last_strung.{any,last_30,this_year,never}
"Recent activity" / "Make / model (A→Z)" / "Manufacturer / model (A→Z)" sort labels {% trans %} catalogue.list.sort.{recent,name_racket,name_string}
"{N} rackets" / "{N} strings" count line {% trans %} (Babel) catalogue.list.count.{rackets,strings}
"Show more ({N} of {M})" {% trans %} (Babel) catalogue.list.show_more
"+ New" CTA {% trans %} catalogue.list.cta.new
Row spec line "{version} · {head_size} sq in · {pattern}" mixed ({% trans %} for "sq in" via Babel format_unit; rest is data) catalogue.row.head_size_unit (just "sq in")
"owned by {name}" {% trans %} catalogue.row.owned_by
"last strung {date}" / "never strung" / "last used {date}" / "never used" {% trans %} (Babel format_date) catalogue.row.last_{strung,used}.{value,never}
"Specs" / "Origin" / "Actions" / "Order history" detail H2s {% trans %} catalogue.detail.section.{specs,origin,actions,history}
Spec field labels ("Make", "Model", "Version", "Head size", "String pattern", "Year", "Serial", "Instance label", "Currently in rotation", "Manufacturer", "Gauge") {% trans %} catalogue.detail.field.{make,model,version,head_size,string_pattern,year,serial,instance_label,current,manufacturer,gauge}
Origin field labels ("Visibility", "Owner", "Created by", "Created at", "Last strung", "Times strung") {% trans %} catalogue.detail.origin.{visibility,owner,created_by,created_at,last_strung,times_strung}
"you" (created_by self) / "(another stringer)" {% trans %} catalogue.detail.origin.created_by.{self,other}
"Yes" / "No" (Currently in rotation) {% trans %} common.{yes,no}
"Edit" CTA {% trans %} catalogue.detail.cta.edit
"Submit for shared catalogue" CTA + hint {% trans %} catalogue.detail.cta.submit / catalogue.detail.cta.submit_hint
"Submission pending review · {N days ago}" chip {% trans %} (Babel) catalogue.detail.submission.pending
"{N} stringjobs on this racket" / "this string" {% trans %} (Babel) catalogue.detail.history.summary.{racket,string}
"main" / "cross" string-side label (string detail order card) {% trans %} catalogue.detail.history.side.{main,cross}
Edit modal title "Edit racket" / "Edit string" {% trans %} catalogue.edit.title.{racket,string}
Edit modal field labels (re-use detail-field keys) {% trans %} (re-use)
Edit modal "Visibility:" hint {% trans %} catalogue.edit.visibility_hint
Edit modal "Cancel" / "Save" {% trans %} common.{cancel,save}
Submit modal title "Submit for shared catalogue?" {% trans %} catalogue.submit.title
Submit modal body "{name} will be reviewed…" {% trans %} catalogue.submit.body
Submit modal "Note for the admin (optional)" {% trans %} catalogue.submit.note_label
Submit modal placeholder hint "e.g. 'I've used this with…'" {% trans %} catalogue.submit.note_placeholder
Submit modal hint "Until the admin decides, this racket stays visible to you only." {% trans %} catalogue.submit.privacy_hint
Submit modal "Cancel" / "Submit" {% trans %} common.cancel / catalogue.submit.cta
Toasts ("Saved.", "Submitted for review.") {% trans %} catalogue.toast.{saved,submitted}
Empty states ("You don't have any rackets…", "No rackets match your filters.") {% trans %} catalogue.list.empty.{none,no_match}.{rackets,strings}
403 page "This catalogue entry is read-only…" {% trans %} catalogue.detail.readonly_403
Validation messages {% trans %} catalogue.validation.{...}
Make / Model / Manufacturer values (Stefan's actual data) Data n/a
Date "2026-04-30" Format ISO YYYY-MM-DD (Babel format_date) n/a
Tension "24 kg" (in order card) Format (Babel format_unit) n/a
Currency "CHF 43" (in order card) Format (Babel format_currency) n/a

DE strings are Iris's later pass.

DE width budget (designer note)

DE labels here are typically 20–30% longer than EN. Specific watch-outs:

  • "Catalogue" → "Katalog" (~0.8×) — actually shorter.
  • "Rackets" → "Schläger" (~1.2×) — fits chooser card + list H1.
  • "Strings" → "Saiten" (~0.8×) — fits.
  • "Search rackets" → "Schläger suchen" (~1.1×) — fits.
  • "Visibility" → "Sichtbarkeit" (~1.3×) — fits filter row label.
  • "Private" → "Privat" (~0.8×); "Shared" → "Geteilt" (~1.2×); "One-off" → "Einmalig" (~1.4×) — chip widths admit each.
  • "Last strung {date}" → "Zuletzt bespannt {date}" (~1.5×) — wraps to two lines on sm for some rows; the row card reserves the height (already verified for the client list).
  • "Submit for shared catalogue" → "Für geteilten Katalog einreichen" (~1.6×) — the button wraps to two lines on sm; OK at 48 px hit target.
  • "Submission pending review · {N days ago}" → "Einreichung in Prüfung · vor {N} Tagen" (~1.5×) — wraps; OK.
  • "owned by {name}" → "gehört {name}" (~0.8×) — actually shorter.
  • "Currently in rotation" → "Aktuell im Einsatz" (~1.0×) — fits the modal label.
  • "{N} stringjobs on this racket" → "{N} Bespannungen mit diesem Schläger" (~1.5×) — wraps to two lines; OK.

The single-column scroll naturally handles wrapping; no DE-specific layout tweaks needed.

Decisions Mira made on Stefan's behalf

Listed for the MR description so Stefan can reverse any of them on review:

  1. Split routes — /catalogue/rackets + /catalogue/strings with a /catalogue chooser landing — vs. tabs at one route or a unified mixed list. Five-point rationale above; Stefan flips trivially if he prefers tabs.
  2. + New from the list page reuses the add-stringjob inline-create modal (Pax-A's #144 endpoints) in a "no order context" mode. Avoids forking the create form; no orphan-catalogue-row standalone create path.
  3. Visibility is NOT editable on the racket / string edit form — the only catalogue_privatecatalogue_shared transition is the moderation queue. M17's "no unilateral flag-flip by stringers" explicitly drives this.
  4. one_off rows are not user-promotableone_off is set at order-creation time only, and the edit form does not offer a catalogue_privateone_off transition. Stefan to flag if he wants the demote-to-one-off path.
  5. catalogue_shared rows cannot be reverted to catalogue_private from the stringer side — admin-only operation. V2 doesn't expose it.
  6. Visibility filter shows [One-off] only when count > 0 on each list (rackets and strings). Avoids visual noise for stringers without one-off rows.
  7. Last-strung / last-used / times-strung metrics are stringer-scoped — only the current stringer's orders count. A catalogue_shared racket strung 100 times by other stringers shows "0 stringjobs" if the current stringer has never used it. Matches the M9 picker's posture.
  8. Catalogue rows are NOT deletable in V2 — soft-equivalent for rackets is Racket.current = false; for strings, no soft-equivalent exists (a typo'd private string just sits unused). FK chain to Order makes hard-delete unsafe.
  9. Submit-for-moderation note is optional. Re-submission after reject is allowed and pre-fills the prior note. No V2 stringer-side full view of decided submissions (queued for a future round).
  10. The detail page omits the Actions panel entirely for non-owner views — vs. rendering disabled buttons + a "you can't edit this" warning. Cleaner; the absence is the message.
  11. source filter (#145) is structurally reserved but not rendered in V2. A comment marker in the template tells Pax + Juno where to wire the V3 chip row.
  12. Auto-update / imported UI (#146) is structurally reserved on the row card — one extra chip slot fits at sm width — but not rendered in V2.
  13. Per-row checkboxes for bulk operations are not in V2 — but the row layout reserves left-edge space (16 px) for V3 retrofit.

Open questions for Stefan (with proposed defaults)

  1. Split routes vs. tabs at /catalogue? Proposed default: split routes with chooser landing. See surface-decision section above. Stefan flips trivially if he prefers tabs at one URL.
  2. List page size — 50? Proposed default: 50. Matches the client list page size; Stefan's V1 inventory is small enough that 50 covers most stringers in 1–3 pages.
  3. Order-history page size on detail (20)? Proposed default: 20. Matches client-detail's order history. Bumps trivially if Stefan flags it.
  4. Visibility filter chip default — "All" or "Private"? Proposed default: "All". Stefan's curatorial use case ("submit my private string for moderation") is a sub-task; the primary use case is "what's in my catalogue?" — answered by All.
  5. Show one-off chip when count is 0 — render with "0" or omit? Proposed default: omit. Per decision #6 above. Stefan to flag if he wants it always-rendered for consistency.
  6. Search match scope — also match Racket.serial_number, Racket.instance_id? Proposed default: no — search matches make + model + version only. Serial / instance are ID-shaped fields; matching them from a free-text search produces hits that look like noise. Stefan can filter via the (future) advanced-filter row if needed.
  7. Last-strung filter buckets — "Last 30 days" / "This year" / "Never" is a 3-bucket coarse filter. Should Mira add "Last 7 days" or "Last 90 days" buckets? Proposed default: ship the 3 buckets. More buckets = more chip-row real estate; the 3 cover the "recent / this year / dormant" dimensions Stefan asked for in the epic.
  8. Submit-for-moderation note pre-fill on re-submit after reject — pre-fill or empty? Proposed default: pre-fill with the prior note. Small kindness for the typo-fix-and-resubmit path. Stefan flips if he wants empty-by-default.
  9. CatalogueSubmission.notes schema gap — Mira flagged that the model file lacks the notes column the admin moderation spec renders. Proposed default: file a sibling implementation issue (or include in #147's implementation MR) for Pax to add the column. Out of design-spec scope; flagged here for visibility.
  10. Detail page "Order history" stringer-scoped vs. global? Proposed default: stringer-scoped — only the current stringer's orders. Matches the M9 picker's last-strung shape; respects tenant isolation. A catalogue_shared racket's "global stringings" view (across all stringers) would leak cross-tenant order metadata. Stefan to flag if he disagrees.
  11. + New button placement — top-right header (current proposal) or bottom-of-list? Proposed default: top-right header. Mirrors the client list § header + New button. Stefan to flag if he wants the bottom-of-list pattern (which is more thumb-friendly on sm but pushes the affordance below the fold).
  12. Visibility chip colours — slate / indigo / amber for private / shared / one-off? Proposed default: that triplet. Slate = neutral / private; indigo = primary / shared (matches the platform primary); amber = warning-ish / transient (one-off is rare and order-attached). Stefan flips if he wants different semantics.
  13. Detail-page "Origin" panel — show the created_by_stringer_id == self ? 'you' : '(another stringer)' line, or omit cross-tenant rows entirely? Proposed default: show "(another stringer)". Tells the user "this isn't yours to edit" without revealing identity. Removing the row entirely would leave the user wondering why Edit isn't there.

Cross-references