UI-SPEC — v4.62 Tags on the in-app feedback board
Phase: v4.62 Phase 2 (OBJ-3791) · Implements: SPEC-01..SPEC-06 · Consumed by: Phase 4 (OBJ-3793) Mockups: .planning/phases/v4.62-phase-2-feedback-tags/mockups/ (4 HTML + 24 stills + 2 motion clips)
Scope
Tags become a second way to narrow the feedback board and an optional field on the create form. Four surfaces change: ionic_frontend/src/views/Feedback.vue (filter rail, active-filter row, zero-result branches), components/feedback/NewFeedbackModal.vue (the picker), components/feedback/FeedbackCard.vue (chips + a card restructure the chips require), and components/feedback/FeedbackDetailModal.vue (full chip list + an admin tag editor). One new component ships: TagFilterSheet.vue. Orion's task package is the root comment on OBJ-3791.
No code ships from this phase. Every class named below resolves against design_system/tailwind-tokens.cjs; no hex appears anywhere in this document by design.
Component / screen inventory
| Surface | Disposition |
|---|---|
views/Feedback.vue | modified — filter rail restructured, active-filter row added, zero-result branches rewritten |
components/feedback/FeedbackCard.vue | modified — tag row added; card root restructured (see §7) |
components/feedback/NewFeedbackModal.vue | modified — picker added after Category |
components/feedback/FeedbackDetailModal.vue | modified — tag row + admin tag editor |
components/feedback/TagFilterSheet.vue | new |
components/ui/AppModal.vue | reused as-is (presentation="sheet") |
1 · The ruling: category and tag are one filter rail, not two rows
Orion's first acceptance criterion. The answer is one merged filter rail with tags behind a disclosure pill in that rail — not two independent rows, and not tag-nested-under-category.
Why not two rows. Today's Row 2 (four category chips wrapping + a sort <select>) already consumes two lines at 390px. A second chip row for an open, admin-grown vocabulary would put five rows of chrome above the first card. That is the enterprise-admin-panel failure mode.
Why not nested. Nesting tags under category asserts a containment relationship that does not exist. "Streaks" is orthogonal to "Bug Report" — a streak bug and a streak feature request are both legitimately tagged Streaks. Nesting would force a category choice before a topic choice and make the common query ("everything about streaks") two steps instead of one.
The merged rail. Category is a closed set of four; it stays inline and scannable. Tags are an open set that grows; they live behind a Tags pill that sits as the leading item of the same rail, so the board carries one filter idiom, and a tag vocabulary that grows to 40 never costs the board a row.
Phone-width layout — the row budget, stated
At 390px inside ion-padding the content width is 358px. Three persistent rows, plus one conditional:
┌───────────────────────────────────────────────┐
│ [+ New Idea] (full-w) │ header
│ [🔍 Search feedback…] │ search
├───────────────────────────────────────────────┤
│ [ ≣ Board | ⫴ Roadmap ] [Most Voted ▾] │ Row A
│ [ All | My Posts | My Votes ] │ Row B
│ [🏷 Tags 2 ▾] │ (Features)(Improvements)(Bu…→│ Row C ← one scrollable rail
│ (Streaks ×)(Coach ×) Clear tags │ Row D ← only when tags are active
├───────────────────────────────────────────────┤
│ card · card · card │
└───────────────────────────────────────────────┘lg: and up, Row C stops scrolling and wraps (lg:flex-wrap); Rows A and B are unchanged.
This is a row fewer than today at 390px, not one more. Today Row 1 (flex-wrap, view toggle
- post-filter tabs) wraps to two lines and Row 2 wraps to two more. Moving Sort up beside the view toggle and giving the post-filter tabs their own full-width
flex-1row recovers the space the tag pill spends. That is the whole argument for why "both at once" fits — verified inboard-tags-mobile-light.webp/-dark.webp, not asserted.
Rail mechanics. -mx-4 px-4 flex flex-nowrap overflow-x-auto lg:mx-0 lg:flex-wrap lg:px-0. The negative-margin bleed is deliberate: a chip clipped at the viewport edge is the signal that the rail scrolls. A hairline <span class="h-5 w-px shrink-0 bg-border" aria-hidden="true"> separates the Tags pill from the category chips — the pill opens a sheet, the chips toggle in place, and the divider is what says those are different kinds of control.
Filter semantics — any-of, and said out loud
Selecting two tags returns posts carrying either — not both. With MAX_TAGS_PER_POST = 3 (§2), AND-semantics on two tags returns zero often enough to feel broken. There is no any/all toggle: one decision per screen. The rule is not hidden — the sheet states it in body copy ("Posts matching any tag you pick."). Tag filtering composes with category and search as AND (?tags=streaks,coach&category=bug means "bug reports about streaks or coach").
2 · MAX_TAGS_PER_POST — move it to 3
Orion's package sets it provisionally at 5 and invites this spec to move it. Move it to 3.
- 3 is the largest number a person picks without ranking. At 5 the user starts optimizing coverage — "which five reach the most people" — and that deliberation is precisely the failure the roadmap's own metric (60%+ of posts carry a tag) is trying to avoid.
- Tag-spam degrades the filter for everyone. A post carrying 5 of 12 tags matches nearly half the board's tag filters and is noise in all of them. The cap is a precision control, not a storage limit.
- It fits the surfaces. The card shows 2 + overflow, so 3 is exactly one overflow step. The detail modal renders all 3 on one line at 390px. The picker's counter reads
0/3, which is a number a person parses at a glance.
Phase 4 (or Phase 1, if it hasn't merged) carries a one-line constant change. Everything in this spec assumes 3.
3 · The ruling: users may not propose tags in v4.62
Orion flagged this as the place "admin-managed" could quietly become user-managed. No proposal mechanism ships in v4.62 — no free-text entry, no "suggest a tag" field, no proposal queue.
Why not. A proposal control implies a review path, and none is scoped: Phase 1's FeedbackTag has no status column, there is no admin review queue, and there is no accept/reject notification. Shipping a "Propose a tag" button with nothing behind it produces a control that lies — the user types a tag, nothing visible happens, and they learn the affordance is decorative. That is worse than its absence.
Why the closed set is not a dead end. The escape hatch already exists and is the feature itself: a user who wants a tag posts the idea. Two things make that honest rather than a shrug:
- The picker's footer says so in plain words: "Don't see the right tag? Post it anyway — we add tags as themes show up."
- Admins can tag a post after the fact, from the detail modal (§9). So "user proposes, admin files" is a real round trip, not a promise. This is the mechanism that makes the no-proposal ruling complete rather than a refusal.
If this is revisited later, the cheapest honest version is a proposed status on FeedbackTag plus a row in the existing admin tag surface — not a free-text field on the create form. Recorded here so a future phase doesn't re-derive it.
4 · The anti-vanity / PBC ruling
Tags are a retrieval mechanism. They must not become a surface that rewards posting volume or manufactures an audience. Ruled out, explicitly and permanently:
| Ruled out | Why |
|---|---|
| Tag follower counts, "Follow this tag" | Manufactures an audience metric; converts a filing decision into a reach calculation. |
| Any post-count badge on a tag chip in consumer UI — picker, sheet, card, detail modal | A visible count makes tag choice a popularity bet. Someone filing picks the biggest tag, not the right one, and the filter degrades for everyone. |
| "Trending tags" / "Popular tags" / recency- or usage-weighted ordering | Directly rewards posting volume. See ordering rule below. |
| Tag-based notifications ("3 new posts in Streaks") | An engagement loop. Sessions are ~10 min/day; nothing here earns a push. |
| Per-user tag leaderboards | Ranks people. |
| Colour or rarity tiers on tags implying status | Gamification belongs to streaks/XP/badges, which measure the user's own action. A tag measures nothing the user did. |
| A per-tag destination page with its own feed and header | Tag filtering is a state of the board, not a place. A destination invites a vanity header; a state cannot carry one. |
| A "Hot" option in the sort dropdown | Same vector at the sort layer. Sort stays Most Voted / Newest. |
Ordering rule — the enforcement point. Tag order in the picker and the sheet is admin position, then alphabetical by name. Never usage-derived, never recency-weighted, in any surface, under any flag. This is where "trending" would otherwise arrive without anyone deciding to add it — an ordering is a ranking whether or not it is labelled one.
One carve-out: the admin tag surface (Phase 1, OBJ-3790) may show post counts. That is operational data for curation, seen only by admins, and it never reaches a consumer surface.
5 · Design tokens
| Surface | Token / class | Note |
|---|---|---|
| Tag chip, read-only (card, detail) | rounded-full border border-solid border-border bg-transparent px-2 py-0.5 text-2xs font-medium text-muted-foreground | Outlined. See ruling below. |
Tag overflow chip +N | same, font-semibold | Non-interactive <span>. |
| Tag chip, selectable — unselected | border-border bg-background text-muted-foreground hover:border-border/80 | Lifted verbatim from the live category chip, Feedback.vue:84-96. |
| Tag chip, selectable — selected | border-primary-accessible bg-primary/10 text-foreground dark:bg-primary/20 | Same source. |
| Tag chip, selectable — at cap, unselected | add border-dashed, aria-disabled="true" | Never opacity-50. See ruling below. |
| Active filter chip (board) | min-h-11 gap-1.5 rounded-full border border-solid border-primary-accessible bg-primary/10 px-3 py-1.5 text-xs font-medium text-foreground dark:bg-primary/20 | Whole chip is the remove control. |
Tags pill — inactive | border-border bg-background text-foreground | |
Tags pill — active | border-primary-accessible bg-primary/10 text-foreground dark:bg-primary/20 | Count in font-bold. |
| Rail divider | h-5 w-px shrink-0 bg-border + aria-hidden | |
| Sheet root | .glass-sheet + rounded-t-2xl | Per desi-design-context §7b — no border/shadow of its own. |
| Sheet live count | text-xs font-medium text-primary-accessible | |
| Picker counter — at cap | text-accent-accessible font-semibold | Emphasis without dimming anything. |
| Refining progress bar | h-0.5 rounded-full bg-muted track, w-1/3 animate-pulse bg-primary fill |
Why the read-only chip is outlined, not filled. It sits inches from the category pill (bg-muted, filled) and the status badge (filled, coloured). A third filled pill on the same card makes three things that look like one thing. Outline versus fill is the cheapest non-colour differentiator and survives both themes. No icon and no # prefix — # reads as a social-media hashtag, which is the exact idiom §4 rules out.
Why at-cap chips are not dimmed. opacity-50 over text-muted-foreground measures below the 4.5:1 floor in both themes (desi-design-context §13). axe-core caught this in refine cycle 1 of this spec's own mockups, at 5 nodes per viewport. border-dashed + aria-disabled carries the same meaning at full ink.
6 · Typography
| Element | Font | Class |
|---|---|---|
| Sheet title | Outfit | font-display text-lg font-semibold |
| Sheet body / helper | Inter | text-sm text-muted-foreground |
| Sheet live count | Inter | text-xs font-medium |
| Sheet row label | Inter | text-sm font-medium text-foreground |
| Picker label | Inter | text-sm font-semibold text-foreground |
| Picker helper | Inter | text-xs text-muted-foreground |
| Selectable chip | Inter | text-xs font-medium |
| Read-only chip | Inter | text-2xs font-medium |
| Empty-state title | Inter | text-lg font-semibold (<h2>) |
text-2xs is the 11px legibility floor and a design_system extension — write text-2xs, never text-[11px].
7 · FeedbackCard — 2 chips, then +N, on a full-bleed bottom row
Chips before truncation: 2. Overflow affordance: a non-interactive +N chip.
Where the row goes. Not inside the content column and not in the metadata row. At 390px the content column measures ~163px once the vote pill (52px), the status badge (~72px), and two 12px gaps are subtracted — not enough for two chips. The tag row is instead a full-bleed sibling below the three-column row, giving the full 311px card interior. There is no indent to keep in sync with the vote pill's width (desi-design-context §4 — derived geometry).
┌────────────────────────────────────────────┐
│ ┌────┐ Title of the post [Planned] │
│ │ ▲ │ Description, two lines clamped… │
│ │ 34 │ (Feature Request) 💬7 2d by Priya │
│ └────┘ │
│ (Streaks)(Goals)(+1) │ ← full-bleed, only when tags exist
└────────────────────────────────────────────┘The chips are not interactive, and neither is +N. The card is already one large activation target with a vote button inside it; a third class of tap target in a 311px row produces mis-taps, and "tap a tag to filter" is already served by the rail. Phase 4 should not add it. The full tag list is in the detail modal.
Card restructure this requires (and the a11y debt it closes). The live card is role="button" tabindex="0" on the root with an interactive vote <button> inside it — axe nested-interactive, serious, confirmed in this spec's first render pass. Adding a tag row under it makes the shape worse. The card becomes:
- Root: plain
<div class="relative flex flex-col gap-2 rounded-2xl glass-card p-4">— norole, notabindex. - Inner row:
flex items-start gap-3.items-start, not the default stretch — the vote pill currently stretches to the full card height, and with a tag row added it becomes a 200px gold block dominating the card. - Title becomes
<h2>wrapping a<button class="rounded text-left after:absolute after:inset-0 after:rounded-2xl after:content-[''] focus-visible:ring-2 focus-visible:ring-ring">— the stretched-link pattern. The whole card stays clickable; exactly one control is in the accessibility tree. - Vote button gains
relative z-10so it sits above the stretched pseudo-element. - Tag row carries
id="<postId>-tags"and the title button carriesaria-describedby="<postId>-tags", so tags are announced with the post. A leading<span class="sr-only">Tagged </span>prefixes the row, and overflowed tags render assr-onlytext — a screen-reader user hears all three names, never "+1".
<h2> (not h3) because the card sits directly under the page <h1>; h1 → h3 skips a level. The page <h1> itself changes from hidden lg:block to sr-only lg:not-sr-only — today the mobile board has no level-one heading at all. Both are one-line changes on files Phase 4 is already editing; both were surfaced by axe on this spec's own mockups.
8 · TagFilterSheet.vue — the picker's filter-side twin
<AppModal presentation="sheet">. Native Ionic sheet motion, duration-0 under prefers-reduced-motion — gate g10 is satisfied by routing through AppModal, never by hand-binding the card curve to a sheet.
Filters apply live, as each row is toggled. The Done button only dismisses. The problem this creates — the board is behind the sheet, so the user can't see the effect — is solved by a live count in the sheet header (3 ideas match), which updates as rows toggle. That line is the whole reason live-apply works here; without it, live-apply is invisible and Done would have to be an Apply button.
Structure, top to bottom: grab handle · <h2> "Filter by tag" · helper "Posts matching any tag you pick." · live count · search input (renders at ≥10 tags) · scrollable row list · sticky footer [Clear all] [Done].
Rows are real <input type="checkbox" class="sr-only"> inside a <label>, not role="checkbox" buttons. A scannable vertical list earns the native control: Space toggles for free, and the row announces "Streaks, checkbox, checked" with no ARIA to maintain. This deliberately differs from the picker's chips (§9), which stay role="checkbox" buttons to match the live category-chip idiom in Feedback.vue:84-96 — a compact horizontal chip group and a scannable list are different controls. Both announce identically.
Filtering the list narrows rows only. A tag selected outside the current filter text stays selected and stays counted — the footer's Clear all is the only thing that deselects.
Breakpoints: sheet opens at 0.6, expandable to 0.95. lg: and up the same component renders via AppModal's card presentation, anchored centre.
9 · The picker in NewFeedbackModal
Sits after Category, before the action row. Optional, and it must not make a three-field form feel like a five-field form.
Collapsed is the default: the first 6 tags (admin position, then alphabetical) plus a +N more chip. Six wraps to exactly two rows at 390px. Eight wrapped to three, which is where an optional field starts reading as required work — that was refine cycle 1 on this spec's own mockup, not a guess.
Overflow behaviour. Tapping +N more reveals the full set in place, instantly. No height animation: a variable-height region has nothing worth animating, and animating it is both janky and a prefers-reduced-motion question nobody needs to answer. The chip's label becomes Show fewer and aria-expanded flips. At ≥10 tags the expanded state pins a Find a tag input above the group; above 20 tags the group becomes max-h-52 overflow-y-auto.
At cap (3 of 3). Unselected chips take border-dashed + aria-disabled="true" at full ink (never dimmed — §5). The counter turns text-accent-accessible. The helper line swaps from "Pick up to 3 so the right people see it." to "That's 3 — plenty. Swap one to change it." No toast fires; a toast for a cap the user can see is noise. Tapping an at-cap chip is not a silent no-op — it re-announces the live region ("3 of 3 tags selected. Remove one to swap.").
Pre-fill from the active board filter. If the board has tag filters active when New Idea is tapped, those tags are pre-selected in the picker (up to the cap, admin order). The app already knows what the user is looking at; asking again is the friction this spec exists to remove. It also makes the "I posted and it vanished from my filtered view" case rare instead of routine — see §11.
Detail modal
- All tags, one row, below the status/category badges. Three chips fit on one line at 390px; there is no overflow state at
MAX_TAGS_PER_POST = 3. - No tag row renders when the post has none.
- Admin tag editor — a block mirroring the live
Admin: Change Statusblock (FeedbackDetailModal.vue:269-283): labelAdmin: Tags, helperUp to 3., the same selectable chip group as the picker. Each toggle saves immediately and fires onetoast.success('Tags updated.')(2.5s, no haptic). This is the mechanism §3's no-proposal ruling depends on.
10 · State catalog
Filter rail
- Default:
Tagspill inactive, no count, chevron down. - Active: pill takes the selected treatment, count in
font-bold, active-filter row (Row D) appears below the rail. - Loading (tag list not yet resolved): the pill does not render. Not a disabled pill and not a skeleton — a control for an empty set is noise, and the rail has no fixed height to preserve.
- No tags exist: the pill does not render, permanently, until an admin creates one.
- Focus:
focus-visible:ring-2 focus-visible:ring-ring. - Hover (desktop):
hover:border-border/80on chips; the pill takeshover:bg-mutedwhen inactive. - Active/pressed:
active:scale-[0.98], 150ms. Nomotion-reduce:decoration — the global reset intheme/tailwind.cssneutralises input-state scale transforms already. - Dark:
bg-primary/10→dark:bg-primary/20; borders →border-white/10via the token. - Motion-reduced: nothing to fall back to; the rail has no entrance animation.
Tag filter sheet
- Default: rows, live count,
Clear allenabled only when ≥1 selected. - Loading: three
animate-pulserow skeletons at the row height, so the list doesn't reflow on resolve. - Empty (no tags exist): see §12. Reachable only on a race — the last tag archived while the sheet is open.
- Search no-match: "No tag by that name." +
Clear search. - Error (tag query failed): "Couldn't load tags." / "Connection's spotty. Try once more." /
Try again, matching the board's existing error block verbatim in structure. - Dark:
.glass-sheetcarries its own dark fill (0.50 vs 0.80). - Motion-reduced:
AppModalshort-circuits the sheet transition to duration-0.
Picker
Default · expanded · at-cap · pre-filled — all four rendered in tag-picker-mobile-{light,dark}.webp. Disabled: the whole group takes aria-disabled while the form is submitting, matching the existing Submit button's disabled treatment.
Card tag row
Present (1–2 chips) · overflow (2 + +N) · absent (no tags — row does not render).
Board results
See §12 for the full zero-result matrix and the refining state.
11 · Flow sequence
| From | Trigger | To | Transition + duration | Feedback (toast / haptic) | Reduced-motion fallback | Back-nav |
|---|---|---|---|---|---|---|
| Board | tap Tags pill | Tag filter sheet | AppModal presentation="sheet" — native Ionic sheet, breakpoint 0.6 | none (opening isn't an outcome) | AppModal short-circuits to duration-0 | Backdrop tap / Esc ≡ Done; filters already applied |
| Sheet | toggle a row | Sheet (count updates) + board refetch behind it | none; count is a text swap | live region "{n} ideas match."; no toast, no haptic | n/a | toggling again restores |
| Sheet | tap Done | Board, tag filters active | sheet leave, native | none | duration-0 | — |
| Board | filter changes with results already on screen | Board (refining) | 2px indeterminate bar under the rail; list keeps full contrast and stays tappable | aria-busy="true" on the list | animate-pulse is opacity-only, already within budget | — |
| Board | tap New Idea | Create modal, picker pre-filled from active tag filters | useModalAnimations enter — 250ms cubic-bezier(0.32,0.72,0,1) | none | duration-0 via AppModal | dismiss discards, existing behaviour |
| Picker | tap a tag chip | chip selected | transition-colors duration-150 | live region "{n} of 3 tags selected." | colour transition, exempt from the reset | tap again deselects |
| Picker | tap +N more | full tag set revealed in place | none — instant by ruling | aria-expanded flips | n/a — nothing animates | Show fewer collapses |
| Create modal | tap Submit Idea | Board, modal closed | useModalAnimations leave — 250ms | toast.success('Idea posted.') 2.5s, no haptic; + action Clear filters when the new post matches no active tag filter | duration-0 | — |
| Detail modal (admin) | toggle a tag | saved | none | toast.success('Tags updated.') 2.5s, no haptic | n/a | toggle again reverts |
Every duration, easing curve, toast tier, and haptic tier above is cited from .claude/skills/objectuve-throughline/references/motion-vocabulary.md. Nothing new is minted.
12 · Zero-result states — the full matrix, in precedence order
The live branch (Feedback.vue:138-161) is a two-way searchQuery ? … : …, which means a category-only zero result currently renders "No ideas here yet." and offers "Share an idea" — an answer to a question the user did not ask. Four branches, evaluated in this order:
| # | search | tags | category | Title | Body | CTA |
|---|---|---|---|---|---|---|
| 1 | ✓ | any | any | No results found. | Try a different word, or clear filters to see everything. | Clear filters |
| 2 | — | ✓ | any | Nothing tagged that yet. | Try another tag, or clear filters to see the whole board. | Clear tag filters |
| 3 | — | — | ✓ | Nothing in that category yet. | Pick another category, or clear it to see the whole board. | Clear category |
| 4 | — | — | — | No ideas here yet. | Be the first to shape what we build next. | Share an idea |
Branch 1 changes the live body copy ("clear search" → "clear filters") and gains a CTA it does not have today. Branch 3 is new. Branch 4 is unchanged. Exactly one CTA per branch — two competing CTAs on an empty state is an anti-pattern, and in branches 1–3 the user's live intent is "find posts about X", not "file a post".
Tags enabled but none created yet is a separate state and it is deliberately invisible: the Tags pill does not render at all. Not disabled, not empty-with-a-tooltip. There is no content, so there is no affordance. The only place it surfaces is inside the sheet, reachable only if the last tag is archived while the sheet is open:
No tags yet. We group ideas into themes as they show up. Keep them coming.
[Back to the board]
It has a forward action because every terminal screen inside a flow does (gate g38) — a dismiss-only empty state is a dead end.
The refining state (a filter changed while results were already rendered) is not a skeleton swap and not a dimmed list. cache-and-network flips loading true on every variable change, so today's v-if="loading" branch would flash three skeleton cards over three real ones for under 200ms on every chip tap. Instead: a 2px indeterminate bar under the filter rail, aria-busy="true" on the list, full contrast, still tappable. Skeletons stay for first load only. Dimming a list you still want people to read is the stacked-opacity anti-pattern at container scale — axe flagged it at 16–17 nodes when this spec tried it in refine cycle 1.
13 · Microcopy — every string, final
| Surface | String | Tone check |
|---|---|---|
| Filter pill | Tags | noun-label on a disclosure ✅ brief ✅ |
| Filter pill a11y (0 selected) | Filter by tag | action verb ✅ |
| Filter pill a11y (n selected) | Filter by tag, 2 selected | states reality ✅ |
| Active chip a11y | Remove Streaks filter | action verb ✅ |
| Clear-all control | Clear tags | action verb ✅ brief ✅ |
| Sheet title | Filter by tag | action verb ✅ |
| Sheet helper | Posts matching any tag you pick. | states the rule, one sentence ✅ |
| Sheet live count | 3 ideas match / 1 idea matches / No ideas match | information, not praise ✅ |
| Sheet search placeholder | Find a tag | action verb ✅ |
| Sheet search no-match | No tag by that name. | brief ✅ |
| Sheet search no-match action | Clear search | forward action ✅ |
| Sheet footer secondary | Clear all | brief ✅ |
| Sheet footer primary | Done | brief ✅ |
| Sheet empty title | No tags yet. | honest, not apologetic ✅ |
| Sheet empty body | We group ideas into themes as they show up. Keep them coming. | one sentence each, forward ✅ |
| Sheet empty CTA | Back to the board | forward action ✅ |
| Sheet error title | Couldn't load tags. | what failed ✅ |
| Sheet error body | Connection's spotty. Try once more. | recovery ✅ matches live board copy ✅ |
| Sheet error CTA | Try again | verb ✅ |
| Picker label | Tags + (optional) | matches the live Description label pattern ✅ |
| Picker helper | Pick up to 3 so the right people see it. | verb-led ✅ gives the reason, not just the rule ✅ |
| Picker helper, at cap | That's 3 — plenty. Swap one to change it. | coach-who's-a-friend ✅ no scolding ✅ |
| Picker counter | 0/3 … 3/3 | information ✅ |
| Picker expander | +6 more / Show fewer | brief ✅ |
| Picker filter placeholder | Find a tag | verb ✅ |
| Picker footer | Don't see the right tag? Post it anyway — we add tags as themes show up. | honest about the closed set ✅ forward ✅ |
| Picker live region | 3 of 3 tags selected. Remove one to swap. | states reality ✅ |
| Card tag row a11y prefix | Tagged | — |
| Detail admin label | Admin: Tags | matches live Admin: Change Status ✅ |
| Detail admin helper | Up to 3. | brief ✅ |
| Detail admin toast | Tags updated. | noun + past-tense verb ✅ |
| Create success toast | Idea posted. | replaces live Idea submitted. Thanks for the feedback! — one sentence, no empty praise ✅ |
| Create success toast action | Clear filters | only when the new post matches no active tag filter ✅ |
| Board live region | 3 ideas match. / No ideas match. | information ✅ |
| Empty branches 1–4 | see §12 |
No placeholders. Phase 4 writes no new copy; anything not in this table or §12 is a gap to route back, not a gap to fill.
14 · Accessibility
Focus order on the board: search → Clear search (when present) → Board → Roadmap → Sort → All → My Posts → My Votes → Tags pill → Features → Improvements → Bugs → Other → each active tag chip → Clear tags → each card title → each vote button → …
The filter rail is horizontally scrollable; every chip in it stays in the tab order and the browser scrolls focus into view. Do not add tabindex="-1" to off-screen chips.
Sheet: role="dialog" aria-modal="true" aria-labelledby="tag-sheet-title". Focus moves to the search input on open, or the first row when search isn't rendered. Ionic's modal traps focus. Esc and backdrop tap both close and return focus to the Tags pill.
Announcements:
- A sheet row announces
"Streaks, checkbox, checked"— native, no ARIA authored. - A picker chip announces
"Streaks, checkbox, checked"viarole="checkbox"+aria-checked. - An at-cap picker chip adds
"dimmed"/"unavailable"viaaria-disabled="true"while staying focusable — never thedisabledattribute, which would drop it from the tab order and silence the label (desi-design-context§16, rule 1). - The
+N moreexpander carriesaria-expandedand announces its own state change.
Live regions — exactly one per surface, to avoid double-announcement:
- Board: one
<p class="sr-only" role="status" aria-live="polite">owned by the results container, updated when the filter set changes (not on every re-render). The existingrole="status"on the empty-state block is removed in favour of it. - Sheet: the header count line is
aria-live="polite". - Picker: the
n/3counter isaria-live="polite".
Tap targets: every chip, row, and pill is min-h-11 (44px — the literal sanctioned floor, not min-h-[46px]). The active-filter chip's × is not a nested button; the whole 44px chip is the remove control, which is why there is no 28px target inside a 44px one.
Contrast: zero axe-core violations across 4 mockups × 3 viewports × 2 themes (24 runs) after two refine cycles. The two contrast failures found — opacity-50 at-cap chips and an opacity-60 refining list — are both fixed in this document, not waived.
Motion-reduced: the only authored motion is a 150ms colour transition on chip selection (exempt — colour and opacity are not neutralised) and animate-pulse on skeletons and the refining bar (opacity-only, within the under-200ms budget). The sheet defers to AppModal's duration-0 short-circuit. Nothing here needs a motion-reduce: variant; per desi-design-context §9, write the press scale normally and add nothing else.
15 · Prerequisite plumbing for Phases 3 and 4
Not design decisions — spec-adjacent facts that will silently break this contract if missed:
FEEDBACK_POSTS_QUERYandFEEDBACK_POST_QUERYmust both selecttags { id name slug }. Card chips render from the list query.CREATE_FEEDBACK_POST_MUTATION's returnedfeedbackPostmust selecttags { … }too, or a just-created post renders untagged until the next refetch (gate g46 — the cache-write shape must match what the list query expects).FEEDBACK_POSTS_QUERYneeds atags: [String!]variable (slugs, any-of).- The
feedbackTagsquery is admin-gated in Phase 1. Consumer surfaces need an unauthenticated-or-any-user read of the pickable (non-archived) tag list, ordered byposition, name. Without it neither the picker nor the sheet can populate.
Item 4 is the one that blocks Phase 4 outright if Phase 3 doesn't carry it.
16 · Storybook story brief
ionic_frontend/src/components/feedback/TagFilterSheet.stories.ts Variants: Default · WithSelection · Searching · SearchNoMatch · Loading · EmptyNoTags · Error · ManyTags (30, exercises scroll + the ≥10 search threshold). argTypes: tags (array), selected (array), matchCount (number), loading (bool), error (bool), isOpen (bool). play(): toggle two rows, assert the live count text changes and Clear all becomes enabled.
NewFeedbackModal.stories.ts (extend) Add: WithTagPicker · TagPickerExpanded · TagPickerAtCap · TagPickerPrefilled · NoTagsAvailable (picker absent). argTypes: add availableTags, preselectedTags. play(): select three chips, assert the 4th is aria-disabled="true" and still focusable, and that the helper line swapped.
FeedbackCard.stories.ts (extend) Add: WithOneTag · WithTwoTags · WithOverflow (3 tags → +1) · NoTags. play(): assert the title button's aria-describedby resolves to a row containing all three tag names, and that the vote button and title button are the only two interactive elements in the card.
Feedback.stories.ts (extend) Add: TagFilterActive · ZeroResultsTagFilter · ZeroResultsCategory · Refining.
17 · Acceptance criteria for Codi
- [ ] Renders at 375px and 390px with no horizontal scroll (the filter rail's own
overflow-x-autois intentional and does not count). - [ ] The filter stack is three persistent rows at 390px, plus the conditional active-filter row — verified against
board-tags-mobile-light.webp. - [ ]
MAX_TAGS_PER_POSTis 3 everywhere: model constant, picker counter, admin editor. - [ ] Card shows at most 2 tag chips + a
+Nspan; chips and+Nare non-interactive. - [ ]
FeedbackCardroot carries norole="button"; the title is the only stretched-link control and the vote button carriesrelative z-10.nested-interactivedoes not fire. - [ ] Card titles are
<h2>; the page<h1>issr-only lg:not-sr-only.heading-orderandpage-has-heading-onedo not fire at any viewport. - [ ] Vote pill uses
items-start, not stretch. - [ ] Picker collapsed shows 6 chips +
+N more; expand is instant with no height transition. - [ ] At-cap chips use
border-dashed+aria-disabled="true"— no opacity modifier on any text token anywhere in this change. - [ ] Picker pre-fills from active board tag filters.
- [ ] Sheet rows are native
<input type="checkbox">; picker chips arerole="checkbox"buttons. Both announce checked state. - [ ] Sheet applies filters live and shows a live match count.
- [ ] All four zero-result branches from §12 are implemented, in that precedence order, each with exactly one CTA.
- [ ] The refining state is a progress bar, not a skeleton swap and not a dimmed list.
- [ ] Every microcopy string matches §13 / §12 verbatim, including the two replacements of live strings.
- [ ] No tag post-count, follower count, or usage-derived ordering appears in any consumer surface. Order is
position, name. - [ ] Tag ordering is identical in the picker and the sheet.
- [ ] Every transition in §11 matches the locked motion / feedback vocabulary — no invented easing, toast duration, or haptic tier.
- [ ] Tap targets ≥44px (
min-h-11), verified withtests/unit/helpers/tapTarget.tscomputed dimensions, not class-name assertions. - [ ] WCAG AA verified in both themes; axe-core clean.
- [ ] Storybook covers every variant in §16, with the three
play()assertions.
18 · Open questions
None blocking. Two items routed rather than decided here:
MAX_TAGS_PER_POST = 3needs to reach Phase 1 (OBJ-3790) if it hasn't merged; otherwise it is a one-line change in Phase 4.- §15 item 4 (a non-admin-gated pickable-tag read) is a Phase 3 API requirement, not a design decision. It blocks Phase 4 if it's missed.