The course was challenging but fair, and the atmosphere at the finish line made the effort worth it. Marshals were visible throughout and water stations were well stocked even for back-of-pack runners.
Review card
One review card recipe everywhere. Public surfaces show athletes overall stars and comments; Private organiser tools add category breakdown and reply. Shared tokens, list rhythm, and variants live under Common information.
Public
How reviews are listed for athletes and visitors on event pages, embeds, and widgets. Use reviewCard({ detail: 'overall' }) inside reviewList() — name and date on the left, figure and gold stars on the right, and one action row after the review: Like on the left, Report on the right. No category breakdown unless product explicitly shows it.
List on event pages
reviewCard({ detail: 'overall' }) — what athletes and visitors see. A written review is layout: 'card': name and date, then headerEnd with the figure (ratingStars().value) and the stars; the body; a published organiser reply; and the footer action row. Like (thumbs-up, an aria-pressed toggle) leads and Report (flag) trails with spacer, both ghost size: 'md' (44px). Like and Report are always there on a written review: the app decides what a signed-out visitor must do to like or to report. A score-only review (comment: 'none') is layout: 'row': the same identity block (name, the date below it), the figure and stars, no body, and Report at the right edge. Its children are flat (identity, figure and stars, Report) and Report carries spacer (ml-auto), so it stays on the right even when the row wraps, alone, onto a third line. Composed with Tabs when the list sits behind a tab panel.
-
-
-
Incentivised review
Best organised race I have done this year. Clear communication before the event and plenty of volunteers on course.
-
Incentivised review
-
Great provisions at the aid stations. Would have liked clearer bag-drop signage in the morning.
Reply from Star Events
Thanks for the feedback — we added extra signage at bag drop for the 2027 edition.
Structure
Each review is <li><article> in the list. The figure and the stars are one ratingStars() row, so the number the reader sees is the number the stars round from. Published organiser replies sit in thread + reviewReply() — never behind a disclosure. Pair long lists with Pagination.
import {
reviewCard,
reviewList,
reviewReply,
ratingStars,
starsFor,
starsLabel,
formatRating,
formatCount,
button,
} from '@sportingscouter/ui';
import { Flag, Gift, ThumbsUp } from 'lucide-react';
const list = reviewList({ surface: 'raised' });
const stars = ratingStars({ tone: 'star' });
const reply = reviewReply();
<div class={list.summary()}>
{counts.incentivised > 0 && (
<span class={list.summaryItem()}>
<Gift className="icon-sm" aria-hidden />
{t('reviews.incentivisedSummary', {
incentivised: formatCount(counts.incentivised, locale),
total: formatCount(counts.total, locale),
})}
</span>
)}
<a class={button({ variant: 'link', size: 'xs' })} href={howCheckedHref}>
{t('reviews.howChecked')}
</a>
</div>
<ul class={list.root()} aria-label={t('reviews.listLabel')}>
{reviews.map((r) => {
const hasText = Boolean(r.body?.trim());
const c = reviewCard({
surface: 'raised',
detail: 'overall',
comment: hasText ? 'text' : 'none',
layout: hasText ? 'card' : 'row',
});
const filled = starsFor(r.overall);
const aria = starsLabel(r.overall, ratingLabels, t('rating.of'));
const score = (
<span class={stars.root()} role="img" aria-label={aria}>
<span class={stars.value()}>{formatRating(r.overall, locale)}</span>
<span class={stars.stars()}>{'★'.repeat(filled)}{'☆'.repeat(5 - filled)}</span>
</span>
);
const author = (
<div class={c.identity()}>
<span class={c.author()}>{r.author ?? t('reviews.anonymous')}</span>
<time class={c.date()} dateTime={r.dateIso}>{r.dateLabel}</time>
{r.incentivised && (
<span class={c.disclosure()}>
<Gift className="icon-sm" aria-hidden />
{t('reviews.incentivised')}
</span>
)}
</div>
);
return (
<li key={r.id} class={list.item()}>
<article class={c.root()} aria-label={`${r.author ?? t('reviews.anonymous')} · ${r.dateLabel}`}>
{hasText ? (
<header class={c.header()}>
<div class={c.headerRow()}>
{author}
<div class={c.headerEnd()}>{score}</div>
</div>
</header>
) : (
<>
{author}
{score}
<button
type="button"
class={[button({ variant: 'ghost', size: 'md' }), c.spacer()].join(' ')}
>
<Flag className="icon-sm" aria-hidden />
{t('reviews.report')}
</button>
</>
)}
{hasText && <p class={c.body()}>{r.body}</p>}
{r.organiserReply && (
<div class={c.thread()}>
<article class={reply.root()} aria-labelledby={`reply-${r.id}`}>
<p class={reply.label()} id={`reply-${r.id}`}>
{t('reviews.replyFrom', { name: r.organiserName })}
</p>
<time class={reply.meta()} dateTime={r.organiserReply.dateIso}>
{r.organiserReply.dateLabel}
</time>
<p class={reply.body()}>{r.organiserReply.body}</p>
</article>
</div>
)}
{hasText && (
<footer class={c.footer()}>
<button
type="button"
class={[button({ variant: 'ghost', size: 'md' }), c.like()].join(' ')}
aria-pressed={r.likedByViewer}
onClick={() => toggleLike(r.id)}
>
<ThumbsUp className="icon-sm" fill={r.likedByViewer ? 'currentColor' : 'none'} aria-hidden />
{t('reviews.like')}
{r.likes > 0 && <span>{r.likes}</span>}
</button>
<button
type="button"
class={[button({ variant: 'ghost', size: 'md' }), c.spacer()].join(' ')}
>
<Flag className="icon-sm" aria-hidden />
{t('reviews.report')}
</button>
</footer>
)}
</article>
</li>
);
})}
</ul>Private
How reviews appear in organiser (business) queues — reputation, triage, and reply. Use reviewCard({ detail: 'categories' }) and wrap breakdown in reviewBreakdown({ expandable: true }) (native <details>, collapsed by default). See Collapse.
Written feedback and category detail
comment: 'text' with title + description. Open Category scores to expand density: 'full' rows (ScoreBar layout="stacked").
Memorable finish, well marshalled
The course was challenging but fair, and the atmosphere at the finish line made the effort worth it. Marshals were visible throughout and water stations were well stocked even for back-of-pack runners.
Category scores 7 categories
-
Communication
Information accessibility and clarity of instructions for participants
-
Provisions
Availability and quality of event provisions
-
Assistance
Medical cover, marshals, and help when something went wrong
-
Merchandising
Availability and quality of event merchandising, including official apparel and accessories
-
Fair Play
Fair competition, accurate timing, and enforcement of the rules
-
Sustainability
Waste reduction, cupless stations, and environmental impact on course
-
Security
Bag drop, access control, and safety on course
Everyone who can see this review can see your reply.
Only you, Eliane K. and Sporting Scouter can see this. It never appears on the event page.
Scores only — no title, no description
comment: 'none' — no written fields. author is Anonymous without athlete data. Compact rows show label + bar only; hover or focus the label for the category description (Tooltip in apps, itemTooltip slot).
Category scores 7 categories Needs attentionSustainability2.5Assistance3.0
- CommunicationInformation accessibility and clarity of instructions for participants4.5
- ProvisionsAvailability and quality of event provisions4.5
- AssistanceMedical cover, marshals, and help when something went wrong3.0
- MerchandisingAvailability and quality of event merchandising, including official apparel and accessories4.5
- Fair PlayFair competition, accurate timing, and enforcement of the rules4.5
- SustainabilityWaste reduction, cupless stations, and environmental impact on course2.5
- SecurityBag drop, access control, and safety on course4.5
Public reply and private thread
Two threads, two audiences. The public reply (thread) is the organiser's answer: one per review, shown on the public profile once the review is published, as on Google reviews. The private thread (privateThread) is for disputes. Today it is the dispute timeline: the organiser's statement, the athlete's evidence, Sporting Scouter's decision. It never appears on a public list, embed or widget. A thread can only start while the review is held before publication. While it is open, the card has Reply in private in the footer, right after the chat. Once it is closed, or the review is published, the thread stays readable and the button is gone: the first card below is closed, so it offers only Report; the second is open. Both threads state their audience in words and an icon (reviewReply().audience). The private thread is a chat: your messages on the right, the other party's on the left, Sporting Scouter's decision full width with its icon, and the sender's name always written.
Great provisions at the aid stations. The bag drop closed before I arrived, so I could not leave my kit.
Reply from Star Events
Visible to everyone
Thanks for the feedback. We are looking into the bag-drop hours for the 2027 edition.
Private threadOnly you, Ana S. and Sporting Scouter can see this
Closed · Review published
Star Events (you)
Submitted a dispute
The bag drop was open until 08:00, as the event guide says.
event-guide.pdf · PDF
Ana S.
Submitted evidence
I arrived at 07:40 and the bag drop was already closed.
bag-drop-photo.jpg · JPEG
Sporting Scouter
Decision: review published
Both accounts are plausible and the review does not break the rules.
Open private thread: reply
While a thread is open the chat is followed by the footer row: Reply in private opens the “Private message” form, and Report is still last. The status line says what the thread is waiting for. This review is held before publication, so its public reply is public once the review is published (the second of the three audience states: only your team, public once published, visible to everyone).
The finish area was crowded and my result was wrong for my category.
Reply from Star Events
Public once the review is published
Thanks for letting us know. We are checking the timing records.
Private threadOnly you, Pedro L. and Sporting Scouter can see this
Waiting for the athlete · Due Sep 28, 14:00
Star Events (you)
Submitted a dispute
Our official results show Pedro L. in the right category.
official-results.csv · CSV
Sporting Scouter
Reminder sent to Pedro L.
Only you, Pedro L. and Sporting Scouter can see this. It never appears on the event page.
Structure
Optional title and description slots; map categories to scoreBar() — derive fill from ratingOf() (ScoreBar). Reply uses card.footer(), the same action row as Report; publish is ink primary in card.replyForm().
import {
reviewCard, reviewBreakdown, ratingStars, starsFor, scoreBar, ratingOf, ratingTier, roundRating, formatRating, button,
} from '@sportingscouter/ui';
import { ScoreBar } from '@sportingscouter/ui-react';
import { Flag, Lock, MessageSquareReply } from 'lucide-react';
const card = reviewCard({ detail: 'categories', comment: 'text' });
const bd = reviewBreakdown({ expandable: true, density: 'full' });
const stars = ratingStars({ tone: 'star' });
// Up to two categories below 3.5 (good, fair or poor), lowest first. None: render nothing.
const attention = categories
.map((cat) => ({ cat, value: roundRating(cat.score) }))
.filter(({ value }) => value != null && value > 0 && ratingTier(value) <= 3)
.sort((a, b) => a.value! - b.value!)
.slice(0, 2);
<article class={card.root()} aria-label={…}>
<header class={card.header()}>
<div class={card.headerRow()}>
{/* identity — same as the public card, including the incentivised disclosure */}
<div class={card.headerEnd()}>
<span class={stars.root()} role="img" aria-label={aria}>
<span class={stars.value()}>{formatRating(review.overall, locale)}</span>
<span class={stars.stars()}>{'★'.repeat(starsFor(review.overall))}{'☆'.repeat(5 - starsFor(review.overall))}</span>
</span>
</div>
</div>
</header>
{review.title && <h4 class={card.title()}>{review.title}</h4>}
{review.description && <p class={card.description()}>{review.description}</p>}
<details class={bd.root({ expandable: true })}>
<summary class={bd.summary()}>
<span class={bd.summaryMain()}>
<span class={bd.summaryLabel()}>{t('reviews.categoryScores')}</span>
<span class={bd.summaryHint()}>{t('reviews.categoryCount', { count: categories.length })}</span>
{attention.length > 0 && (
<span class={bd.summaryAttention()}>
<span class={bd.summaryHint()}>{t('reviews.needsAttention')}</span>
{attention.map(({ cat, value }) => {
const bar = scoreBar({ rating: ratingOf(value!), size: 'sm', scale: 'sm', layout: 'cell' });
return (
<span key={cat.id} class={bd.summaryAttentionItem()}>
<span class={bar.root()}>
<span class={bar.label()}>{cat.label}</span>
<span class={bar.track()} aria-hidden="true">
<span class={bar.fill()} style={{ width: `${(value! / 5) * 100}%` }} />
</span>
<span class={bar.value()}>{formatRating(cat.score, locale)}</span>
</span>
</span>
);
})}
</span>
)}
</span>
<span class={bd.summaryIcon()} aria-hidden="true">{/* chevron-down / chevron-up */}</span>
</summary>
<div class={bd.panel()}>
<ul class={bd.list({ density: 'full' })}>
{categories.map((cat) => (
<li key={cat.id} class={bd.item()}>
<ScoreBar layout="stacked" label={cat.label} description={cat.hint} value={cat.score} />
</li>
))}
</ul>
</div>
</details>
<footer class={card.footer()}>
<button type="button" class={[button({ variant: 'ghost', size: 'xs' }), card.spacer()].join(' ')}>
<MessageSquareReply className="icon-sm" aria-hidden />
{t('reviews.replyPublicly')}
</button>
{canReplyInPrivate && ( {/* the private thread is open, or none exists and the review is held; never on a closed one */}
<button type="button" class={button({ variant: 'ghost', size: 'xs' })}>
<Lock className="icon-sm" aria-hidden />
{t('reviews.replyInPrivate')}
</button>
)}
<button type="button" class={button({ variant: 'ghost', size: 'xs' })}>
<Flag className="icon-sm" aria-hidden />
{t('reviews.report')}
</button>
</footer>
</article>Structure — the two threads
Public reply and private thread are separate regions with separate data. thread renders on public lists too; privateThread renders only when the viewer is the organiser, that athlete or Sporting Scouter. A public reply is not allowed on a review verified by email only or kept private (the API answers 400): show why next to “Reply publicly” instead of removing it silently.
import { reviewCard, reviewReply, statusDot } from '@sportingscouter/ui';
import { Clock, EyeOff, Globe, Lock, Paperclip, ShieldCheck } from 'lucide-react';
const card = reviewCard({ detail: 'categories', comment: 'text' });
const pub = reviewReply({ visibility: 'public' });
const priv = reviewReply({ visibility: 'private' });
{/* PUBLIC reply. The audience line is for the organiser's tools; a public list omits it. */}
{review.publicReply && (
<div class={card.thread()}>
<article class={pub.root()} aria-labelledby={`reply-${review.id}`}>
<p class={pub.label()} id={`reply-${review.id}`}>{t('reviews.replyFrom', { name: review.organiserName })}</p>
<p class={pub.audience()}>
{audience.team ? <EyeOff className="icon-sm" aria-hidden /> : audience.pending ? <Clock className="icon-sm" aria-hidden /> : <Globe className="icon-sm" aria-hidden />}
{t(audience.key)} {/* audienceTeam · audiencePending · audiencePublic */}
</p>
<time class={pub.meta()} dateTime={review.publicReply.dateIso}>{review.publicReply.dateLabel}</time>
<p class={pub.body()}>{review.publicReply.body}</p>
</article>
</div>
)}
{/* PRIVATE thread: a chat of the dispute (today). Only for this review's organiser, that athlete and
Sporting Scouter. Never in a public list, embed or widget, and the API must not send it to
anyone else. */}
{viewer.canSeePrivateThread && review.privateThread.length > 0 && (
<section class={card.privateThread()} aria-labelledby={`pt-${review.id}`}>
<h4 class={card.privateTitle()} id={`pt-${review.id}`}>
<span class={priv.audience()}><Lock className="icon-sm" aria-hidden />{t('reviews.privateThread')}</span>
<span class={card.privateHint()}>{t('reviews.privateOnlyYouAnd', { name: review.athleteName })}</span>
</h4>
<p class={card.privateStatus()}>
<span class={statusDot({ tone: dispute.closed ? 'muted' : 'primary' })} aria-hidden="true" />
{t(`reviews.dispute.${dispute.status}`, { date: dispute.deadlineLabel })}
</p>
{review.privateThread.map((m) => {
// `from` is the viewer's side: 'self' (their own message), 'other', or 'platform' (Sporting Scouter).
const msg = reviewReply({ visibility: 'private', from: m.from });
return (
<article key={m.id} class={msg.root()} aria-labelledby={`msg-${m.id}`}>
<p class={msg.label()} id={`msg-${m.id}`}>
{m.from === 'platform' && <ShieldCheck className="icon-sm" aria-hidden />}
{m.from === 'self' ? t('reviews.senderYou', { name: m.senderName }) : m.senderName}
</p>
<p class={msg.event()}>{t(`reviews.event.${m.type}`)}</p>
{m.text && <p class={msg.body()}>{m.text}</p>}
{m.file && (
<p class={msg.attachment()}>
<Paperclip className="icon-sm" aria-hidden />{m.file.name} · {m.file.type}
</p>
)}
<time class={msg.meta()} dateTime={m.dateIso}>{m.dateLabel}</time>
</article>
);
})}
</section>
)}Common information
Shared card anatomy, variants, and rules for public and private surfaces.
Surface · plain (optional)
surface: 'plain' drops the per-card border for hairline-separated rows when a parent already supplies a frame (embeds, widgets). Most product lists use raised.
Specification
| Property | Value | Why |
|---|---|---|
| Scope | All review lists in the product | One card — public, business, moderation, embeds |
| Overall | Figure and gold stars, trailing in the header | ratingStars, read-only. The figure (value slot) is the unrounded score, so 4.6 is never shown as five bare stars |
| Incentivised | disclosure slot, always the last line of identity (under the name and the date), on every surface and in both layouts | Required per product and legal (EU Omnibus Directive; legal confirms the basis and the wording): a review the author was rewarded for must be identified. Visible text with the gift icon, never a tooltip, never colour alone. The app renders it from the review's own data; it is not optional for those reviews |
| List summary | reviewList().summary, above the list, outside the <ul> | How many of all the reviews (every page, counted by the server) were incentivised, with the gift icon of the per-review disclosure, and a link to how reviews are checked. The count shows only above zero; the link is always there. Never computed from the visible page. The wording of the verification statement the link opens is product and legal copy |
| Score only | layout: 'row' with comment: 'none' | One compact band: the card's identity block (name, the date below it, the disclosure), the figure and stars, and Report. A review with no text is not a box with nothing in it |
| Written feedback | title + description slots | Optional headline plus body; description alone is valid |
| Comment | comment: 'text' | 'none' | Rating-only: omit title, description, and body |
| Category rows | full vs compact | Full shows description inline; compact shows it on label hover/focus via tooltip |
| Expand | reviewBreakdown({ expandable: true }) | <details> collapsed by default on organiser lists; chevrons swapped, not rotated |
| Categories | reviewBreakdown + scoreBar | Organisers triage on Communication vs Provisions, not stars alone |
| Author | Display name or Anonymous | When athlete PII is unavailable to the viewer, always render Anonymous — never omit the name row or show initials alone |
| Element | <ul> of <li><article> | Each review is its own document |
| Actions | One footer row, after the review | Body, then any organiser reply, then the row. The same place on every surface: public (Like, Report) and organiser (Reply publicly, Reply in private, Report). Report is always the last item, at the right edge. Never in the header. A score-only row has Report at its right edge and nothing else |
| Like | Public, leading in the footer, on every written review | thumbs-up at 16px, ghost size: 'md', like slot, aria-pressed. The label stays “Like”; the state is aria-pressed plus a filled icon, never a changed label or colour alone. The count sits beside it and is hidden at zero |
| Report | Always available, on every review and every surface, for anyone who can see it | flag at 16px, ghost, trailing and always the last action: size: 'md' (44px) on public lists, 'xs' in organiser tools. No sign-in requirement in the card: the app decides what a signed-out visitor must provide to flag. Always at the right edge: on a score-only row too, and still on the right when the row wraps on a narrow screen |
| Reply publicly | Business only, ghost in footer | message-square-reply at 16px, size: 'xs' (organiser tools are desktop), trailing in the same footer row as Report; the form says who will see it; publish is ink primary in the form. One public reply per review |
| Reply in private | Organiser, only while the review is held before publication; same footer row | lock at 16px, size: 'xs', trailing. Opens a form whose hint names the audience (“Only you, Eliane K. and Sporting Scouter can see this. It never appears on the event page.”); publish reads “Send message”. Replies into the open thread, or starts one when the review is held before publication and none exists (one open at a time). Gone once the thread is closed or the review is published. The result is privateThread: a privateStatus line and chat bubbles, reviewReply({ visibility: 'private', from }) |
| Public reply audience | audience on the organiser's tools | Three states, in words and an icon: only your team (internal), public once the review is published (waiting for approval), visible to everyone (published). Not allowed on reviews verified by email only or kept private: say why next to the button, and do not hide it silently |
| Athlete's side | Not in this card | The athlete has no signed-in view of the thread: they answer from an emailed link (one-time page, evidence file and optional statement). Their entries appear here once submitted |
| Needs attention | Organiser summary, collapsed; only when it applies | summaryAttention: up to two categories whose tier is below very-good (under 3.5: good, fair or poor in ratingOf()), lowest first, as scoreBar({ layout: 'cell' }) items. When every category is at 3.5 or above, nothing is rendered: the summary stays “Category scores · 7 categories”. The tier is the fill colour; the figure stays in ink. The average is not repeated: the header already shows it. The cut is product policy (ratingTier(value) <= 3) |
Props
| Recipe | Prop | Values | Default |
|---|---|---|---|
reviewCard | comment | 'text' | 'none' | 'text' |
reviewCard | detail | 'overall' | 'categories' | 'overall' |
reviewCard | surface | 'raised' | 'plain' | 'raised' |
reviewCard | layout | 'card' | 'row' | 'card' |
reviewReply | visibility | 'public' | 'private' | 'public' |
reviewBreakdown | density | 'compact' | 'full' | 'compact' |
reviewBreakdown | expandable | true | false | false (public); true on organiser lists |
reviewList | surface | 'raised' | 'plain' | 'raised' |
Rules
- Do not render an empty
<p>for missing comment text — usecomment: 'none'and omitbody. - Do set
authorto localized Anonymous when the viewer cannot receive athlete identity — same card, same slot. - Do collapse category breakdown on private organiser lists; never collapse the overall stars or the written review.
- Do attach the ScoreBar description to compact labels with
tooltip()— the label stays visible; description is supplementary, not the only name. - Do use the same raised card on public and private lists — omit category breakdown on public surfaces unless product explicitly shows it.
- Do derive bar fill colour from the score via
ratingOf()— never hand-pick tier colours in apps. - Do not hide a published organiser reply behind a disclosure on public lists.
- Do not render
privateThread, a private message or “Reply in private” on a public list, embed or widget, and do not send private-thread data to a public payload. Only the review's organiser, that athlete and Sporting Scouter may receive it. - Do draw the private thread as a chat: the viewer's messages on the right (
from: 'self', tinted, name “… (you)”), the other party's on the left ('other'), Sporting Scouter full width with the shield icon ('platform'). Do not rely on side or tint alone: write the sender's name on every message and the caption of what they did. - Do state the audience in words on every reply and form: “Visible to everyone” and “Only you, Ana S. and Sporting Scouter can see this”. Never rely on colour or the icon alone.
- Do offer “Reply in private” in the footer, after the chat, while the private thread is open (or none exists and the review is held before publication). Do not offer it on a closed thread or a published review: the thread stays readable and the button is gone.
- Do write the thread's state in words (
privateStatus, with astatusDot), never colour alone. Wording for each dispute state is below; the package ships none. - Do put every action in the one
footerrow after the review, in the same place on every surface. Do not put an action in the header. - Do offer Report on every review, on every surface, to anyone who can see it: cards, rows and the organiser queue. It is always the last, trailing action. Do not hide it behind a sign-in, drop it from compact rows, or let it wrap next to the stars: it is at the right edge.
- Do render Like on every written public review, as an
aria-pressedtoggle with a constant label. Do not change its label with its state, and do not put it on a score-only row. - Do show the list summary above every review list: “12 of 156 reviews were incentivised” (from the server's total, not the visible page) and a link to how reviews are checked. Do not hide the count when it is non-zero, and do not count only the current page.
- Do put
disclosurein the same place on every card and row: the last line of the identity block, under the name and the date. Do not move it beside the date or into the header. - Do render
disclosure(“Incentivised review”) on every review the author was rewarded for, in lists, embeds, widgets and the organiser queue, on cards and on rows. Do not hide it behind a tooltip or a disclosure, or drop it in compact lists. It is text with an icon, so it never depends on colour. - Do render a review with no text as
layout: 'row'. Do not render a card with only a name and stars. A row's only action is Report. - Do show the figure beside the stars. Do not show stars alone: they round, so 4.6 and 4.5 read the same.
- Do show “Needs attention” in the collapsed summary only when a category scores below 3.5, with at most two compact
scoreBaritems (layout: 'cell'), lowest first. Do not show the lowest categories of a review that is fine: a green 4.7 labelled as a problem is noise. Do not use the solid ratingbadgethere (its text is below AA contrast on amber) or agauge(one per view; a ring per card cannot be compared). - Do pair long public lists with Pagination and restore focus after client-side page changes.
Private thread: suggested wording
Today the private thread is the dispute, shown as a chat. These are the states and events the API writes, with English wording the apps can use. The caption goes in event and the sender's name in label (yours with “(you)”). They are product copy: the package ships none.
| Dispute state | Status line | Message (event) | Caption | Side |
|---|---|---|---|---|
DRAFT | Draft | organizer_submitted | Submitted a dispute | organiser |
AWAITING_ATHLETE | Waiting for the athlete · Due {date} | athlete_evidence_submitted | Submitted evidence | athlete |
AWAITING_PUBLICATION | Waiting for publication | organizer_cancelled | Cancelled the dispute | organiser |
IN_SS_REVIEW | In review by Sporting Scouter · Decision due {date} | ss_decision | Decision: {outcome} | Sporting Scouter, full width |
RESOLVED_*, DISMISSED, AUTO_DISMISSED, CANCELLED | Closed · {outcome} | system_rule | Closed automatically: {reason} | Sporting Scouter, full width |
email_sent, status_change | Do not show raw event names. Hide, or say “Reminder sent to {athlete}” | |||
theme.css.