Feature Generation & Frontend Consolidation — 2026-08-03¶
Document type: Audit + the implementation record for Part I
Branch audited: claude/website-feature-audit-36kvk6, revised against 0bd0236a
Two questions asked: (1) what is this site lacking relative to comparable
platforms, and (2) how do we consolidate what already exists, especially on the
frontend.
Revision note (same day). The first pass of this document was written against
3047783. Four commits landed onmainimmediately afterwards, one of them a large SEO sweep (8cad6b95, “SEO: make every public page discoverable, and keep it that way”) touching ~35 of the routes audited here plus the whole sitemap layer, and one retiring the Alex Discord bot (7cc2a801, −3 Prisma models). Every finding those commits touched has been re-verified against the merged tree and is flagged [resolved], [escalated] or [unchanged] below. The short version: F1 shipped, §0(a) is fixed, C3 got easier, C2 and C8 are now confirmed by the repo’s own code comments, and C1 got worse — the SEO pass hardened two duplicate URLs with self-canonicals rather than removing them.
Method. Read the live tree — app/routes/** (734 route files, 127 under
_site/, 125 API namespaces), lib/games.ts + lib/apps.ts, lib/sidebar-nav.ts,
prisma/schema.prisma (249 models), components/** (927 .tsx, 41 .css) — then
subtracted everything already shipped and everything already specced in the four
prior idea docs so nothing here repeats them:
plans/2026-07-15-cross-system-feature-ideas.md— 6 featuresplans/2026-07-19-platform-expansion-design.md— 12 featuresplans/2026-07-20-parity-qol-customization-design.md— 16 featuresplans/2026-07-31-feature-gap-ideas.md— 24 features
Every item states its gap evidence — the file, model or grep that proves it — so none of it is speculative. Effort is XS/S/M/L on the same scale the other plan docs use.
Framing, and it is different from the 07-31 doc’s. That document concluded the platform is past “build the basics” and that the remaining gaps are in safety, ops and connective tissue. Re-reading the tree with a consolidation lens, there is a fifth thing, and it now outranks most of the feature backlog:
This site has been consolidating for weeks and the consolidations are only half-landed.
/playlists,/creator-studio,/arcade,/leaderboard,/buildsand/marketwere correctly folded into hub pages and reduced to redirect stubs. But/shopand/pricingwere folded into/storeand left live;/explorelost its nav entry and kept its page, reachable only from the 404;/profile/$idwas superseded by/u/$useridand left live; thePageLayoutmigration stopped at 65 of 127 pages and left a dead import behind in 22 of them. The result is roughly a dozen URLs rendering content the reader can reach two ways at two levels of quality — and, since8cad6b95, the two ways now compete over which is canonical rather than merely leaving it unstated.Finishing the consolidations already started is cheaper than any feature in this document and improves more of the site. Part I is that work. Part II is the new-feature answer.
STATUS — all of Part I is implemented. C1–C9 shipped on
claude/website-feature-audit-36kvk6(PR #750) in six commits after this document was written. Each finding below keeps its original text, because the evidence is why the change was made; what changed is recorded per item as [DONE] with the commit’s shape. Part II is still a proposal list — nothing in it has been built.Where implementation disagreed with the plan, the plan was wrong and the record says so:
C4 did not merge the two card renderers. Doing so would move 30 imports and 11 hooks onto the wheel, against the measured 3D-transform cost in design.md §4. The real defect — two private relative-time formatters giving different answers for the same post, one of them re-introducing the hydration hazard
RelativeTimeexists to fix — was fixed instead, and the split is now documented rather than accidental.C7 did not merge Lists or Wishlist into
SavedItem. A list curates accounts; a wishlist entry carries a target price. They became tabs of one destination, not rows in one table.C8 grew a second half nobody planned:
/createstopped rendering the catalog at all, because leaving it at two URLs would have recreated exactly what C1 removed.
§0 — Two live defects found while auditing¶
Not features. Reporting them here because they were found on the way and both are small.
(a) /sitemap.xml advertised two URLs that 404. — [resolved by 8cad6b95]
The old app/routes/sitemap[.]xml.ts emitted /games and /apps at priority 0.9,
hardcoded its own SITE_URL, and omitted profiles and tags. All three are fixed:
the route is now a 39-line sitemap index delegating to /sitemaps/{section}[-{n}].xml
(app/routes/sitemaps.$name.ts + lib/sitemap.ts + lib/sitemap.server.ts), it
imports SITE_URL from @/lib/seo, and users, posts, vibe pages and communities
are all sections now. Two new suites gate it —
lib/__tests__/sitemap-coverage.test.ts (“lists no path that has no route”, “lists
no route that only redirects”, “gives every sitemap-listed page a description and a
canonical”) and sitemap-sections.test.ts. This is a better outcome than the
finding asked for; see F1, which is now delivered.
One artifact of the fix is worth carrying forward: lib/seo-catalog.ts now exports
/** Where the games/apps browser actually lives. There is no `/games` route. */
export const GAMES_INDEX_PATH = '/create?tab=games';
export const APPS_INDEX_PATH = '/create?tab=apps';
The SEO work hit C8’s wall and worked around it by encoding the tab URL as a constant. That is the right call for a sweep that wasn’t allowed to add routes — and it is independent confirmation that the catalogue has no index page.
(b) 22 route files import WIDE_NO_RIGHT_SIDEBAR_WIDTH and never use it. — [unchanged]
c.$slug, progress, ranked, shop, drafts, moments.$id, thread/$rootId,
achievements, personas/$id, music-trivia, tag.$tag, store/$userid,
wrapped, bookmarks, recap, spaces.$id, roadmap, groups/index,
study/browse, study/$deckId, study/index, explore. Residue of the layout
refactor described in C5. Harmless at runtime; useful as a map of exactly which
pages the migration didn’t finish.
8cad6b95 edited 13 of these 22 files to add a head() and left the dead import
in every one of them — shop.tsx is the clearest case, where the commit reformatted
the AnimatedMain call two lines below the unused import. That is the finding’s own
proof: this residue is invisible to a reviewer working route-by-route, which is why
C5 proposes a lint gate rather than a one-off cleanup.
Part I — Consolidation¶
The recipe is already in the repo¶
Four surfaces have been consolidated correctly, and they establish a pattern worth naming, because every proposal in this part is an application of it:
Move the surface’s content into a tab of a hub page —
PageTabs+components/ui/liquid-tabs(the only sanctioned tab strip) + a?tab=search param validated invalidateSearch.Replace the old route file with a
beforeLoadredirect stub that maps the old search params onto the new ones, so deep links and in-app back-links survive.Remove the entry from
SIDEBAR_NAVinlib/sidebar-nav.tsso the globe gets a pin back.
app/routes/_site/arcade.tsx is the reference implementation — 26 lines, and it
translates a legacy ?tab=leaderboard into the new ?tab=games&sub=leaderboard.
_site/creator-studio.tsx, _site/leaderboard.tsx, _site/builds/index.tsx,
_site/market.tsx and _site/playlists.tsx are the others. Each carries a
docstring explaining where the content went, which is why this audit could
reconstruct the history at all — keep doing that.
Everything in C1–C4 below is a case where steps 1 and 3 happened and step 2 did not.
C1. Finish /store: /shop and /pricing are still live duplicates — [DONE]¶
Both are
beforeLoadredirect stubs; both moved fromSTATIC_ROUTEStoEXCLUDED_ROUTESas redirects; the membership OG card followed/pricingto/store, which had been falling through todefault.png. Seven inbound references repointed, including the/shoplink on the settings page.
What changed.
8cad6b95gave/shop,/pricingand/storeeach abuildMeta({ path })+buildCanonical()self-canonical, and listed/shop(0.5),/store(0.5) and/pricing(0.6) in the sitemap as three separate pages. Before the sweep, none of the three had a canonical, so a crawler had to guess which was authoritative. Now/shopand/store?tab=shop— the identicalShopColumn, the identicalgetShopData()loader — each assert that they are the canonical version of the same content. An ambiguous duplicate became a contradictory one.This was not an oversight.
sitemap-coverage.test.tsline 266 requires that every sitemap-listed page have a description and a canonical, so the gate will keep re-asserting/shop’s self-canonical for exactly as long as/shopremains a listed route. The gate cannot fix this and correctly does not try — the decision “should this page exist” is upstream of it.The vocabulary to express the answer already exists in the same file:
EXCLUDED_ROUTESinlib/sitemap.tscarries a'duplicate'reason, used for/rmhtype/solo('duplicate', // same content as /rmhtype) and the six/daily/*puzzle routes./shopand/pricingare that case and were listed instead. Redirecting them (the proposal below) is better still, becausesitemap-coverage.test.tsline 174 — “lists no route that only redirects” — then removes them from the sitemap automatically.Effort is unchanged at XS. Urgency is now the highest in Part I.
Evidence. _site/store/index.tsx docstring: “Merges what used to be three
separate destinations — Membership (/pricing), the cosmetics Shop (/shop), and
the player-to-player Marketplace (/market) — into a single tabbed page.” Of the
three, only /market became a redirect stub. /shop is a live 37-line page whose
body is <ShopColumn initialData={shop} /> fed by getShopData() — byte-for-byte
the same component and the same loader as the shop tab of /store (store/index.tsx
line 134). /pricing is a live page whose body is <MembershipPanel/>, and its own
docstring admits the panel is “also embedded at the top of the combined /store
page.”
Consequence. Two indexable URLs per surface with no rel=canonical between
them; two SSR loaders doing the same query; and _site/settings/index.tsx line 165
still links users to /shop rather than /store?tab=shop, so the settings page
actively routes people away from the consolidated surface.
Proposal. Turn /shop → redirect('/store', { tab: 'shop' }) and /pricing →
redirect('/store', { tab: 'membership' }), following the market.tsx stub exactly.
Repoint the /shop link in settings/index.tsx. Grep for other to="/shop" /
to="/pricing" call sites first — pricing in particular is likely linked from
membership upsells and the Stripe return URL, and a redirect is fine for all of them
but the upsell copy may want the direct ?tab= deep link.
Watch for. /pricing is the URL you’d hand to a payment processor, a partner or
an ad. Keeping it as a redirect (not deleting it) is the whole point.
C2. Retire /profile/$id in favour of /u/$userid — [DONE]¶
Redirect stub; the param passes straight through since
/u/$useridresolves an id. Six call sites that fell back to/profile/<id>when a handle was missing now use/u/<handle ?? id>.
What changed. The SEO sweep classified every route in the tree, and it recorded this exact finding in code.
lib/sitemap.tsline 374:'/profile/$id': null, // legacy alias of `/u/$userid` '/store/$userid': null, // mirrors the profile; would duplicate itSo the duplicate is now documented and excluded from the sitemap — and still live, still linked from wherever it was already linked, and still the only one of the pair without a
rel=canonical. Sitemap exclusion is not deindexing: it stops the site submitting the URL, it does nothing about crawlers reaching it from links, history or the existing index. A page that a crawler can reach, that duplicates another page, and that carries no canonical, is the precise shape this finding describes — and the sweep that touched ~35 route heads did not give this one a head at all.The fix below is unchanged and now has the repo’s own comment as its justification. Note the same line flags
/store/$useridas a profile mirror, which is worth a look on the same pass.
Evidence. diff of the two files: they are the same route, and the older one is
strictly worse. _site/u/$userid/index.tsx (147 lines) has a rel=canonical link,
a personSchema JSON-LD block, RSS autodiscovery for the user’s feed, and handle
resolution with the legacy @ prefix stripped. _site/profile/$id.tsx (112 lines)
has none of those four — no canonical, no JSON-LD, no feed link, id-only lookup —
and its OG-card comment (”/u/$userid already used it; this route — the same page
under a different URL — was still sharing a cropped 400px square”) records that this
exact divergence has already bitten once and was patched on one side only.
Consequence. Every profile on the site is reachable at two URLs. The duplicate carries no canonical, so search engines pick a winner themselves, and roughly half the time that winner is the page with no structured data. This is the single clearest SEO defect in the tree.
Proposal. _site/profile/$id.tsx → beforeLoad: redirect({ to: '/u/$userid', params: { userid: params.id } }). Delete the duplicated fetchProfileData server
function with it.
C3. /explore is orphaned, and SEO already picked the winner — [DONE]¶
One
hrefinSIDEBAR_NAV, withid: '/search'kept stable. That broke a test assertingid === href, which was the wrong invariant — see C5.
Evidence. SIDEBAR_NAV maps the label “Explore” to /search (line 65), and
_site/search.tsx titles itself explore-title → “Explore”. Meanwhile
_site/explore.tsx is a separate live page rendering a different component
(ExploreColumn, fed by listExplore()). The only in-product link to it is
components/errors/NotFound.tsx:46 — the 404 page (pre-existing, from 1cf59ccc,
not the SEO sweep). It is not in the nav, not in the globe, not in the rail.
What changed, and it resolves the open question. The first pass of this document offered two options — fold
/exploreinto/search, or promote it. The SEO sweep answered, inlib/sitemap.ts:{ loc: '/explore', changefreq: 'daily', priority: 0.8 }, // listed, high priority '/search': 'noindex', // a search results page is thin by construction
/explorealso got a fullbuildMetadescription and a self-canonical. So the SEO layer has decided/exploreis the public Explore surface and/searchis a thin results page that should not be indexed at all — which is the correct call on both counts.The nav was not told. Today the site tells crawlers “Explore is
/explore, priority 0.8, changes daily” and tells users “Explore is/search, which we have asked Google to ignore.” The 404 page is the only thing in the product that agrees with the sitemap.
Proposal — now one line, not a decision. Point the Explore entry in
SIDEBAR_NAV at /explore instead of /search:
{ id: '/search', href: '/search', tKey: 'nav-explore', label: 'Explore', icon: Compass },
// ^^^^^^^^ ^^^^^^^^ → '/explore'
Two cautions, both cheap:
idis the stable customization key validated againstSIDEBAR_NAV_IDSinlib/home-widgets.ts— the file’s own header says renaming an id is a data migration. Changehrefand leaveid: '/search'alone unless you ship the migration./searchmust stay reachable: it is where the search field submits and whereSavedSearcheslinks. Keep it as a destination, just not as the nav’s Explore pin.
Then ExploreColumn should gain a search entry point (or /explore should host the
field) so the two surfaces are one journey rather than two doors. ExploreColumn
already renders MemoRMHarkCard from VirtualPostList, so it is on the good card
path (C4) and inherits any fix made there.
C4. Two post-card renderers — [DONE, differently]¶
Not merged; see the status banner.
radial/RmharkCard.tsx→WheelCard.tsx(killing the case-only collision), both cards onto onerelativeTimeShortthroughRelativeTime, and the deliberate split documented in the file.
Evidence. components/feed/RMHarkCard.tsx (541 lines) is the real one, used by
FeedItem, VirtualPostList, ExploreColumn, ExploreRecommendations,
RelatedPosts and ThreadView. components/radial/RmharkCard.tsx (163 lines) is a
second, smaller renderer of the same RMHark object, used by exactly one caller:
radial/RadialFeed.tsx, the wheel on the home feed.
The names differ only in the case of three letters. On a case-insensitive
filesystem — macOS default, and a plausible contributor machine — RmharkCard.tsx
and RMHarkCard.tsx are a collision waiting for the day someone moves one of them
into the other’s directory.
Consequence. The home feed — the site’s most-viewed surface — renders posts
through a card that knows about a subset of what a post can be. Anything added to
the 541-line card (a new media type, a new badge, a content-warning state, an
audience marker) silently does not appear on the wheel. This is the classic
“two surfaces disagree about what this site is” defect that design.md §10.2 warns
about by name.
Proposal. Give RMHarkCard a variant="wheel" (or a compact prop) covering
what the radial card does differently — the wheel needs a fixed height and a reduced
element count for the 3D rake, which is a legitimate constraint, not a fork — and
delete radial/RmharkCard.tsx. If the wheel genuinely cannot afford the full card’s
DOM (worth measuring: the rake is the antialiased-curve slow path from design.md
§4), then keep two renderers but make the split explicit — rename to
RMHarkCard.full.tsx / RMHarkCard.wheel.tsx, put them in the same directory, and
have the wheel one import its subcomponents from the full one rather than
reimplementing them.
Watch for. Do this one with a screenshot pass in three themes. It is the highest visual-risk item in Part I.
C5. Finish the PageLayout migration — [DONE]¶
The prediction in this section held: what
PageLayoutwas missing is the ability to supply the frame without a header, for pages whose column draws its ownColumnHeader.PageFrameis that, all 29 routes take one of the two, every dead import is gone, and a gate with an empty allowlist keeps it that way. Also retired two dozen calls passingContextRail’s no-opreserve/compactReserve.
Re-verified after
8cad6b95: still 65 of 127, still 29 hand-rolled, still 22 dead imports. The SEO sweep edited a dozen of the hand-rolled routes (achievements,c.$slug,explore,groups/index,personas/$id,ranked,roadmap,shop,study/*,tag.$tag,thread/$rootId,u/$userid/post/$postid) to add ahead()and changed nothing about their layout. A sweep that opened two thirds of the offending files and moved the number by zero is the argument for the CI gate at the end of this section rather than for another cleanup pass.
Evidence. components/CLAUDE.md and design.md §5 both name
feed/PageLayout.tsx the canonical page wrapper, and
lib/__tests__/design-consistency.test.ts line 480 asserts it is in the audited set.
65 of 127 _site routes use it. 29 hand-roll the layout instead — importing
AnimatedMain + ContextRail directly and composing the frame themselves:
c.$slug · share · progress · ranked · shop · drafts · profile/$id · moments.$id
thread/$rootId · help · achievements · personas/$id · music-trivia · tag.$tag
pricing · store/$userid · wrapped · bookmarks · messages/$conversationId
u/$userid/post/$postid · u/$userid/index · recap · spaces.$id · groups/$id
groups/index · study/browse · study/$deckId · study/index · explore
22 of them carry the dead WIDE_NO_RIGHT_SIDEBAR_WIDTH import from §0(b), which
dates them: they were written against an older AnimatedMain API that took an
explicit width, the API changed, and the call sites were updated without the imports
being cleaned up.
Consequence. Page-level chrome — the title treatment, the back link, breadcrumbs,
the sticky offset arithmetic, the pb-dock bottom inset, the right-rail portal — is
implemented 29 extra times. docs/page-consistency.md exists precisely because this
drifts, and the drift is not CI-detectable today.
Proposal. Migrate in three batches, cheapest first, and let each batch be its own commit so a visual regression is bisectable:
Batch |
Routes |
Why grouped |
|---|---|---|
1 — free wins |
The 6 that C1–C3 turn into redirect stubs anyway ( |
The layout question disappears with the page |
2 — plain columns |
|
Single column, no rail, no bespoke chrome — a mechanical swap |
3 — the real ones |
|
These have genuine layout needs; each may need a |
Batch 3 is where the value is: whatever PageLayout is missing that made 10 authors
route around it, that is the thing to add to PageLayout. Extend the system —
design.md §2.
Then gate it. Add a case to design-consistency.test.ts: a file under
app/routes/_site/** that imports AnimatedMain without importing PageLayout must
be on an explicit allowlist. That is the same shape as the existing tab-strip gate
and it stops the count climbing back.
C6. The token contract is enforced in .tsx and unenforced in 29,022 lines of .css — [DONE, step 1]¶
The gate ships as a ratchet: 13 stylesheets frozen at their current counts in
CSS_DEBT, zero tolerance for anything not on the list, and a fourth assertion that fails when a file improves without its entry being lowered, so the list drains instead of becoming permission. Verified in both directions. Step 2 — actually burning the 119 hex + 91 radius violations down, starting withlibrary.css— is not done and is the follow-up.
Evidence. design-consistency.test.ts collects sources via
entry.name.endsWith('.tsx') (line 98). It never opens a .css file. There are 41
of them under components/, totalling 29,022 lines, plus app/globals.css at 5,853.
Games are exempt from the palette rules by design (design.md §9), so
temple-of-joy.css (4,103), synapse-storm.css (2,107), kowloon-knockout.css
(1,266), isleworks.css (798) and friends are legitimately outside it. The
site-tier files are not:
File |
Lines |
|
raw hex |
hardcoded |
|---|---|---|---|---|
|
3,066 |
287 |
41 |
56 |
|
2,963 |
— |
— |
— |
|
1,600 |
30 |
0 |
16 |
|
910 |
82 |
0 |
13 |
|
817 |
197 |
0 |
5 |
|
634 |
— |
— |
— |
|
363 |
16 |
2 |
7 |
|
354 |
38 |
32 |
9 |
library.css alone carries 41 raw hex colours and 56 hardcoded radii. design.md
§2 law 1 is “nothing is hardcoded,” §9 says the palette and radius rules are
CI-enforced — and for these files neither is true. A purchased user theme
(§6: “must render every one of ~860 components correctly, because none of them knows
it exists”) does not reach any of them.
Note. rmhladder.css is a deliberate exemption — the test file’s comment at
line 60 names it as intentionally not liquid-glass. That exemption should be
recorded in the CSS gate rather than being an accident of the gate not existing.
Proposal. Two steps, and step 1 is worth doing even if step 2 never happens:
Extend the gate to
.css, allowlist the current state, and freeze it. Same collector,.cssextension, same site-tier/games-tier split the.tsxrules already use. Every existing violation goes on the allowlist with its count. New ones fail. This is XS and it stops the bleeding immediately.Burn the allowlist down,
library.cssfirst — it is the worst offender and the Library is a flagship consumer surface. 41 colours and 56 radii is one focused session, and every one of them is a theme that renders wrong today.
C7. The collection problem: one concept, twelve models, five destinations — [DONE]¶
RMHarkBookmarkfolded intoSavedItemby migration 20260803210000, verified against a real Postgres (history applies clean; a bookmarked-and- filed post keeps its folder; re-running is a no-op; no row unaccounted for).SavedItemgained the reverse index the old table had./savesis one destination with Saved · Lists · Wishlist tabs.SavedSearch/LadderSavedSearchwere left alone — still worth merging.
Evidence. Grepping the schema for save-shaped models returns twelve:
Model |
Scope |
Surface |
|---|---|---|
|
generic ( |
|
|
posts only, flat |
|
|
shop/market/creator intent, with |
|
|
lists of accounts (custom feeds) |
|
|
library documents |
|
|
music tracks |
|
|
RMHTube videos |
RMHTube |
|
property listings |
|
|
jobs |
|
|
addresses |
|
|
search queries |
|
|
job queries |
|
SavedItem already is the general solution — polymorphic entityType/entityId
with an optional folder, uniqued on (userId, entityType, entityId). It was clearly
built to be the one true saves table. RMHarkBookmark predates it and was never
folded in.
Consequence. The user-visible symptom: a post can be bookmarked and saved
independently, into two lists, on two pages, and neither knows the other exists.
Ask a user where the thing they saved went and there is no correct answer. Beyond
that: SavedSearch and LadderSavedSearch are the same model twice, and five
different “saved” pages each maintain their own empty state, sort, and pagination.
Proposal. Not one mega-refactor — a two-layer answer:
Data. Migrate
RMHarkBookmarkintoSavedItemwithentityType: 'rmhark'and keep a DB view (or a thin server helper) under the old name so the post bookmark toggle and its@@index([rmheetId])count query don’t have to change in the same commit. MergeLadderSavedSearchintoSavedSearchwith a scope column. Leave the domain collections alone — aPlaylistwith ordering and aLadderWatchlistEntrywith alerting are genuinely not the same object as a bookmark, and forcing them intoSavedItemwould be consolidation for its own sake.Frontend. One
/saveshub with a tab per source, built on the existingPageTabscontract — Saved · Bookmarks · Lists · Wishlist · Collections — where the domain collections appear as read-only cross-links into their own apps rather than being re-implemented./bookmarks,/lists,/wishlistbecome redirect stubs with?tab=. That is the C1 recipe again, and it takes the “where did my thing go” answer from five places to one.
Effort. M — the frontend hub is S; the RMHarkBookmark migration and its
backfill are the M.
C8. /create is a creator verb doing consumer work — [DONE]¶
/gamesand/appsexist, with descriptions, canonicals and anItemListof their members;GAMES_INDEX_PATH/APPS_INDEX_PATHpoint at them, so every game breadcrumb now ends somewhere a crawler will follow./createno longer renders the catalog — the Apps tab is gone and?tab=appsredirects. Not done: renaming the Create nav pin to “Arcade”. That is a product call.
What changed. The SEO sweep independently hit this wall and had to encode the workaround as a constant —
lib/seo-catalog.ts:/** Where the games/apps browser actually lives. There is no `/games` route. */ export const GAMES_INDEX_PATH = '/create?tab=games'; export const APPS_INDEX_PATH = '/create?tab=apps';Every game’s
head()now walks a JSON-LD breadcrumb back through that path. Butsitemap-coverage.test.tsline 200 asserts “keeps query strings out of the sitemap” — correctly, since a?tab=URL is a page state, not a page. So the browse surface for 21 games and 12 apps is now, by rule, the one thing on this site that cannot be submitted to a search engine, and the breadcrumb for every game page terminates at a URL no crawler will index.The sweep did everything it could without adding routes: 18 game roots that had no
head()at all now have titles, descriptions, canonicals and cards derived fromlib/games.ts. That work is done and good. It has no index page to point at, and that is the remaining half.
Evidence. SIDEBAR_NAV line 75 gives /create the label “Create” and the Wand2
icon. _site/create/index.tsx has six tabs: pages, games, apps, user-builds,
personas, earnings. Four of those are creator surfaces. Two of them —
games and apps — are the browse catalogue for 21 games and 12 apps, and after
the /arcade, /leaderboard and /builds folds they are the only place to browse
them. lib/games.ts is imported by exactly nine files, and other than the sitemap,
the OG-card renderer and two detail routes, its consumers are CommandPalette,
RecentsTracker and ArcadeHub.
Consequence. A first-time visitor who wants to play a game has no destination
called anything like “Games.” They must intuit that a wand icon labelled “Create”
contains the arcade. And the sitemap’s response to this was to advertise /games and
/apps — which is §0(a): the crawler was pointed at the pages a human would expect,
and those pages do not exist.
This is the one place where consolidation went one step too far. Every comparable catalogue platform — itch.io, Newgrounds, Poki, CrazyGames — treats its browse index as the primary public landing page and its highest-value SEO surface. This site consolidated its away.
Proposal. Split the consumer half back out, without giving up the tab count:
Add
_site/games/index.tsxand_site/apps/index.tsx— public, SSR’d, no auth gate, rendering the same catalogue components the Create tabs render today. They cost one route file each because the components already exist.Give them real
head()blocks withbuildMeta+breadcrumbSchema, and avideoGameSchemalist. The per-game pages (/games/$gameId) already do this — they just have no index above them.Fix the sitemap so its two highest-priority URLs resolve.
Nav: this does not need a new globe pin. Point the existing “Create” pin at
/createfor signed-in creators and let/gamesand/appsbe reached from the homepage’s projects section, the command palette and search — or, better, rename the pin “Arcade” and make/gamesits destination with Create as a tab, since playing is the higher-frequency intent by a wide margin.Keep
/create?tab=gamesworking as a deep link — it is where the Arcade Pass and Ranked summary live, and those are creator/progression surfaces that belong with Earnings.
C9. Settings has ten pages and an off-site eleventh — [DONE]¶
Destinations are a catalog (
lib/settings-nav.ts) the hub renders and filters, with a keywords field so “dark mode” finds Appearance. Theme authoring moved to/settings/themes;/studio/themesredirects.
Evidence. _site/settings/ holds index, profile, appearance, content,
privacy, security, notifications, circle, layout, account-status. Theme
authoring lives somewhere else entirely, at _site/studio/themes.tsx, and
settings/appearance.tsx is 27 lines — a PageLayout around a single panel.
settings/index.tsx links out to /shop (for cosmetics), /wallet and /progress
as well.
Consequence. Twelve destinations for “change something about my account,” scattered across three route prefixes.
Proposal. Settings is the one place a tab strip is worse than a list — deep settings pages want their own URL for support links and search. So the fix is not “one hub with tabs,” it is:
Fold
studio/themesin as/settings/appearance/themes(or a section ofappearance, which at 27 lines has room), leaving a redirect at/studio/themes.Make
settings/indexa real index — a grouped, searchable list of every setting with its destination, including the ones currently living at/shop,/walletand/progress. A settings search box is the cheapest usability win on this list and it scales past twelve pages, which the current card grid does not.
Consolidation scoreboard¶
# |
Item |
Effort |
Surfaces removed |
Risk |
Status |
|---|---|---|---|---|---|
C1 |
|
XS |
2 |
None — pure redirect |
shipped |
C2 |
|
XS |
1 |
None — fixes an SEO defect |
shipped |
C3 |
Point nav’s Explore at |
XS |
1 |
None |
shipped |
C4 |
One post-card renderer |
S |
1 component |
Visual — screenshot pass required |
shipped |
C5 |
Finish |
S |
29 bespoke frames |
Visual, per batch |
shipped |
C6 |
Extend the design gate to |
M |
— |
None for step 1 |
shipped (ratchet; burn-down pending) |
C7 |
One saves hub; |
M |
3 |
Data migration |
shipped |
C8 |
Re-expose |
S–M |
−2 (adds two, on purpose) |
None |
shipped |
C9 |
Settings index + fold |
S |
1 |
None |
shipped |
C1 + C2 + C3 together are perhaps two hours and remove three duplicate URLs plus one nav contradiction. Start there — and note that C1 and C2 are now actively degrading rather than merely untidy: one page-pair is emitting contradictory canonicals, and the other is a known-duplicate route that was classified rather than retired.
Part II — What the site is lacking¶
The prior four documents cover 58 features between them. Everything below was checked against the tree and is absent, and none of it appears in those documents. I checked and discarded roughly twenty more candidates that turned out to already exist — those are listed in Part IV so nobody re-derives them.
D. Account security — the clearest parity gap on the site¶
D1. TOTP + recovery codes — S¶
Gap evidence. settings/security.tsx renders exactly two things:
<PasskeyManager/> and <SessionManager/>. A grep for totp, authenticator and
twoFactorSecret across app/ components/ lib/ prisma/ server/ returns nothing;
so does recoveryCode/backupCode.
Why it matters. Passkeys are the better factor and shipping them first was the
right call — but they are device-bound, and a user whose only passkey lives on a
lost phone has no documented recovery path on a platform holding a coin balance,
a Stripe membership and a redemption history. Every comparable platform ships TOTP
as the portable second factor and recovery codes as the floor. Better Auth has a
twoFactor plugin, so this is configuration plus a settings panel plus a
one-time-download code sheet.
Also worth adding in the same pass: step-up re-authentication before the three
irreversible actions — RedemptionRequest, account deletion, and email change.
api/account/delete.ts and export.ts gate on emailVerified today, which is not
the same thing.
D2. Multi-account switching — M¶
Gap evidence. No switchAccount/accountSwitcher anywhere. Better Auth sessions
are single-active.
Why it matters. This platform actively encourages a second identity — Persona
is a shipped model with its own routes, creators have storefronts, and /store/$userid
is a per-user commerce surface. People running a personal account and a creator or
persona account currently sign out and back in. X, Reddit, Discord, Instagram,
Bluesky and GitHub all ship fast switching; it is table stakes for a platform with a
creator tier.
Shape. Multiple concurrent session cookies with an active-session pointer, an
avatar-stack switcher in the radial hub, and a hard rule that the economy surfaces
(wallet, store, redemption) always render the active account’s balance with the
account name visible — mixing those up is the failure mode.
D3. Signup/abuse defence: there is none — M¶
Gap evidence. No captcha, turnstile or hcaptcha anywhere.
lib/rate-limit.ts is in-memory and per-process (RATE_LIMIT_MULTIPLIER
defaults to 4) — so behind blue/green with two web processes, the effective limit is
whatever a single process sees, and it resets on every deploy.
Why it matters. This is a site where a fresh account can earn coins (quests,
streaks, the daily wheel, achievements), transfer them peer-to-peer (MarketListing,
tips, CoinStake), and route them toward RedemptionRequest. That is a complete
value cycle with no cost to account creation and no bot-detection layer. It composes
badly with the 07-31 doc’s #1 (age gating) and #8 (auctions) — both of those assume
accounts are scarce.
Shape. Turnstile on signup and on the first write of a new account; move the rate
limiter’s counters to the Redis backplane that lib/redis.server.ts already provides
(it no-ops without REDIS_URL, which is exactly why nobody noticed); add a velocity
rule on coin transfers between accounts younger than N days.
D4. Upload-time media classification — M¶
Gap evidence. lib/moderation/auto-moderate.server.ts classifies text.
lib/media/ handles upload, quota, sweep and policy. Nothing classifies image or
video content on the way in — no nsfwDetect, no safeSearch, no perceptual hash
for known-bad matching.
Why it matters. The site accepts user images on posts (imageUrls), albums, the
library, builds and profiles. Text-only auto-moderation on a platform accepting
arbitrary images is the gap that turns into an incident rather than a backlog item.
Pair it with the 07-31 doc’s #3 (visibility tiers) so a medium-confidence image hit
has somewhere proportionate to go.
E. Communities are a shell¶
E1. Community rules, roles that do something, and a per-community mod queue — M¶
Gap evidence. Community has slug, name, description, colour, icon, isPrivate,
counts. Its relations are members, posts, announcements, spaces, events.
CommunityMember.role is a CommunityRole enum — but there is no CommunityRule,
no CommunityBan, no community-scoped report queue, no post approval, and no flair.
A grep for CommunityRule, CommunityBan and CommunityReport returns nothing. So
the role column exists and there is essentially nothing a moderator can do with it
that a site admin can’t.
Why it matters. Compared to Reddit, Discord or even a Discourse forum, a community
here is a hashtag with a member list. The reason communities scale as a moderation
model is that they delegate — the platform team cannot review every post in every
community, and today it must. This is also the cheapest way to make the 07-31 doc’s
safety block tractable: ContentReport already exists; scoping a queue to a community
and letting its moderators work it is mostly routing.
Shape. CommunityRule { communityId, ordinal, title, body } rendered in the
sidebar and offered as report reasons; CommunityBan { communityId, userId, expiresAt, reason }; a communityId filter on the existing ContentReport queue plus a
community-scoped view of it; optional post approval for isPrivate communities.
Flair is a separate, smaller item and can wait.
F. Discovery & SEO¶
F1. ~~A real sitemap: index-sharded, profile-inclusive, canonical-clean~~ — [SHIPPED in 8cad6b95]¶
Proposed in the first pass of this document; delivered by the SEO sweep the same day,
in a stronger form than proposed. /sitemap.xml is now an index over
/sitemaps/{section}[-{n}].xml with per-section chunking and a documented cap
warning; profiles, posts, vibe pages and communities are sections; SITE_URL comes
from @/lib/seo; and the whole thing is gated by two new suites that also enforce
per-page canonicals and descriptions. Recorded here rather than deleted so the
sequencing below still reads.
One follow-up remains, and it is C1’s. sitemap-coverage.test.ts requires a
canonical on every listed page but cannot know that two listed pages are the same
page. /shop and /store are both listed, both self-canonical. Fixing C1 removes
one of them from the sitemap automatically via the suite’s own
“lists no route that only redirects” rule.
F2. Automated accessibility checks in CI — S¶
Gap evidence. design.md §7 makes accessibility part of the material and §9
lists what CI enforces — accent contrast, colour-vision-mode integrity, the tab-strip
grammar, no new lint warnings. None of those is an actual a11y audit. No axe-core
in package.json. jsx-a11y runs at warn.
Why it matters. This is a site that has invested unusually heavily in accessibility — sixteen locales, RTL, colour-vision modes, reduced motion, reduced transparency, high contrast, dyslexia-friendly type, a density scale — and verifies none of it automatically. §9’s own conclusion (“a green suite means you did not regress the enforced rules; it does not mean the change looks right”) is the argument for closing this: the enforced set should be as large as it cheaply can be.
Shape. playwright is already a dependency (^1.62.0, for vitest.epic.config.ts).
Add @axe-core/playwright, walk a fixed list of ~15 representative routes in the
default and high-contrast themes, fail on serious/critical violations, allowlist
the current state exactly as C6 proposes for CSS. Same pattern, second axis.
F3. A visual-regression baseline for the glass tiers — M¶
Gap evidence. Same Playwright dependency; no screenshot baseline anywhere.
Why it matters. C4 and C5 are both blocked on “screenshot pass in three themes”
being a manual step. design.md §10.6 ends with “look at it — three themes, two
widths, reduced motion once,” which is the correct instruction and is exactly the
kind of instruction that decays. A baseline of ~20 routes × 3 themes × 2 widths turns
the consolidation work in Part I from risky into routine, and it pays for itself on
C5 alone.
Sequencing note. This is the one Part II item worth doing before Part I rather than after.
G. Content lifecycle¶
G1. Scheduled and delayed messaging — S¶
Gap evidence. lib/scheduled/publish.server.ts schedules posts (via the lazy
publishDueForUser materialization). messages.server.ts and group-chat/ have no
scheduling. No undoSend anywhere either.
Why it matters. Undo-send in particular is a ten-second client-side hold before the write commits, it is the single most-appreciated small feature on every messaging product that ships it, and the scheduling infrastructure to do the durable version already exists one directory over.
G2. Deliberate offline content — S¶
Gap evidence. The PWA shell is complete: public/sw.js, lib/sw-register.ts,
manifest.webmanifest, VAPID push, app/routes/offline.tsx. No backgroundSync, no
SyncManager, and nothing pins content to the cache on purpose.
Note. The 07-31 doc’s #24 proposes offline reading bundled with home-screen
widgets and rates the pair M. Splitting them is worth it — the offline half is S on
its own, it depends only on the service worker that already ships, and it is the half
that makes DailyStreak survivable on a commute. Take the S half now.
Part III — Sequencing¶
Week 1 — the free consolidations. C1, C2, C3 then §0(b). Roughly a day, no user-visible risk, and the three of them retire two contradictory canonicals, one classified-but-live duplicate, and the nav/sitemap disagreement about what “Explore” means. §0(a) and F1 are already done.
A note on ordering that the SEO sweep makes concrete: do the routing decision
before the metadata pass, not after. 8cad6b95 gave /shop a description, a
canonical and a sitemap entry — all of which have to be undone the moment C1 lands,
and none of which would have been written if C1 had landed first. The same is true
of every future sweep over /profile/$id. Consolidation is upstream of SEO.
Week 2 — the gates, before the risky work. C6 step 1 (.css allowlist freeze),
F2 (axe in CI), F3 (visual baseline). Each is independently useful and together they
are the safety net for everything after.
Weeks 3–4 — the structural consolidations. C5 batches 1–3, then C4 with the
baseline in place, then C9. This is where PageLayout learns what it was missing.
Then, in parallel lanes:
Safety — D3 (abuse defence) and D4 (media classification) first; both are preconditions the 07-31 doc’s economy items quietly assume. D1 (TOTP) is S and can go any time.
Growth — C8 (
/games+/appsindexes) then F1 (sitemap index). These two are one project: the pages and the thing that submits them.Structure — C7 (saves), then E1 (communities). Both are M and both are the kind of work that gets harder every month it waits.
The one thing to do out of order: F3, the visual baseline. Every consolidation in Part I is a visual change to a page someone reads daily, and the difference between “we consolidated the frontend” and “we broke the frontend” is whether a screenshot diff existed at the time.
Part IV — Checked and found present¶
Listed so the next reader doesn’t re-derive them. All of these were candidate gaps and all of them already ship:
Social — polls on posts (RMHarkPoll), quote-reposts (RMHark.originalId
self-relation), edit history (RMHarkEdit), reply controls (RMHarkReplyControl),
audience scoping incl. close friends (RMHarkAudience + CloseFriend), content
warnings (isSensitive), per-image alt text (imageAlts), paid unlocks
(PostUnlock), pinned posts, scheduled posts, drafts.
Games — reviews and ratings (GameReview, GameReviewVote), player guides with
revisions (GameGuide, GameGuideRevision), replays (GameReplay + /replays/$id
embed.replay.$id), Elo (EloRating), per-game OG cards.
Account — passkeys (PasskeyManager), active-session management
(SessionManager), email verification, data export (api/account/delete.ts,
export.ts), cookie consent, referrals.
Platform — command palette (components/site/CommandPalette, mounted globally),
keyboard shortcuts (components/site/KeyboardShortcuts), digest email
(lib/digest/pipeline.server.ts), web push (VAPID), onboarding first-week arc,
admin audit log, moderation appeals (07-31 doc’s #2 — this one landed since that
document was written), bundle budgets
(lib/__tests__/performance-guardrails.test.ts), client error beacons (lib/rum.ts,
lib/client-errors.ts).
SEO/discoverability — shipped by 8cad6b95 after the first pass of this document
was written: the sitemap index with per-section chunking (lib/sitemap.ts,
sitemap.server.ts, app/routes/sitemaps.$name.ts), a head() with title,
description, canonical and OG card on all 18 previously-bare game roots and the app
roots — derived from lib/games.ts/lib/apps.ts via lib/seo-catalog.ts so the
catalogue and the pages cannot drift — an explicit EXCLUDED_ROUTES classification
for every route in the tree with a typed ExclusionReason, a rewritten
public/robots.txt, and two CI suites asserting that the sitemap never lists a
missing route, a redirect-only route, a duplicate loc, an absolute URL, a query
string, or a page lacking a description/canonical.
That commit also removes three Prisma models with the Alex bot retirement
(7cc2a801), so the model count in this document is 249, not the 252 quoted in
/CLAUDE.md and the earlier plan docs.
Still absent from the 07-31 list (re-checked against 0bd0236a, and #4 remains
the highest-leverage item across both documents): feature flags, public status page,
changelog, keyword mutes, visibility tiers, age assurance, semantic search /
pgvector, OAuth apps.
Part V — Explicitly not proposed¶
A design-system package or a component-library extraction.
components/ui/is already the primitive layer and it works; the problem is adoption (C5, C6), not architecture. Extracting a package would add a build step and change nothing about the 29 routes that hand-roll their frame.Merging the
--site-*and--app-*contracts.design.md§8 explains why they are separate and the reasoning holds — an app owns the viewport and has no shared aurora to sample. Consolidating them would be consolidation for its own sake.Collapsing the per-game component directories. 21 games with their own directories and their own CSS is correct; games are explicitly exempt from the site palette rules and their CSS should stay theirs. Only the site-tier CSS in C6 is in scope.
Route-tree flattening / a different router.
routeTree.gen.tsis generated and the file-based layout is working. 733 route files is a lot of product, not a lot of structure.Full inbound ActivityPub, ads, and the rest of the 07-31 doc’s exclusions — those reasons still stand.