Handoff — Group Feed becomes an EVENT feed (+ the return-after-the-win modal)
Product / feature change (one line)
The group feed stops being a chat: nobody types free text any more — the week posts events (medals, four week milestones, a joiner welcome, a try request, the goal-reached win), members react on them, and the only words they can send back are preset chips from a vocabulary the event itself loads, landing as a threaded reply under that event. Members also get a "Your updates" band + drawer collecting the reactions/replies their own events drew, and returning after a won week now opens a Group Win celebration modal on Home.
The single biggest integration consequence: on integration
FeedCardsubsumes the repo'scelebration-card.tsx/system-card.tsx/group-win-card.tsxtrio — those three stop being siblings and become three of one card's five faces, discriminated by a new server-sideFeedEvent.type.
Component changes
New
- FeedCard (
components/FeedCard/FeedCard.{html,js,css}) — NEW. One card, five faces, eleven states; the event type IS the state (medal-bronze·medal-silver·medal-gold·medal-platinum·week-start·mid-week·half-target·close-to-goal·member-joined·try-request·goal-reached).onMountowns only try-gifting (the CTA flips the card to its granted face in place — no second "gifted" item is posted).
data-modelFeedEvent{ type, postedBy: 'group'|'member', reactions: Reaction[] }+ per-type payload:MedalEarned{gameSlug,gameName,tier,points}·Milestone{stage,points,target,subtitle}·MemberJoined{name,avatarSlug,memberCount}·TryRequest{requester,requesterAvatarSlug,gameSlug,status}·GroupWin{points,target,memberCount,medalCount,contributingMemberCount,members[]}.
New bound fields:celebration.tier/.gameName/.points,group.points/.target/.memberCount/.medalCount/.contributingMemberCount,milestone.subtitle(data-formatted),memberJoined.name,tryRequest.requester,member.avatarSlug,reaction.count.data-repo:src/features/challenges/components/group-feed/feed-item.tsx→ becomes<FeedCard event={…}/>.
DATAQuery groupFeed(groupId, week) → FeedEvent[](proposed). Backend GAP: the repo has only three of the five faces and notypediscriminator; the MILESTONE + WELCOME faces and the discriminator are owed. Milestone events must be server-scheduled posts (Mon 00:00 / Wed 09:00 / threshold crossings), not client renders of the live score — otherwise they rewrite themselves.
ACTIONSgift-reset→Mutation giftTryReset(requestId)(premium-only) ·arm-reply→ local UI event caught by Feed (no mutation). - YourUpdates (
components/YourUpdates/YourUpdates.{html,js,css}) — NEW. Two surfaces, one component: the band pinned under the group header and the drawer it opens over the feed. States:has-updates·open·all-read(the band deliberately stays when empty).update()moves focus to the drawer's ✕ on open; Escape closes.
data-modelYourUpdates{ newCount, eventCount, respondents[{avatarSlug}], groups: UpdateGroup[] },UpdateGroup { eventId, eventTitle, eventTime, thumb, newCount, read, reactions[], replies[] }.
New bound fields:updates.newCount·updates.eventCount·updates.eventTitle·updates.eventTime(data-formatted) ·updates.groupNewCount·reply.author/reply.text·member.avatarSlug.data-repo:new→ becomes<YourUpdates state={…}/>mounted by<GroupFeed/>.
DATAQuery myGroupUpdates(groupId, since: member.lastFeedVisitAt)(proposed). Backend GAP: neither the query normember.lastFeedVisitAtexists — both owed. It must be a server-side aggregate, not a client filter over the loaded feed page (responses can predate the window).
ACTIONSopen-updates→Mutation markUpdatesSeen()(the band tap stampslastFeedVisitAt; reading the sheet is the read gesture — no "Mark all as read") ·close-updates→ no mutation. - GroupWin (
components/GroupWin/GroupWin.{html,js,css}) — NEW. The return-after-the-win modal on Home. States are the three ways a won week resolves:won·won-unanimous(every member scored — the copy earns "all") ·you-absent(goal cleared without you — the roll must never claim you added points;update()hides the "you added N" clause). ExportsOPEN_GROUP_WINso the screen doesn't hard-code the event string. The+Noverflow pill is derived (members.length - 3), which is why it carries nodata-field.
data-modelGroupWeek{ group{ name, points, target, medalCount, memberCount, contributingMemberCount, topContributor{name,points}, otherContributorCount, members[{avatarSlug}] }, self{ points, scored }, weekEndsAt }.
New bound fields:group.topContributor.name/.points·group.otherContributorCount·member.self.points(plus the sharedgroup.*set).data-repo:new→ becomes<GroupWinModal state={…}/>. Build it as a sibling, not from scratch: shell anatomy fromsrc/features/challenges/components/pre-game-challenge-modal.tsx(dialog, scrim, frosted close, full-width pill CTA); payload language fromsrc/features/challenges/components/group-feed/group-win-card.tsx(trophy, collective score, medal tally, crew stack). Avatars →<ProfileAvatar avatarSlug=…/>.
DATAuseGroupWeekResultApi(groupId)→groupWeekResult(groupId, weekEndsAt)(proposed) — the closed week's totals + contributor roll, plus a server-ownedacknowledgedAtso it shows once per week boundary per member.
ACTIONSstart-week→ dismiss +acknowledgeGroupWeek(groupId, weekEndsAt)(proposed) ·dismiss→ same mutation, no navigation (✕ / scrim / Esc).
Changed
- Feed (
components/Feed/Feed.{html,js,css}) — rewritten from chat to event stream. It now owns only the row chrome — avatar, the stamp moved ABOVE the card (so the reply thread can hang off the card's free bottom edge), the reply thread — and composes one FeedCard per row plus one YourUpdates. New markup layer.group-feed__lower(position:relative) hosts the band, stream, composer and the tour gate.- State removed:
just-joined(and with itplayCaughtUp/runCurtain— the Silk-curtain reveal + counter pill). Remaining states:active·empty·win·just-joined-tour. The tour direction is now the only mid-week-join story. - New: the targeted reply composer. One composer for the whole feed, absolutely positioned over the stream's bottom so arming an event never changes the feed's height; the stream instead grows a measured tail and scrolls just enough to clear it. Idle footer carries no chips at all (an untargeted chip is the ambient noise this design removes).
- New: the preset VOCABULARY — 8 sets of 8, keyed by what the reply is directed at (person:
medal-routine/medal-standout/welcome; group:open/availability/momentum/final-push/victory), mapped from event type byVOCAB_FOR(medal sets picked by tier;try-requestloads none — granting the reset is its only response). Ship it as one server-served data table, not literals per event.victory's 8th chip is a theme slot filled fromtheme.labels.crew, so the one chip that names the group stays data. - New model:
Reply { author, avatarSlug, text, isSelf }→reply.author/reply.text/member.avatarSlug. Replies are one level deep — no thread ever needs collapsing. Text is always a preset, so it is safe to render raw. cssUrlis now an array (FeedCard + YourUpdates sheets load before Feed's own, deterministic order).onMountisasyncand mounts children witheditorHidden: true.- ACTIONS added:
arm-reply/disarm-reply(local UI, no mutation) ·send-reply→Mutation postReply(eventId, presetKey)— sends the preset KEY, not the rendered string, so copy can change without rewriting history.say-hi→postReply(...).gift-resetmoved to FeedCard.dismiss-gate→markMemberWelcomed()unchanged. - Backend GAPs restated on the Feed note: (1)
FeedEvent.type+ scheduled milestone events, (2) theevent.replies[]edge +postReply, (3) the VOCAB table as served copy, (4) the join-timeCatchUpDigestaggregate. data-repounchanged:src/features/challenges/components/group-feed/group-feed.tsx→<GroupFeed/>.
- State removed:
Page changes
challenges/challenges.js—slot-feedandslot-chat-feednow passprops: { cardDef, updatesDef }(the AwardsSummary pattern: the screen owns the wiring, Feed falls back to its own imports in gallery/editor).setupMemberPopover()trigger selector now excludes group-authored rows —.feed-item:not(.feed-item--group) .feed-item__avatar/__author— because a group is not a member and has noMemberProfile. Lands in the repo Challenges route mounting<GroupFeed/>+ the app popover layer anchored to<ProfileAvatar/>.challenges/challenges.html— doc block rewritten: the domain tree'sChat/Celebrationbranches becomeFeed(8 event types) +Response(reaction or preset reply, no free text ⇒ no moderation surface); REPO COMPONENT MAP gainsfeed card→group-feed/feed-item.tsxandyour updates→ NEW; PAGE BEHAVIOR records the member-popover exclusion.challenges/home.html/home.js— newslot-group-win(gated overlay). New PAGE BEHAVIOR entry week-boundary gate: GroupWin is time-triggered, not user-triggered — fire it on load whengroupWeekResult(groupId, weekEndsAt).goalReached && !acknowledgedAt. Prototype stand-in is?group-win=won|won-unanimous|you-absent; on integration replace ONLY that param read — the event + component are unchanged. Lands on the repo Home route.challenges/challenges.css— press-feedback selector.quickreply→.reply-chip.
Registration
challenges/challenges.jsLAYOUT— FeedCard + YourUpdates enter via Feed's props (both feed instances, themed and unthemed paths);slot-group-winadded tohome.jsLAYOUT.challenges/gallery.jsCATALOG— + FeedCard (248px, 🗂️), + YourUpdates (370px, 🔔), + GroupWin (452px, 🎉).challenges/gallery.cssneutralises GroupWin's fixed positioning and un-does YourUpdates'display:contents/ absolute drawer — with a child combinator (.specimen__stage > .your-updates), load-bearing: as a descendant selector it also hit the band nested inside the group-feed specimen and clipped it to a 12px sliver.core/gallery.js— the gallery navbar + sections now sort alphabetically by display label (locale-aware,numeric: true), so a component is findable by name and CATALOG order stops mattering. Shoot mode is left unsorted (address lookup).
Motion
- Reply landing (
Feed.css.feed-reply--in) — 200mscubic-bezier(0.32,0.72,0,1)fade + 4px rise as a preset chip lands under its event. transform/opacity only, reduced-motion-gated. FM equivalent:<motion.div initial={{opacity:0,y:-4}} animate={{opacity:1,y:0}}>withSPRING_SOFT(README porting table). - GroupWin arrival (
GroupWin.css,@media (prefers-reduced-motion: no-preference)) — staged: dialog rise 420ms → trophy pop 620ms @160ms (overshoot ease) → crew line 460ms @500ms → each crew avatar 340ms, stagger. transform/opacity only. FM equivalent: a<motion.div>dialog withvariants+staggerChildrenon the crew stack, gated byuseReducedMotion(). - Removed: the
just-joined"Silk" curtain wipe + counter pill (runCurtain,playCaughtUp) andMotionFX.bubbleIn(the chat-bubble slide) — both belonged to surfaces that no longer exist. - The
just-joined-tourentrance (playIntrobeats 0–4,feed:tour-gate→ the screen's page dim) is unchanged in behavior; comments retitled chat → feed.
Shared helpers
common/interactions.js— now the reaction strip only. Quick-reply sending (sendQuickReply,.quickreply) deleted; the picker/strip lookup collapses to one class,.reactions, for every FeedCard face (the win card only adds a modifier). Delegated at the document, so it covers strips in a mounted Feed and standalone FeedCard specimens.common/motion.js—MotionFX.bubbleInremoved; pressable selector.quickreply→.reply-chip.
Assets
None added — every asset the new components reference already existed (trophy.png, tier medals, icons/modal-close.svg, icons/avatar-*.svg, icons/lucide/{calendar-clock,calendar-plus,circle-check,flame,message-circle}.svg, game tiles).
Open questions for the dev
- FeedCard's repo binding is a proposed one.
group-feed/feed-item.tsxexists, but this card is a superset of it pluscelebration-card.tsx/system-card.tsx/group-win-card.tsx. Confirm you want the trio collapsed into one discriminated card before wiring — that's a repo-shape decision, not a markup one. - Milestone events must be server-scheduled, not derived client-side. If the backend can't schedule posts, the four milestone states need a different contract (and would visibly rewrite themselves mid-week).
- The preset vocabulary is copy that changes often — confirm it ships as served data keyed by preset key, so
postReply(eventId, presetKey)stays valid across copy edits.