UI-SPEC — v4.62 The public feedback board
Phase: v4.62 Phase 5 (OBJ-3794) · Implements: SPEC-07..SPEC-13 · Consumed by: Phase 6 (the public read path) and Phase 7 (feedback_site/ + hosting) Mockups: .planning/phases/v4.62-phase-5-public-board/mockups/ (5 HTML + 24 stills + 2 motion clips, axe-clean) Amended: OBJ-3861 — §3.5a adds the create form's category row, closing the silent 'feature' default Roy flagged in Phase 7 review. §§3.5, 11, 13, 15, 18, 19 updated to match. Reads on from: docs/ui-specs/v4.62-feedback-tags.md (Phase 2) — the tag rail, the chip treatments, and MAX_TAGS_PER_POST = 3 are inherited from it, not re-derived here.
Scope
feedback.objectuve.com — a public, crawlable, no-account-required board showing the same FeedbackPost records the in-app board shows. One list screen, one post-detail screen, one auth overlay, one create form. No code ships from this phase.
The reader this is for has never heard of Objectuve. They want two answers before they hand over an email address: do these people listen, and is the thing I need on the list. Every ruling below is decided against those two questions.
This is not the Ionic app. No IonPage, no IonContent, no Capacitor, no haptics, no useToast, no routeAnimated. It is a browser page, and the closest shipped precedent in this repo is admin_dashboard/ — Vite + Vue 3 + @clerk/vue + Apollo, its own useTheme, no toast layer. Where a rule below differs from the app, that is why.
Component / screen inventory
| Surface | Disposition |
|---|---|
feedback_site/ list route / | new |
feedback_site/ detail route /ideas/:publicId/:slug? | new |
| Auth overlay (one component, three copy sets) | new |
| Create form (post-auth) | new |
| Invitation footer card | new |
| Site header / footer chrome | new |
admin_dashboard/src/composables/useTheme.ts | reused verbatim — lift, don't rewrite |
ionic_frontend/src/styles/clerk.css | reused as the pattern — the .cl-* + html.dark override set |
| Tag chip, status badge, filter rail | reused from Phase 2's spec, re-tokenised for this surface |
ionic_frontend/src/views/Feedback.vue and its four components | untouched — this phase changes nothing in the app |
1 · The ruling: a private_mode user's name never appears on this board
Ruling: anonymised. private_mode users render as a member, with no avatar and no initial.
This is the milestone's highest-risk decision and Orion asked for the argument, not the verdict. Here is the argument.
1.1 What the user actually agreed to
The consent record is the toggle's own copy, and it is short enough to quote in full (components/settings/PrivacyControlsCard.vue:58-62 desktop, :120-121 mobile — the mobile hint is the shorter PRIVATE_MODE_HINT at :173):
Private profile Hides you from username search, so no one can find you that way — you can still add allies yourself.
Sitting under a card headed "Social & discovery / Decide how social Objectuve feels and whether others can find you." (:9-10).
Two things follow.
The label and the mechanism do not say the same thing. The label is "Private profile" — a broad claim about how visible the person is. The hint is narrow — username search, one surface. A user sets an expectation from the label they tapped, not from the implementation detail underneath it. Someone who turned on a thing called "Private profile" and later found their name on a Google-indexed page has had the label's promise broken, whatever the hint technically said.
Neither string contemplates the open web at all. There is no sentence here that a reasonable person could read as "…but your name may appear on a public website." The record is silent. And consent cannot be inferred from silence — particularly not retroactively: private_mode shipped 2026-07-06 (db/migrate/20260706000001_add_privacy_controls_to_users.rb), long before this surface existed. Nobody who flipped that switch was making a decision about a public board, because there wasn't one.
1.2 What this codebase already does with public attribution
There is exactly one shipped surface in this repo that puts a user's name on a public, unauthenticated, crawlable page: views/PublicGoal.vue, which renders ${firstName} ${lastName} (:421) behind Resolvers::GoalQueries#public_goal (goal_queries.rb:36-41) — the one feedback-adjacent resolver with no require_auth!.
It only ever renders a goal whose visibility the owner explicitly set to everyone, against a column that defaults to private (models/goal.rb:31, :43). Public attribution in this codebase has always followed an affirmative, per-item act by the person being attributed.
A feedback post carries no such act. It was written into a logged-in-only board. The precedent does not extend to it — it argues against extending to it.
1.3 The asymmetry of being wrong
Publishing a name that shouldn't have been published is not reversible. The page is built to be found; once it is indexed, it is in search caches, the Wayback Machine, and whatever scrapes public pages. Withholding a name that could have been shown costs one line of social proof per row and is reversible in a single deploy, or by a later explicit opt-in.
When one error is permanent and the other is a one-line revert, you take the reversible one. That is the whole calculus, and it does not require anyone to agree about what the hint meant.
1.4 The counter-argument, stated and answered
The hint only ever promised username search. Publishing the name is technically within what was disclosed.
Technically-within-what-was-disclosed is the definition of a dark pattern. MISSION.md:21 and MISSION.md:151 ban them by name. A Public Benefit Company whose defence of a privacy decision is the narrow reading of its own hint text has already lost the argument it is making. We do not get to be right on a technicality against our own users.
1.5 The anonymised string, and the leak it has to avoid
a member. Byline renders by a member; the detail page renders Posted by a member.
Not Anonymous — which reads as a deleted account or a throwaway, and quietly devalues the post. Not Private member, Hidden, or anything naming the setting, for a specific reason:
A distinct string for
private_modeusers is itself a disclosure. The in-app board renders the same post asby Priya Sharma(FeedbackCard.vue:77-81). If the public board rendered it asby a private member, any signed-in user could put the two boards side by side and learn which specific people have private mode switched on. The fix would have created a new leak one layer over.
So: one string, shared with every other case where a display name can't be rendered. That case already exists — first_name, last_name, and username are all nullable (db/schema.rb:1510, :1513, :1531; models/user.rb:95 validates uniqueness allow_nil: true). A user with none of the three is indistinguishable from a private_mode user on this surface, by design.
Avatar: none. The neutral user glyph in a bg-muted circle. This is the same treatment a member with no uploaded photo gets, so it discloses nothing either.
1.6 What Phase 6 must not be able to do
The ruling is only real if the public GraphQL type cannot express the alternative. Phase 6's PublicFeedbackAuthorType should expose one field — a pre-resolved displayName: String! — and resolve it server-side. Not firstName, not lastName, not privateMode, not a boolean the client branches on. If the client can see the inputs, some future client will render them.
2 · The ruling: everyone else renders as first name + last initial
Ruling: Priya S. — first name, last-initial, full stop. No surname. No username. No profile link. Avatar only if the user uploaded one. Applies to every post, existing and new.
2.1 Why not the full name the in-app board shows today
The in-app card renders ${firstName} ${lastName} (FeedbackCard.vue:77-81), and so does the detail modal (FeedbackDetailModal.vue:119-122). That was written for an audience of signed-in users. This milestone changes the audience, not the content — and the audience change is the entire question.
Full name is the one option that is searchable. "Priya Sharma" Objectuve is a findable pair that joins a real person to a goal-tracking app, a bug they hit, and whatever they said about their own life in the post body. "Priya S." is not that pair. Dropping the surname to an initial removes the search-engine join while keeping the human signal completely intact — the board still reads as written by people. That trade is the whole ruling, and nothing else on the list gets both.
2.2 Why not username
username is nullable and has no presence validation (models/user.rb:95 — uniqueness: { case_sensitive: false }, allow_nil: true). A username scheme has a null branch for an unknown share of the user base, and the only sensible fallback from it is… a name. Worse, usernames are frequently a handle the person reuses across platforms, which makes them more identifying than a first name, not less — and they were chosen for a logged-in context too.
2.3 Why not avatar-only
An avatar is usually a face. A face on a crawlable page is more identifying than Priya S. and is reverse-image-searchable. Avatar-only is the option that looks most private and is least private. Ruled out.
2.4 Why not anonymise everyone
It answers the privacy question by destroying the surface's only evidence that a human community exists, which is the acquisition job this board is for. It also over-corrects: a first name and an initial on a public product-feedback board is squarely inside what a reasonable person expects — it is what every comparable board does, and it is less than the full name this app already shows its own users.
2.5 Retroactive application, and the one direction it's safe in
Applies to all posts, existing and new. This is only defensible because the change is strictly protective: there is no user who is worse off having their surname withheld. Narrowing an existing disclosure needs no consent; widening one does. If the ruling had gone the other way — publish full names — it could only have applied to posts written after a disclosure shipped, and that asymmetry is itself evidence that narrowing is the correct default.
A user who objects to Priya S. appearing at all has two paths, both of which already exist and neither of which this phase builds: switch on Private profile in Settings (which now also anonymises them here — §1), or delete the post.
2.6 No profile links, anywhere, ever
A byline that links to a profile turns the board into a people directory and re-creates exactly the findability private_mode exists to prevent — for everyone, not just for the people who opted out. The byline is plain text. The avatar is not a link. There is no hover card.
2.7 Official team comments
FeedbackComment#is_official exists on the model and the GraphQL type (types/feedback_comment_type.rb:9) and is rendered by no consumer surface today. This board is where it matters most: a team reply is the single strongest evidence that someone read the post, which is literally the reader's first question.
Ruling: official comments render as Objectuve Team, never an individual staff member's name. One accountable voice, no individual staff exposure, and it scales as the team changes. Treatment is in §9; it is the most visually prominent element on the detail page by design.
3 · The auth wall — the moment, the mechanism, and the copy
Ruling: the wall sits at the write, never at the read, and it preserves the reader's intent across the trip.
3.1 The mechanism, in one sentence
Every write affordance is fully rendered and fully tappable for a signed-out reader. Tapping one opens a small overlay that names the action they just took and offers sign-in; on return, the action they wanted completes by itself.
That last clause is the whole ruling. An auth interruption that forgets what you were doing is a gate — you pay the toll and then pay the effort again. One that finishes your action for you is a door. Mechanically:
- On tap, the intended action is held as a pending intent — the action kind plus the post's
publicId— insessionStorage, with the Clerk return URL pointing back at the exact post. - On return, the intent is read, executed once, and cleared unconditionally — on success, on failure, and on any navigation that isn't the immediate post-auth return. It does not survive a tab close, and it never fires late (gate g23).
- The reader lands back on the post they were reading, scrolled to where they were. Never a welcome screen, never an app-download prompt, never a "you're in!" interstitial (gate g48 — and this is precisely the detour a growth instinct would add here).
3.2 What is never allowed to gate the read
Written as a ruling so nobody has to re-litigate it:
| Ruled out | Why |
|---|---|
| Any modal on page load | The reader has read nothing; there is nothing to convert on. |
| Scroll-depth or timed modal | Manufactured interruption, no user intent behind it. |
| Exit-intent overlay | Same, plus it punishes leaving. |
| Fade, blur, or truncate after N posts | Hiding content already being read is the dark pattern this ruling exists to prevent. |
| "Sign in to see the rest" | As above. |
| Email capture before content | Inverts the trade the reader came for. |
| App-install interstitial on mobile web | The classic mobile-web dark pattern, and this reader has no reason to want an app yet. |
A second ask after Not now | One ask per intent. Declining is a complete answer (gate g51). |
3.3 The three moments
| Action | Trigger | Overlay title | Body | On return |
|---|---|---|---|---|
| Vote | the vote pill on a card or the detail page | Add your vote | Votes are one per person, so we need to know it's you. Takes about 20 seconds. | Vote applies, pill fills, count increments. No toast. |
| Comment | Join the thread (the row that replaces the compose box) | Join the thread | Comments show your first name and last initial, so they need an account. Takes about 20 seconds. | Compose box renders in place of the invitation row and takes focus. |
| New post | New idea in the toolbar, or Post an idea in the invitation footer | Post your idea | We'll tell you when it moves. Takes about 20 seconds. | Create form opens (§3.5). |
Shared across all three: primary Continue · secondary Not now · footer We'll only email you when something you voted on moves.
The title names the action the reader just took, which is what makes the overlay read as a continuation rather than an interception. The body's second sentence is the honest-cost line — it states the price up front instead of discovering it three fields in. The footer is the only promise made about email, and it is a real one: it is the reason an account is worth having here, not a reassurance bolted onto an ask.
The comment body deliberately tells the reader what will be published under their name before they have an account, not after. That is the same disclosure the create form carries (§3.5), moved to the first moment it is relevant.
3.4 Clerk
@clerk/vue, wired as in admin_dashboard/src/main.ts:25-27. Appearance overrides follow ionic_frontend/src/styles/clerk.css — the .cl-formFieldInput / .cl-header / .cl-footerItem set plus its html.dark variants. The Clerk surface is hosted, so the overlay's Continue hands off to it rather than embedding a form inside the overlay; the overlay is the invitation, Clerk is the form.
3.5 The create form, and the disclosure that belongs in it
Post-auth. Two fields, one of them optional, plus a pre-answered category row (§3.5a) — the form is short because the board wants the idea, not a ticket, and the one value the form has to supply on the reader's behalf is shown rather than assumed.
| Field | Label | Placeholder |
|---|---|---|
| Title (required) | What would you change? | One sentence is plenty |
| Body (optional) | Why does it matter? + (optional) | What you're trying to do, and what gets in the way. |
Between the fields and the buttons — inside the form, not as a footnote under it — a bg-muted block with a shield glyph:
This board is public and shows up in search. Your post will read by Priya S. — first name and last initial, never your full name or your email. Turn on Private profile in Settings and it reads by a member instead.
The name in that sentence is the reader's own, interpolated. Showing someone an abstract rule about attribution is not disclosure; showing them the exact string that will appear is. It also makes the Private-profile setting discoverable at the one moment it is relevant, which is the honest version of a privacy control.
Actions: Post it (primary) · Cancel (ghost).
3.5a The category row — the ruling, and why it is a chip row and not a select
Decided in OBJ-3861, against the gap Roy flagged in Phase 7 review: CreateIdeaForm.vue:44 sent category: 'feature' silently, because §3.5 as written specified two fields and Feedback::CreateFeedbackPost requires a category. Codi was right not to invent the control. The resolution is that the control should exist.
The ruling: an explicit, pre-answered category row, directly under the title field.
Why the silent default could not stay
§4's governing rule — a control must never assert something about the reader that we don't know yet — was written about the vote pill. It applies just as hard one step over: the form was asserting something about the reader's post that nobody had said. And §8.3 hides the category badge on the card whenever it is feature, so the assertion was invisible at both ends — no control when it was written, no label when it was read.
The consequence is not cosmetic. Row B's category rail is a real filter over a real column. With a silent feature default:
- every public post lands in
Features, including every bug report; Bugsrenders §12 branch 4 —Nothing in that category yet.— on a board that in fact contains bugs;- and a stranger's bug report, which is the single highest-value thing a public board collects, is triaged as a feature request forever.
Option 1 from the ticket — keep the default and add a static "Posted as a Feature" label near the submit button — is the worst of the three. It discloses the misfiling without offering the fix. A control that explains the wrong answer is not more honest than one that hides it; it is a control that lies more loudly.
Why a chip row rather than the <select> the in-app modal uses
NewFeedbackModal.vue:145-153 uses a labelled <select>. This spec does not inherit it.
- A
<select>on mobile web opens the native picker — a full-screen system sheet over a form that the reader reached through an auth round trip. §5 already ruled against stacking surfaces on this board; this would stack one the OS controls. - Row B has already taught the reader this vocabulary, as chips, twenty pixels up the page. Reusing the shape makes the control self-explaining: this chip is the rail I'll show up in. A
<select>would be a second presentation of the same four values on the same screen. - Pre-selected means the form still asks for exactly one decision. The category is answered before the reader arrives; changing it is a correction, not a question.
Post itstays gated on the title alone — the category can never block a submit.
The seeding rule
The row opens on the board's active category filter, falling back to Feature.
BoardList.vue:41 already holds category from the query string, and the create form mounts inside that same view (BoardList.vue:299). A reader who filters to Bugs, finds nothing, and taps New idea has stated their category as clearly as anyone ever will. Filing that post as a feature — and dropping it out of the very view they were looking at — is the one case where the silent default was reliably, demonstrably wrong.
Fallback stays Feature: it is the board's stated default (§8.3) and the honest majority case.
Markup and tokens
A <fieldset> of native radios. Not role="checkbox" chips — Row B's chips are a multi-select filter; this writes one value, and native radios carry arrow-key roving focus and the Feature, radio button, 1 of 4, selected announcement for free.
| Part | Classes |
|---|---|
| Fieldset | m-0 border-0 p-0 |
| Legend | p-0 text-sm font-semibold text-foreground — same weight as the two field labels, because it is their peer |
| Row | mt-1.5 flex flex-wrap gap-2 |
| Input | peer sr-only — wrapped per option, so peer-checked: can't leak across the general-sibling combinator |
| Chip, unselected | tick inline-flex min-h-11 cursor-pointer items-center rounded-full border border-solid border-border bg-background px-3 text-xs font-medium text-muted-foreground hover:border-border/80 |
| Chip, selected | peer-checked:border-primary-accessible peer-checked:bg-primary/10 peer-checked:text-foreground dark:peer-checked:bg-primary/20 |
| Chip, focus | peer-focus-visible:ring-2 peer-focus-visible:ring-primary-accessible — the ring must live on the <label>; the input it belongs to is sr-only |
Every value is Row B's chip() treatment verbatim (_build.mjs:139-144). Nothing is invented, no token is minted.
flex-wrap, never overflow-x-auto — the one deliberate divergence from Row B. A chip clipped at the viewport edge is a discoverable scroll cue in a filter rail, where losing an option costs nothing. In a form it is an option the poster cannot see and therefore cannot pick. At 390px the four chips fit on one line inside the card; the wrap exists so a font-metric difference degrades to two lines instead of a hidden Other.
Placement, and why not after the body
Directly under the title, above the optional body. The category is a property of the thing the title names, and §3.5 has already ruled that the disclosure block sits between the fields and the buttons — putting the row after the body would either displace the disclosure or wedge the row between the disclosure and the submit, both wrong. It also means a reader who skips the optional body still passes the category on the way to Post it.
Labels
Singular — Feature · Improvement · Bug · Other — matching the card badge (§8.3), not Row B's plural filter chips. The badge and the chip name the same stored value; a third name for it would be exactly the drift §19 item 2 is already filed against.
Focus order, create form
title input → category radios (one tab stop; arrows move within) → body textarea → Post it → Cancel. The disclosure block is static text and takes no stop.
4 · The anonymous state of every identity-implying affordance
The governing rule is desi-design-context §14, re-read for identity: a control must never assert something about the reader that we don't know yet. Before auth resolves, we do not know whether this person has voted.
| Affordance | Signed out | Note |
|---|---|---|
| Vote count | renders, exact | Aggregate, not identity. |
| Vote state | absent, not empty — the pill takes the unvoted treatment and is labelled Add your vote | It is not claiming "you haven't voted." It is offering an action. |
| Vote pill, after auth resolves and the reader had voted | flips to the voted treatment, silently | No toast, no animation beyond the 150ms colour tick. Correcting our own ignorance is not an event to celebrate. |
| Comment count | renders, exact | |
| Comment compose box | not rendered | Replaced by the invitation row (§6). A disabled textarea is a control that lies. |
My Posts / My Votes filter tabs | not rendered at all | A filter over a set we can't compute is not a disabled control, it is an absent one. |
| Author byline | §1 / §2 | |
| Author avatar | uploaded photo, or the neutral glyph | Never a link. |
Sign in (header) | a text link, never a filled button | A filled CTA in the header competes with the board's own actions and reads as a wall. |
| Roadmap / kanban view | does not exist publicly | §7. |
| Admin status editor, admin tag editor | do not exist publicly |
The vote pill's aria-label is the enforcement point. Signed out it is Add your vote; it is never Remove vote and never You have voted. A screen-reader user gets exactly the same honest uncertainty a sighted one does.
5 · The auth overlay — motion, and why it is not a bottom sheet
A centred card overlay with a translucent scrim. Not a bottom sheet.
A bottom sheet on mobile web implies drag-to-dismiss, which this surface does not have and cannot cheaply get, and a rise-from-bottom curve is not in the locked vocabulary — minting one to justify a sheet would be the tail wagging the dog. The centred card uses the vocabulary that does exist, so nothing here is invented.
The pair, verbatim from motion-vocabulary.md § "The overlay rule that stands in for g10":
| Element | Animation | Duration | Curve |
|---|---|---|---|
| Scrim | opacity 0 → 1 (da-fade) | 150ms | ease-out |
| Panel | opacity 0 → 1 (da-pop) | 200ms | ease-out |
| Panel | scale(.98) → none (da-pop) | 200ms | ease-out |
The scrim is translucent (bg-foreground/40), never opaque. The board stays legible underneath. This is not decoration — it is the visible proof that the overlay is not hiding content the reader was already reading.
Reduced motion — the rule that is easy to get wrong here
This surface has no global reduced-motion reset. ionic_frontend/src/theme/tailwind.css:438 neutralises every animation app-wide; nothing on a standalone web build does. Carrying the Ionic habit across ships a transform that plays at full duration for someone who asked for less motion.
So, per motion-vocabulary.md pattern (a):
- Scrim opacity and panel opacity run unwrapped — colour and opacity are not movement.
- The panel's
scale(.98)is a transform and is wrapped in@media (prefers-reduced-motion: no-preference). Underreduce, the panel cross-fades in place. The flow completes identically (gate g13). - The 150ms colour/border
ticktransition on every interactive control is exempt (colour only).
Nothing else on this surface animates. There is no entrance stagger on the card list — a list of other people's requests has not earned a choreographed entrance, and animating it would delay the first thing the reader came to read.
Motion evidence: public-auth-motion-mobile-{light,dark}-motion.webp.
6 · The signup invitation — placement, copy, and the anti-vanity ruling
6.1 Exactly two placements, both earned
1. The footer card, at the end of the list. Reached by finishing the content, so it is a reward for reading rather than a toll on it. Not sticky, not fixed, not dismissible-therefore- re-showable.
✨ Not seeing what you need? Post it and we'll tell you when it moves.
[ Post an idea ]We'll only email you when something you voted on moves.
The headline is the reader's actual question turned back to them — Orion's user need was "is the thing they need on the list", and this is what you say to the person for whom the answer was no.
2. The inline row where the compose box would be, on post detail. Contextual, at the point of intent, replacing a control rather than adding one.
Got something to add here?
[ Join the thread ]
Plus the three triggered overlays (§3), which are responses to a tap and therefore not "placements" at all.
6.2 This page has exactly one filled primary action, and it is at the bottom
The toolbar's New idea is a bordered secondary sitting beside the search field, not a full-width filled CTA above it. This was refine cycle 1 on the mockups, and the reason is concrete: as a filled full-width button above the search row, it made the first impression of the page "sign up to post" before the reader had read a single idea — a wall built out of button weight rather than a modal, which is the same dark pattern in a costume. The filled primary is the invitation footer, which the reader reaches by choosing to.
On the detail page the hierarchy inverts for the same reason it holds here: Vote is the filled primary and Join the thread is the secondary. Voting is the cheapest action on the page and the one most readers will take; commenting costs more. Making the expensive action look primary and the cheap one look optional inverts the effort ladder and pushes readers toward the bigger ask first. (Refine cycle 1.)
6.3 The anti-vanity / PBC ruling
This is a marketing surface inside a product whose company has committed in writing that it will not manipulate (MISSION.md:21, :151-152). Ruled out, explicitly and permanently:
| Ruled out | Why |
|---|---|
| Member counts, user counts, "Join 12,000 builders" | A number about us, presented as evidence about them. It is also the first thing that gets gamed once it's on a marketing surface. |
| A total-ideas or ideas-shipped counter anywhere | Same vector. See the rule below for what replaces it. |
| "N people are viewing this right now" / live activity ticker | Manufactured urgency (gate g50). |
| Countdown, limited spots, waitlist-position framing | As above. |
| Vote-count leaderboards of people, "top contributors" | Ranks humans on a page they didn't ask to be ranked on. |
| Badges, levels, or streaks for posting or voting | Gamification measures the user's own progress toward their own goals. Nothing here is that. |
| Tag follower counts, "Follow this tag", per-tag notifications | Inherited from Phase 2 §4 and re-affirmed for this surface. |
| A "Hot" or "Trending" sort | An ordering is a ranking whether or not it's labelled one. Sort stays Most voted / Newest. |
Re-showing the invitation after Not now | One ask. |
| Any aggregate in the invitation copy | The invitation is an offer, not a pitch deck. |
The enforcement rule — evidence as content, never as a counter. The board's proof that the team listens is the board: real posts, real statuses, real team replies, and a Shipped filter that lists things that actually shipped (shipped_at, models/feedback_post.rb:36). That is checkable. A number next to it is a claim about the number. Per-post vote counts are exempt and stay — they are the mechanism that orders the board, functional rather than decorative, and the reader can see what they're counting.
7 · Relationship to the in-app board
Ruling: same product, same design language, deliberately simplified. Not a separate brand, not a copy.
Not differently branded, because the whole point is that the reader is looking at Objectuve. Not identical, because the in-app board is built for a returning signed-in user with an identity and the public board is built for a stranger on a phone.
In-app (views/Feedback.vue) | Public board | |
|---|---|---|
| List, search, category chips, tag rail, sort | ✅ | ✅ inherited from Phase 2 |
| Status badges | ✅ | ✅ (Declined → Not planned, see below) |
| Status filter | ✗ (the resolver supports status:, the UI never exposes it) | ✅ new here — replaces the roadmap view |
My Posts / My Votes | ✅ | ✗ no identity |
| Roadmap / kanban view | ✅ | ✗ ruled out, §7.2 |
| Post detail | a modal | a route, §7.1 |
| Official team replies | ✗ (field exists, unrendered) | ✅ the most prominent element on the page |
| Admin status / tag editors | ✅ | ✗ |
| Create | modal | route-level form, post-auth, with the disclosure block |
7.1 Post detail is a route, not a modal — and this is the biggest structural difference
A modal has no URL. A page with no URL cannot be shared, cannot be linked from a social post, and cannot be indexed — which is this surface's entire purpose. So:
- List rows are real
<a href>elements. Not arole="button"div with a click handler; a crawler and a middle-click both need a real href. /ideas/:publicId/:slug?— where:slugis the parameterised title, decorative and ignored by the router. It costs no migration (FeedbackPosthaspublic_id, no slug column) and buys a legible URL and a better search snippet. The bare/ideas/:publicIdrenders identically and emits<link rel="canonical">pointing at the slugged form, so the two URLs never compete as duplicate content.- Filters live in the query string —
/?status=planned&tags=streaks&category=bug&q=…— so a filtered view is shareable and browser-back restores it (gate g19). - Any URL carrying a query parameter is
noindex. Filter combinations are effectively infinite; indexing them burns crawl budget and floods the index with near-duplicates. The bare list and the post detail pages are the indexable set. - Open Graph tags carry no author information.
og:title= post title,og:description= the first 160 characters of the description,og:image= a single generic branded card. Never a per-user image, never the byline. A share card is a second, more viral publication surface, and §1's ruling has to hold on it too. - The page must render its content without JavaScript executing first. A client-only SPA is not reliably indexed, which would make the whole surface pointless. Prerender or SSR is a Phase 7 hosting requirement, not an optimisation — flagged in §16.
7.2 The roadmap / kanban view does not exist publicly in v4.62
Four reasons, in order of weight:
- It is a commitment artifact.
PlannedandIn progresson an internal board are working signals. The same words on a crawlable page read as a dated promise — and there is no date field to put on them (shipped_atexists; no target date does). Publishing a roadmap with no dates invites "when?" on every card; publishing one with dates is not scoped and is a much larger decision than a UI-SPEC should make by omission. - It doesn't survive the phone. Orion's own criterion is that most first visits are phones in a browser. A three-column kanban at 390px is either a horizontal scroller hiding two thirds of itself or three stacked lists, which is the board again with extra steps.
- Its information is already on the board. Every card carries its status, and status is now a filter.
Plannedanswers "what's coming" in one tap, with no second surface to design, maintain, or keep honest. - The
Shippedfilter is the honest version of it —completedposts ordered byshipped_atdesc. It is a record rather than a forecast, it is checkable, and it is §6.3's "evidence as content" rule made concrete.
If a public roadmap is wanted later it is a separate phase whose first task is the date-honesty decision, not a view toggle.
7.3 Declined renders as Not planned
Declined is a verdict on the person. Not planned is a statement about our plans. Same information, and the public board is where the difference is felt — the author is reading it alongside strangers.
Declined posts stay visible and readable. They are not hidden and not filtered out of All. Hiding them would make the board a highlight reel, which is the opposite of the evidence the reader came for; a declined idea with a team reply explaining why is among the strongest proof on the page that someone actually read it. But Not planned gets no filter chip — a browse-what-we-rejected mode serves nobody. It is reachable through All, search, and direct link.
(Recorded, not done here: the in-app label should follow. That is a one-line change in FeedbackCard.vue:70 in someone else's phase — §19.)
8 · Mobile web at 390px — stated explicitly, because this is the primary surface
Most first visits are phones in a browser. Every layout decision below is the 390px one; wider breakpoints are the derived case, not the reverse.
8.1 The row budget at 390px
Content width is 358px inside px-4. Four persistent rows above the first card:
┌───────────────────────────────────────────────┐
│ Objectuve / Feedback ☾ Sign in │ sticky header
├───────────────────────────────────────────────┤
│ What we're building next │ h1
│ Ideas from people using Objectuve. We… │ subhead
│ [🔍 Search ideas ] [⊞ New] │ Row A
│ [🏷 Tags ▾] │ (Features)(Improvements)(Bu…→ │ Row B ← scrolls
│ [ All ][ Open ][ Planned ][ In prog… → │ Row C ← scrolls
├───────────────────────────────────────────────┤
│ card · card · card … │
│ [ invitation footer ] │
│ Objectuve Softworks, LLC — a PBC. │ site footer
└───────────────────────────────────────────────┘New idea collapses its label to New below sm:, which is what lets it share Row A with the search field instead of taking a row of its own. Rows B and C are flex-nowrap overflow-x-auto with a -mx-4 px-4 bleed — a chip clipped at the viewport edge is the signal that the rail scrolls. Both wrap instead of scrolling at lg:. The sort <select> is hidden sm:block; below sm the board is Most voted, which is the right default for a first visit and is one fewer control in the first screenful.
8.2 Browser, not app shell
100dvh, never100vh. Mobile browser chrome collapses on scroll;100vhoverflows.- No bottom-fixed anything. No sticky CTA bar, no cookie-style banner. This is both a layout rule and §6.3's ruling: the bottom of a phone screen is where a wall would go.
- The header is
sticky top-0and carries.glass— it is 56px of chrome and it earns that by holding the theme toggle andSign in, which are the two things a returning reader wants. - Safe-area insets still apply on iOS Safari for the bottom inset (home indicator) even with no tab bar; the site footer takes
pb-[env(safe-area-inset-bottom)]. font-display: swapon Inter and Outfit. A page whose job is to be found from a social link must paint text on the first frame; a blocking webfont fetch on a cold 4G connection is a blank screen where the first impression should be.
8.3 The card at 390px
Title text-base font-semibold and description text-sm, both one ramp step larger than the in-app card (text-sm / text-xs). The in-app card is a dense scanning surface for someone who already knows what they're looking at; this one is being read by a stranger, once, on a phone. Legibility beats density here, and the cost is about one card per screenful.
The metadata row renders the category only when it isn't feature. Feature is the default and the large majority of the board; labelling it costs a wrapped line at 390px and carries no information, while Bug, Improvement, and Other genuinely change how the row reads. (Refine cycle 2 — it was wrapping to three lines.)
Card structure inherits Phase 2 §7 verbatim: plain <div> root with no role/tabindex, the title <a> as the single stretched-link control (after:absolute after:inset-0), the vote button relative z-10 above it, items-start on the inner row, tags in a full-bleed row below with aria-describedby and sr-only overflow names. nested-interactive does not fire.
8.4 Wider breakpoints
max-w-3xl centred, matching the in-app board's own container. At lg: the filter rails wrap instead of scrolling; nothing else changes. There is no desktop-only three-column layout — the board is a list at every width, which is also why §7.2's kanban has nowhere to live.
9 · Design tokens
Every class resolves against design_system/tailwind-tokens.cjs. No hex appears in this document by design.
| Surface | Token / class |
|---|---|
| Page background | bg-background |
| Card surface | .glass-card (light bg-card/70, dark bg-card/40, backdrop-blur-xl, border-border/60 → dark:border-white/10, shadow-xl) + rounded-2xl p-4 |
| Site header | sticky top-0 z-20 border-b border-solid border-border + .glass-card's fill (no radius, no shadow) |
| Primary action (filled) | bg-primary text-primary-foreground hover:bg-primary-interactive |
| Secondary action | border border-solid border-border bg-card text-foreground hover:bg-muted |
Sign in link | text-primary-accessible hover:bg-muted |
| Vote pill, unvoted | border-2 border-solid border-border bg-transparent text-muted-foreground hover:border-primary-accessible hover:text-primary-accessible |
| Vote pill, voted | border-2 border-solid border-gold bg-gold text-foreground dark:text-background — see below |
Status Open | bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200 |
Status Planned | bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200 |
Status In progress | bg-orange-100 text-orange-900 dark:bg-orange-900/50 dark:text-orange-200 |
Status Shipped | bg-emerald-100 text-emerald-900 dark:bg-emerald-900/50 dark:text-emerald-200 |
Status Not planned | bg-muted text-muted-foreground |
| Tag chip, read-only | rounded-full border border-solid border-border bg-transparent px-2 py-0.5 text-2xs font-medium text-muted-foreground — outlined, per Phase 2 §5 |
| Filter chip, unselected | border-border bg-background text-muted-foreground hover:border-border/80 |
| Filter chip, selected | border-primary-accessible bg-primary/10 text-foreground dark:bg-primary/20 |
| Status tab, active | bg-primary text-primary-foreground |
| Status tab, inactive | text-muted-foreground hover:bg-muted hover:text-foreground |
| Official comment block | rounded-2xl border border-solid border-primary-accessible/40 bg-primary/5 p-4 dark:bg-primary/10; avatar bg-primary text-primary-foreground; TEAM badge bg-primary text-primary-foreground text-2xs uppercase tracking-wide |
| Disclosure block (create form) | rounded-xl bg-muted p-3 + text-xs text-muted-foreground, emphasis spans font-semibold text-foreground |
| Auth overlay scrim | bg-foreground/40 |
| Auth overlay panel | rounded-2xl bg-card p-5 shadow-xl max-w-sm |
| Rail divider | h-5 w-px shrink-0 bg-border + aria-hidden |
| Refining progress bar | h-0.5 rounded-full bg-muted track, w-1/3 animate-pulse bg-primary fill |
| Focus ring | focus-visible:ring-2 focus-visible:ring-primary-accessible |
The voted pill's dark ink is not optional. bg-gold with text-foreground measures 1.61:1 in dark mode — axe-core caught it on this spec's own first render pass, at 1 node per dark viewport. dark:text-background is the fix, and it is what the live in-app card already writes (FeedbackCard.vue:122). Any new gold-filled control on this surface needs the same pairing. No opacity modifier appears on any text token anywhere in this spec (desi-design-context §13).
10 · Typography
Outfit for headings, Inter for body. No third family.
| Element | Font | Class |
|---|---|---|
Page <h1> | Outfit | font-display text-3xl font-bold tracking-tight |
| Page subhead | Inter | text-sm text-muted-foreground |
Post-detail <h1> | Outfit | font-display text-2xl font-semibold leading-snug tracking-tight |
Section heading (Comments) | Outfit | font-display text-lg font-semibold |
| Invitation headline | Outfit | font-display text-xl font-semibold |
| Overlay title | Outfit | font-display text-xl font-semibold |
Card title (<h2>) | Inter | text-base font-semibold leading-snug |
| Card description | Inter | text-sm text-muted-foreground (line-clamp-2) |
| Card metadata, byline | Inter | text-xs text-muted-foreground |
| Post body | Inter | text-base leading-relaxed text-foreground |
| Comment body | Inter | text-sm leading-relaxed text-foreground |
| Comment author | Inter | text-xs font-semibold text-foreground |
| Control labels, buttons | Inter | text-sm font-semibold |
| Form field label | Inter | text-sm font-semibold text-foreground |
Tag chip, TEAM badge | Inter | text-2xs font-medium |
| Site footer | Inter | text-xs |
text-2xs is 11px, the legibility floor, and a design_system extension (design_system/tailwind-tokens.cjs:246) — write text-2xs, never text-[11px]. No arbitrary text-[Npx] appears anywhere in this spec.
11 · State catalog
Board list
- First load: three
animate-pulseskeleton cards at the real card height, so the list doesn't reflow on resolve (g15). - Refining (a filter changed with results already on screen): a 2px indeterminate bar under the rails,
aria-busy="true"on the list, full contrast, still tappable. Not a skeleton swap —cache-and-networkflipsloadingon every variable change and would flash skeletons over real content for under 200ms on every chip tap (g16). Not a dimmed list — that is the stacked-opacity anti-pattern at container scale. - Empty / zero-result: §12.
- Error:
Couldn't load the board./Connection's spotty. Try once more./Try again. Matches the live in-app block verbatim in structure and copy (Feedback.vue:128-136). - Focus:
focus-visible:ring-2 focus-visible:ring-primary-accessibleon every control. - Hover (desktop): chips
hover:border-border/80; cards take no hover lift — a list of 30 rows that all lift is noise, and the row is already a link. - Active/pressed: no scale. On a surface with no global reduced-motion reset, a press scale would need its own
no-preferencewrapper to earn nothing; colour is enough. - Dark: every token above carries its own dark value. Verified axe-clean in both.
- Motion-reduced: nothing on the list animates except
animate-pulse(opacity-only, exempt).
Vote pill
- Default (signed out): unvoted treatment,
Add your vote. - Pending (tapped, auth overlay open): unchanged — no optimistic fill. Optimistically filling a control whose mutation hasn't been authorised yet is a lie, and it has to be undone if the reader taps
Not now. - In flight (signed in, mutation running):
disabled, count unchanged. Under 200ms in the common case, so no spinner (desi-design-context§12). Double-submit is structurally impossible (g27). - Voted: gold fill, check glyph, count incremented,
Remove your vote. - Error: pill returns to its previous state and an inline
text-destructive-accessibleline appears under the card —Couldn't register that vote. Tap to retry.Never a silent revert (g24).
Post detail
Default · loading (skeleton: title bar, three body lines, two comment rows) · error (same block as the board) · not found (That idea isn't here. / It may have been removed. The board's still worth a look. / See all ideas — a forward action, g38).
Auth overlay
Default (three copy sets) · dismissed via Not now, backdrop, or Esc — all three identical, all three return focus to the control that opened it, none of them ask again (g25, g51).
Create form
Default (category row seeded from the board's active filter, else Feature — §3.5a) · title-empty (Post it disabled; the category never gates the submit) · submitting (Post it disabled, label → Posting…) · error (inline under the field, entered text and the selected category preserved — g26) · success (route to the new post's own page, which is the thing that was created — g48).
Category row
Default (Feature, or the board's active category filter) · selected (peer-checked treatment, 150ms tick) · focus (ring on the <label>, not the sr-only input) · hover, desktop (border-border/80) · dark (dark:peer-checked:bg-primary/20) · motion-reduced (the tick tier is colour and border only — already exempt, no transform to gate). There is no empty, loading, error, or disabled state: the row is always answered, never fetched, and never blocks.
12 · Zero-result states — the matrix, in precedence order
Evaluated top to bottom. Exactly one CTA per branch — in branches 1–3 the reader's live intent is "find something about X", not "file a post", and offering both is the two-competing-CTAs anti-pattern.
| # | search | tags | status | category | Title | Body | CTA |
|---|---|---|---|---|---|---|---|
| 1 | ✓ | any | any | any | No ideas match that. | Try a different word, or clear the filters to see everything. | Clear filters |
| 2 | — | ✓ | any | any | Nothing tagged that yet. | Try another tag, or clear the filters to see the whole board. | Clear tag filters |
| 3 | — | — | ✓ | any | Nothing planned yet. (status name interpolated) | Have a look at what people are asking for — or add yours. | See all ideas |
| 4 | — | — | — | ✓ | Nothing in that category yet. | Pick another category, or clear it to see the whole board. | Clear category |
| 5 | — | — | — | — | No ideas here yet. | Be the first to shape what we build next. | Post an idea |
Branch 5 is the only one where the invitation is the right answer, because it is the only one where the reader's intent has nowhere else to go. It matches the live in-app empty state's copy (Feedback.vue:144-147) so the two boards don't disagree about what an empty board says.
13 · Microcopy — every string, final
Phases 6 and 7 write no new copy. Anything not in this table or §12 is a gap to route back, not a gap to fill.
| Surface | String | Tone check |
|---|---|---|
Page <h1> | What we're building next | states what it is ✅ brief ✅ |
| Page subhead | Ideas from people using Objectuve. We update the status when things move. | honest, no aggregate claim ✅ |
| Header wordmark | Objectuve / Feedback | — |
| Header theme toggle a11y | Switch to dark theme / Switch to light theme | verb ✅ |
| Header auth link | Sign in | brief ✅ |
| Search placeholder | Search ideas | verb ✅ |
| Search label (sr-only) | Search ideas | — |
| Clear-search a11y | Clear search | verb ✅ |
Toolbar create, sm: and up | New idea | verb-adjacent, brief ✅ |
Toolbar create, below sm: | New | brief ✅ |
| Toolbar create a11y (both) | Post a new idea | verb ✅ |
| Tag filter pill | Tags | inherited, Phase 2 §13 |
| Category chips | Features · Improvements · Bugs · Other | inherited from Feedback.vue:274-277 |
| Status tabs | All · Open · Planned · In progress · Shipped | |
| Status badges | Open · Planned · In progress · Shipped · Not planned | Not planned is about our plans, not the person ✅ |
| Sort options | Most voted · Newest | |
| Card vote a11y, signed out | Add your vote to {title} | verb ✅ asserts nothing ✅ |
| Card vote a11y, voted | Remove your vote from {title} | verb ✅ |
| Card tag-row a11y prefix | Tagged | inherited, Phase 2 |
| Byline, attributed | by {FirstName} {LastInitial}. | §2 |
| Byline, anonymised | by a member | not apologetic, not "Anonymous" ✅ §1.5 |
| Detail byline | Posted by {FirstName} {LastInitial}. · {relative time} | |
| Detail byline, anonymised | Posted by a member · {relative time} | |
| Detail back link | All ideas | forward action ✅ |
| Detail vote button | Vote + count | verb ✅ |
| Detail comment count | {n} comments / 1 comment | information ✅ |
| Comments heading | Comments | |
| Official comment author | Objectuve Team | one accountable voice ✅ §2.7 |
| Official comment badge | TEAM | |
| Compose replacement row | Got something to add here? | invitation, not instruction ✅ |
| Compose replacement CTA | Join the thread | verb ✅ warm ✅ |
| Auth overlay — vote, title | Add your vote | names the action just taken ✅ |
| Auth overlay — vote, body | Votes are one per person, so we need to know it's you. Takes about 20 seconds. | gives the reason + the cost ✅ |
| Auth overlay — comment, title | Join the thread | |
| Auth overlay — comment, body | Comments show your first name and last initial, so they need an account. Takes about 20 seconds. | discloses before the ask ✅ |
| Auth overlay — post, title | Post your idea | verb ✅ |
| Auth overlay — post, body | We'll tell you when it moves. Takes about 20 seconds. | the benefit is real ✅ |
| Auth overlay primary | Continue | |
| Auth overlay secondary | Not now | honest exit, no guilt ✅ |
| Auth overlay footer | We'll only email you when something you voted on moves. | one promise, kept ✅ |
| Auth overlay a11y | role="dialog" aria-modal="true" aria-labelledby the title | |
| Invitation headline | Not seeing what you need? | the reader's own question ✅ |
| Invitation body | Post it and we'll tell you when it moves. | one sentence, forward ✅ |
| Invitation CTA | Post an idea | verb ✅ |
| Invitation footer | We'll only email you when something you voted on moves. | same promise, same words ✅ |
| Create form heading | Post an idea | verb ✅ |
| Create title label | What would you change? | asks for the thing, not a ticket ✅ |
| Create title placeholder | One sentence is plenty | lowers the bar ✅ |
| Create category legend | What kind of idea is this? | a question, matching its two sibling labels ✅ brief ✅ |
| Create category chips | Feature · Improvement · Bug · Other | singular, matching the card badge (§8.3) — not a fifth name for a value the codebase already names twice ✅ |
| Create body label | Why does it matter? + (optional) | |
| Create body placeholder | What you're trying to do, and what gets in the way. | |
| Create disclosure | This board is public and shows up in search. Your post will read by {FirstName} {LastInitial}. — first name and last initial, never your full name or your email. Turn on Private profile in Settings and it reads by a member instead. | shows the actual string ✅ §3.5 |
| Create primary | Post it | verb ✅ brief ✅ |
| Create secondary | Cancel | |
| Vote error | Couldn't register that vote. Tap to retry. | what failed · recovery ✅ |
| Board error title | Couldn't load the board. | matches in-app verbatim ✅ |
| Board error body | Connection's spotty. Try once more. | matches in-app verbatim ✅ |
| Board error CTA | Try again | verb ✅ |
| Not-found title | That idea isn't here. | honest, brief ✅ |
| Not-found body | It may have been removed. The board's still worth a look. | forward ✅ |
| Not-found CTA | See all ideas | verb ✅ |
| Board live region | {n} ideas. / 1 idea. / No ideas match. | information ✅ |
| Site footer | Objectuve Softworks, LLC — a Public Benefit Company. | |
| Site footer links | Objectuve.com · Privacy · Terms | |
| Zero-result branches | §12 |
No "AI" anywhere; the Coach is the Coach. No aggregate counts. No exclamation marks. No "Welcome!", no "You're in!", no "Thanks for your feedback!" — the board acknowledges by showing the changed state, not by thanking anyone for it.
14 · Accessibility
Focus order, board: skip link → header wordmark → theme toggle → Sign in → search → Clear search (when present) → New idea → Tags pill → category chips → status tabs → sort → per card: title link, then vote button → invitation CTA → footer links.
A skip link (Skip to ideas, sr-only focus:not-sr-only) is required here in a way it isn't in the app: this is a document with a persistent header, reached cold from a search result, and the header is 4 tab stops in front of the content every single time.
Headings: one <h1> per page, visible (not sr-only — this is a document and the heading is the reader's orientation). Card titles are <h2>. heading-order and page-has-heading-one do not fire at any viewport.
Links vs buttons: anything that navigates is an <a href>; anything that mutates is a <button>. The card title is a link with the stretched-link pseudo-element; the vote button is relative z-10 above it. Exactly two interactive elements per card.
Overlay: role="dialog" aria-modal="true" aria-labelledby="auth-title". Focus moves to the title on open and is trapped; Esc and backdrop tap both close and return focus to the trigger. Background content is aria-hidden while open — the scrim is visually translucent by ruling, but the reader using assistive tech should not be navigating two layers at once.
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. - Detail: none. The vote pill's own
aria-pressed+ label change is the announcement.
Announcements:
- Vote pill:
aria-pressedreflects voted state once known; label isAdd your vote…until then. - Status tabs:
role="tablist"/role="tab"/aria-selected. - Category chips:
role="checkbox"/aria-checked, matching the live in-app idiom (Feedback.vue:86-99). - The
·separators in the metadata row arearia-hidden="true"— a screen reader announcing "middle dot" four times per card is noise. - Decorative glyphs carry
aria-hidden="true"and have no accessible name.
Tap targets: every chip, tab, pill, link, and button is min-h-11 (44px — the sanctioned floor, not min-h-[46px]). Verify with computed dimensions, not class-name assertions (desi-design-context §15).
Contrast: zero axe-core violations across 4 mockups × 3 viewports × 2 themes (24 runs) after two refine cycles. The one violation found — the gold voted pill at 1.61:1 in dark — is fixed in §9, not waived.
Motion-reduced: §5. The only gated declaration on this surface is the overlay panel's scale(.98).
Language: <html lang="en">.
15 · Flow sequence
The journey: anonymous read → write intent → auth → return → action applied.
| From | Trigger | To | Transition + duration | Feedback | Reduced-motion fallback | Back-nav |
|---|---|---|---|---|---|---|
| Search result / social link | inbound | Board or post detail, fully rendered | none — instant cut, every viewport | — | n/a | leaves the site |
| Board | tap a card title | Post detail (/ideas/:id/:slug) | none — instant cut | — | n/a | returns to the board with filters and scroll position restored (filters live in the query string) |
| Board or detail | tap a vote pill, signed out | Auth overlay | scrim da-fade 150ms ease-out; panel da-pop 200ms ease-out | none — opening isn't an outcome | panel cross-fades, no scale | Esc / backdrop ≡ Not now |
| Auth overlay | Not now | back to where they were, unchanged | reverse of the pair | none — declining is complete | as above | — |
| Auth overlay | Continue | Clerk hosted sign-in | full navigation | — | n/a | browser back returns to the post, not into Clerk |
| Clerk | completes | the exact post, scrolled into place, vote applied | instant cut | the pill's own fill + count increment — one inline acknowledgment, no toast (g28) | none needed | back does not re-trigger the vote; the pending intent was cleared on consumption |
| Detail | tap Join the thread, after auth | compose box replaces the invitation row, focused | 200ms opacity | focus move is the acknowledgment | opacity only, already exempt | — |
| Board | tap New idea / Post an idea, after auth | Create form, category row seeded from the active filter (§3.5a) | instant cut | the seeded chip's own fill is the acknowledgment — no toast | n/a | discards the draft (no half-saved record, g21/g22); reopening re-seeds from the query string |
| Create form | tap a category chip | same form, chip filled | tick — 150ms colour + border, ease-out | the fill itself; no toast, no live-region announcement beyond the radio's own | colour only — the tick tier has no transform to gate | n/a |
| Create form | Post it | the new post's own page | instant cut | the post renders, which is the acknowledgment (g48 — lands on the thing created, never an upsell) | n/a | back goes to the board, not the form |
| Board | filter changes with results on screen | Board (refining) | 2px indeterminate bar; list stays full-contrast and tappable | aria-busy="true" + live region | animate-pulse is opacity-only | query string updates; back steps filters |
Every duration and curve above is cited from motion-vocabulary.md. Nothing is minted. Gates g7 (no route-transition layer), g10-as-written (no useModalAnimations), g29 (no haptics — web page, permanently), and g30–g34 (no toast layer, no earning events) are n/a, not pass on this surface — the full disposition is in §19.
16 · Prerequisite plumbing for Phases 6 and 7
Not design decisions. Facts that will silently break this contract if missed.
PublicFeedbackAuthorTypeexposes one field:displayName: String!, resolved server-side to"{FirstName} {LastInitial}."or"a member". NofirstName, nolastName, nousername, noemail, noprivateMode, nopublicId, no avatar URL unless the user uploaded a photo. §1.6 is only enforceable at the type.private_modemust be consulted. No feedback code reads it today. Phase 6's resolver does:user.private_mode?→"a member". Note that the existing ally-search precedent (resolvers/user_queries.rb:171) also gates onFeatureFlagService.enabled?('privacy_controls_v1', user: u)— do not copy that gate here. There, the flag decides whether the feature is live for that user. Here, the switch is already flipped in the database and a flag rollout state is not a reason to publish a name. Read the column.- The public resolver takes no
current_user. Followpublic_goal(goal_queries.rb:36-41) — norequire_auth!.votedByCurrentUsermust not exist on the public type at all (§4 — it can't be honestly answered, so it shouldn't be askable). - A schema regression spec proving no path from
publicFeedbackPostsreaches a private field. Orion named this and it is the real guarantee behind §1 — a type is a promise; the spec is what keeps it one. statusis already a resolver argument (feedback_queries.rb:5,:11) and needs exposing publicly.tagsneeds the[String!]any-of variable from Phase 2 §15.- The comment query must select
isOfficial(§2.7). It exists on the type and no consumer selects it today. - Prerender or SSR is a hosting requirement, not an optimisation (§7.1). A client-only SPA is not reliably indexed, which would make the surface pointless.
useTheme.tslifts verbatim fromadmin_dashboard/src/composables/useTheme.ts, including the pre-boot inline script inindex.htmlthat sets the initialdarkclass before paint. A theme flash on a first-impression page is a first impression.- No
vue-sonner, no toast layer. Acknowledgment is inline, per §15 and g28. Adding one later is a vocabulary decision, not a convenience.
Item 1 and item 4 together are the only things standing between this ruling and a privacy incident.
17 · Component brief for Phase 7
feedback_site/ has no Storybook today. Either stand one up mirroring admin_dashboard's setup, or cover these as Vitest render specs — but every variant below needs to be exercised somewhere, because most of them are states a manual pass will never reach.
FeedbackListCard.vue — Default · Anonymised (author a member + neutral glyph) · WithTags (1, 2, 3 → +1) · NoTags · each of the five statuses · NonFeatureCategory · LongTitle (3-line wrap at 390px). Assert: the vote button and the title link are the only two interactive elements; the title's aria-describedby resolves to a row containing all three tag names.
AuthInviteOverlay.vue — Vote · Comment · NewPost · ReducedMotion. argTypes: intent ('vote' | 'comment' | 'post'), isOpen. Assert: focus is trapped; Esc emits the same event as Not now; focus returns to the trigger.
VotePill.vue — SignedOutUnvoted · Pending · InFlight · Voted · Error. Assert: the signed-out label is Add your vote… and never Remove; Pending does not fill.
PostDetail.vue — Default · WithOfficialReply · AnonymisedAuthor · AnonymisedCommenter · NoComments · Loading · Error · NotFound.
BoardList.vue — Loading · Refining · each of §12's five zero-result branches · Error. Assert: each branch renders exactly one CTA.
CreateIdeaForm.vue — Default · TitleEmpty · Submitting · Error · DisclosureAnonymised (a private_mode author sees the by a member variant of the disclosure).
18 · Acceptance criteria for Phases 6 and 7
- [ ] Renders at 375px and 390px with no horizontal scroll (the two filter rails' own
overflow-x-autois intentional and does not count). - [ ] Every post and comment is readable with no account, no modal, no truncation, and no blur.
- [ ] A
private_modeauthor renders asa memberon every surface: list byline, detail byline, comment author, and the Open Graph card. Zero exceptions. - [ ] The anonymised string is identical to the missing-display-name string, so the byline never discloses who has private mode on.
- [ ] Every other author renders as
{FirstName} {LastInitial}.— never a surname, never a username, never a link to a profile. - [ ]
PublicFeedbackAuthorTypeexposesdisplayNameonly; a schema regression spec proves no path frompublicFeedbackPostsreaches a private field. - [ ] The public resolver reads
user.private_mode?without aprivacy_controls_v1flag gate. - [ ] No modal, overlay, blur, fade, or interstitial fires on load, on scroll depth, on a timer, or on exit intent. Ever.
- [ ] Each of the three write actions opens the overlay with its own title and body, verbatim from §13.
- [ ] Intent survives the Clerk round trip: the reader returns to the exact post, and the action they tapped completes without a second tap.
- [ ] The pending intent is cleared on consumption, on failure, and on any other navigation — it never fires late.
- [ ] Post-auth landing is the post or the created idea. Never a welcome screen, an app-install prompt, or an upsell (g48).
- [ ] The vote pill never claims a voted state before auth resolves, and never fills optimistically while the overlay is open.
- [ ]
My Posts/My Votesdo not render. The compose box does not render. Neither is a disabled control. - [ ] No member count, idea count, "N viewing", countdown, leaderboard, or trending sort appears anywhere on the surface.
- [ ] Post detail is a real route with a real
<a href>from the list;/ideas/:publicIdemits a canonical pointing at the slugged form; any URL with a query parameter isnoindex. - [ ] Open Graph tags carry no author name and no per-user image.
- [ ] Content renders without JavaScript executing first (prerender or SSR).
- [ ] The roadmap/kanban view does not exist publicly;
Shippedis a status filter ordered byshipped_atdesc. - [ ]
Declinedrenders asNot planned, has no filter chip, and is not hidden fromAll. - [ ] The create form's disclosure block interpolates the reader's own display name and names the Private profile setting.
- [ ] The create form renders an explicit category row (§3.5a). No category value is ever sent that the poster could not see before tapping
Post it. - [ ] The row is a
<fieldset>of native radios — not a<select>, notrole="checkbox"chips — and is one tab stop with arrow-key movement inside it. - [ ] The row seeds from the board's active
categoryquery parameter, falling back tofeature. - [ ] The category never gates
Post it;Post itstays disabled on an empty title alone. - [ ] The selected category survives an inline submit error alongside the entered text (g26).
- [ ] The row wraps at every width; it never becomes
overflow-x-auto. - [ ] Every chip is ≥44px tall and carries its focus ring on the
<label>, not thesr-onlyinput. - [ ] Chip labels are singular and match the card badge (§8.3) verbatim.
- [ ] Every microcopy string matches §13 or §12 verbatim. No new copy is written downstream.
- [ ] Every transition in §15 matches the locked motion vocabulary — no invented easing or duration. The overlay uses the
da-fade/da-poppair. - [ ] Every transform on this surface carries its own
prefers-reduced-motionhandling. There is no global reset here. - [ ]
dark:text-backgroundis paired with everybg-goldfill. - [ ] No opacity modifier on any text token anywhere.
- [ ] Tap targets ≥44px (
min-h-11), verified with computed dimensions. - [ ] A skip link precedes the header; one visible
<h1>per page; card titles are<h2>. - [ ] WCAG AA verified in both themes; axe-core clean at 390 / 768 / 1280.
- [ ] Dark mode is first-class;
useThemeis lifted fromadmin_dashboardwith its pre-boot inline script, so there is no theme flash.
19 · Throughline — gate disposition
Journey: anonymous read → write intent → auth → return → action applied. Net-new, so trace had no shipped code path to walk; the audit below runs against §15's sequence.
Fixed in this spec (flow gaps found by the audit and written into the document, not filed):
| Gate | Finding | Where it was fixed |
|---|---|---|
| g6 | A signed-out user hitting a write action must return with context, not to a generic landing. | §3.1 — the pending-intent contract |
| g19 | Browser back from post detail would have lost the filter set and scroll position. | §7.1 filters in the query string, §15 back-nav column |
| g20 | Browser back after the Clerk hop could have landed back inside Clerk. | §15 — the return URL targets the post |
| g23 | A held pending intent could fire late, on an unrelated later visit. | §3.1 — cleared on consumption, failure, and any other navigation |
| g24 | A failed vote could have reverted silently, indistinguishable from a no-op tap. | §11 — inline error + retry |
| g27 | Double-submit on the vote pill. | §11 — disables on first tap |
| g28 | With no toast layer, the mutation had no defined acknowledgment. | §15 — the pill's own fill is the one inline acknowledgment |
| g38 | The not-found state had no forward action. | §11 — See all ideas |
| g48 | Post-auth landing was undefined, and a welcome/app-install screen is the obvious thing to put there. | §3.1 and §18 — ruled out explicitly |
| g13 | The overlay's scale(.98) would have played under reduce — no global reset on this surface. | §5 — wrapped in no-preference |
Re-audited in OBJ-3861 over the create step, after §3.5a added a written value to it. trace was run against the shipped Phase 7 code (router.ts, BoardList.vue:41/123/299, CreateIdeaForm.vue, usePendingIntent.ts) rather than against §15 alone, which is what surfaced g36 — the seeding case is invisible from the screen.
| Gate | Finding | Where it was fixed |
|---|---|---|
| g4 | The form wrote a category the poster was never told was being taken — the step required information the reader hadn't been told was needed, in the direction the gate doesn't usually get read. | §3.5a — the row exists and is visible before Post it |
| g36 | BoardList.vue:41 already holds the reader's active category filter and the form ignored it. A default nobody can see is still a default that should have been prefilled from what the app already knew. | §3.5a — the seeding rule |
| g42 | Semantic, not a cache miss. Post under an active Bugs filter, land on the new post (g48), browser-back to the board — the board is genuinely fresh and the post genuinely isn't in it, because it was filed as feature. No refetch fixes this; only the right value does. | §3.5a — the seeding rule and the explicit control together |
| g26 | §11 preserved the entered text on an inline error and said nothing about a selection, because there wasn't one to say anything about. | §11 — the category now survives the error re-render too |
Confirmed still passing, re-checked because the step changed: g27 (Post it disables on first tap — untouched, and the category can't reach it), g39 (the category is pre-answered, so there is no new blockable step), g40 (Post it is still above the fold at 390×844 — one wrapped chip row is 44px, and the two-line degradation only fires off the primary's screenful), g21/g22 (refresh still discards the draft cleanly, and reopening now re-seeds from the query string rather than resetting to a worse default).
Nothing minted. The chip's only motion is the tick tier — 150ms colour and border, ease-out — already locked, already used by Row B, and already exempt from prefers-reduced-motion gating because it carries no transform.
n/a, not pass — structurally absent, and recorded as absent so a later audit doesn't read them as coverage: g7 (no route-transition layer; instant cut at every viewport, as on the VitePress surfaces) · g10 as written (no useModalAnimations; re-read against the da-fade/da-pop pair, which §5 uses) · g29 (no Capacitor, permanently) · g30 (no toast layer, by ruling in §16 item 9) · g31, g32, g33, g34 (no earning events — a celebration for casting a vote would be manufactured).
g40 deserves a note so a future audit doesn't misfire it: the board's filled primary is the invitation footer, deliberately below the fold. The flow's primary action is the per-card vote pill, which is above the fold at 390×844. g40 passes on that reading.
Recorded for Maggie to route — not fixed here, and not in this phase's scope:
- Vocabulary gap.
motion-vocabulary.md§ "VitePress surfaces" is scoped toguide_siteanddocs_site.feedback_site/is a third non-Ionic web surface, andadmin_dashboard/is a fourth — neither is listed. This spec applies the VitePress rules by analogy, which is the right answer but is currently an inference rather than a written one. Proposal: broaden that section to "Non-Ionic web surfaces" and name all four. No curve, duration, or tier is minted here. - In-app label drift.
Declined→Not plannedshould follow inFeedbackCard.vue:70so the two boards don't disagree about the same record. - Three anonymous-author fallbacks live in the codebase —
'Unknown'(FeedbackCard.vue:80,FeedbackDetailModal.vue:120,:122) and'Anonymous'(PublicGoal.vue:421). This spec defines a fourth (a member) for a new surface rather than widening into a cleanup nobody asked for. Unifying them is a small, separate ticket. - The in-app create form now needs an audience disclosure.
NewFeedbackModal.vuehas none, and content posted there becomes publicly crawlable once this ships. The string in §3.5 is reusable. This is a Phase 4 or Phase 8 item. NewFeedbackModal.vuesends two category values that don't exist. Found while writing §3.5a (OBJ-3861), not looked for.NewFeedbackModal.vue:26-31offersfeature_request/improvement/bug_report/other, andfeature_requestis its default.FeedbackPost's enum isfeature/improvement/bug/other(feedback_post.rb:25);Feedback::CreateFeedbackPost#validate_categorychecksFeedbackPost.categories.key?(category),Mutations::CreateFeedbackPostpasses the string through unmapped, andFeedbackCard.vue:83only title-cases whatever came back. So the in-app board's default option and its Bug Report option both fail withInvalid category— 2 of 4 choices, one of them the pre-selected one. This is a live bug on the authenticated board, it predates this spec, and it is Codi-sized. Not fixed here — this document governs the public surface.- The two create forms will disagree about shape. §3.5a specifies a chip row for the public form;
NewFeedbackModal.vuekeeps its<select>. That is the right call for now — converging them is a design change to a shipped authenticated surface that nobody asked for, and it should ride with item 5's fix rather than ahead of it. Recorded so the divergence is a decision on the record, not drift.
20 · Open questions
None blocking.
One thing worth a human decision before Phase 7 ships rather than after, recorded here because it is a policy question and not a design one: §2.5 applies the first-name-plus-initial rule retroactively to existing posts without notifying their authors. That is defensible precisely because it narrows what is shown — but a short note in the next release's changelog, or an in-app one-liner, would be the more generous version of the same decision, and it costs nothing. Routed to Maggie rather than decided here.