AgnosticUI — Browse, Search & Navigation Philosophy
Scope for this project. This is the trimmed-to-our-needs version of the original AgnosticUI philosophy. We have one content type (Content) and its supporting taxonomy (tags, surfaces, groups, channels). We do not have tools, a glossary, or tips. Multi-kind patterns from the source document are preserved architecturally (so we can add kinds later without a rewrite) but are not described here as near-term obligations.
Client context. The primary client is a Chrome extension (MV3). There is no separate web frontend today. Most interaction patterns below apply, but with the extension-specific adaptations called out in each section.
What this is: a stack-agnostic description of how a user browses, searches, and navigates content.
What this is not: a reference of current file paths, collection names, or component APIs. See TAXONOMY.md, API.md, and AUTH.md for those.
Table of Contents
- The mental model the user carries
- Navigation hierarchy
- Server-driven navigation catalog
- URL / state as the single source of truth
- Responsive navigation
- View-density modes
- Search composes with filters
- Faceted taxonomy
- Content cards
- Color semantics
- Approval gating
- Pagination
- Shared tag vocabulary
- Related content
- Admin surfaces
- Loading, empty, error states
- Accessibility
- Performance
- Anti-patterns
- Chrome-extension adaptations
1. The mental model
Users hold two questions in mind:
- "What topic is this about?" — the group axis (e.g. AI, robotics, 3D).
- "How specific do I want to be?" — the whole topic vs a narrow channel.
A good browse UI answers both without the user having to think about them consciously. Two visible layers of navigation, color-coded per group, with the current location reflected in the route and visual chrome, is how we do that.
If the user ever has to ask "how do I get back to where I was?", the UI has failed. Navigation is bookmarkable (within the extension's own URL scope), and back does what the user expects.
Because this project is single-kind, the Layer 1 "surface" concept from the source document collapses to one surface by default. The catalog still supports multiple surfaces architecturally so that adding a second kind later (e.g. "Updates" vs "Articles") is a data change, not a code change.
2. Navigation hierarchy
LAYER 1: Surface (usually one) e.g. "Content"
↓
LAYER 2: Groups (topic axis) server-defined groups
↓ zero or one of
LAYER 3: Channels (sub-groups) optional nested labels under a group
- Layer 1 is usually a single surface; it is still catalog-driven so operators can add surfaces without a client release.
- Layer 2 is the primary browse axis — groups come from the catalog, not frontend enums.
- Layer 3 is optional sub-topics revealed after the user picks a Layer 2 group.
Detail routes (/browse/<content-slug> in the extension's URL space) are the fourth layer. Preserve enough context in the detail route so back restores scroll and filter state.
Path structure (inside the extension's own pages):
/browse → default feed (newest approved)
/browse/<group-slug> → topic view
/browse/<group-slug>/<channel> → sub-topic view
/browse/item/<content-slug> → detail view
/tags → faceted tag explorer
Slugs are lowercase-hyphenated. They must match slugs the catalog/API issues. See API.md and TAXONOMY.md for slug rules.
3. Server-driven navigation catalog
The UI adapts when operators add groups, rename a channel, or retire one — without shipping a new extension build. That is the single most important reason the catalog exists for a Chrome-extension client: the Chrome Web Store review cycle can be days, and hardcoded taxonomy would make every edit a release.
| Responsibility | Owner |
|---|---|
| What surfaces, groups, channels, facets, and platforms exist; labels; order; colors; visibility | Server (database + config) |
| How they render (pill collapse, active state, link construction) | Client |
Contract. GET /catalog returns the full tree the extension needs to render nav. Cacheable with ETag / Last-Modified. See API.md and TAXONOMY.md.
Client rules:
- Do not ship canonical lists of groups or channels as TypeScript enums — they will drift from the database.
- Do derive filter state from slugs the catalog exposes.
- Do cache the catalog in
chrome.storage.local; revalidate on each cold start and every N minutes thereafter.
4. URL as state
Every meaningful view state lives in the route — path segments for navigation, query params for filters.
| Param | Meaning |
|---|---|
?q= | Free-text search |
?tags=a,b,c | Tag filter (comma-separated, OR within; AND against other filters) |
?sort=newest|oldest|alpha | Sort mode (trending/usage deferred — see API.md) |
?platform= | Platform filter |
?period=24h|7d|30d|90d|all | Time window |
?offset= / ?limit= | Pagination |
Does not go in query params:
- User preferences (view density, theme). Those live in
chrome.storage.local. - Ephemeral UI state (open menus, modals). Component state.
Chrome-extension caveat: the extension's own pages (chrome-extension://<id>/panel.html?...) bookmark inside the extension but are not shareable with non-installed users. For v1 we accept this. If cross-device share becomes a requirement, expose a public web reader later.
5. Responsive navigation
Navigation pills adapt to the container, not the viewport — the extension's popup width differs from the side-panel width, and both differ from the options page.
Three-tier collapse:
wide: [ icon ] [ label ] [ icon ] [ label ]
narrow: [ icon-only ] [ icon-only ] (label in aria-label/title)
very narrow: stacked vertically or horizontal scroll
Icon-only mode MUST expose the label (aria-label + title). Never an icon without an accessible name.
6. View-density modes
Support three densities; user picks per context:
| Mode | Use case |
|---|---|
| Compact | Scanning many items |
| Standard | Default reading |
| Magazine | First item is hero, rest standard |
- Toggle is per-context (side panel vs new-tab page can differ); persist in
chrome.storage.local. - Toggle is one-click, three icons visible at once.
- Data doesn't change — only the card component. No refetch.
- Detail pages, tag explorer, and alphabetical lists don't offer density modes.
7. Search composes with filters
Search is one axis, not the axis. A query like "YouTube videos about gaussian splats from the last week" decomposes to text + platform + tag + period.
- Text is prominent, keyboard-focusable.
- Platform, tag, period, sort appear as pills/dropdowns.
- Filters AND across dimensions. Within a dimension, OR.
- Applied filters show as dismissible chips. "Clear all" is always one click.
- Text debounces at 300ms; pill toggles are immediate.
One endpoint (GET /content) accepts all compositional params and returns the standard envelope. See API.md.
When no filters and no query, /browse shows the default feed (newest approved). Don't gate content behind a forced selection.
8. Faceted taxonomy
Tags are not flat. Each tag belongs to a facet — a kind of classification. For a content-only product, the starting facet set is:
| Facet | Answers |
|---|---|
topic | What is this about? (ai, robotics, 3d, crypto) |
format | How is it delivered? (video, article, thread, paper, podcast) |
level | Who is it for? (beginner, intermediate, advanced) |
source-type | Origin flavor (official, community, personal) |
unclassified | Not yet sorted |
Facets are a column on the Tag entity, not separate tables. See TAXONOMY.md.
Why facets:
- Discovery — "beginner + video + 3d" is compositional; flat tags conflate it.
- Display —
/tagsexplorer groups by facet, making the vocabulary learnable.
Tag explorer (/tags) offers three sort modes over the same data: alphabetical (jump rail), by facet (color-grouped), by usage (most-used first). Every tag row shows its usage count. Clicking a tag routes to /browse?tags=<slug>.
Slug rules, rename, never-delete: see TAXONOMY.md and API.md.
9. Content cards
Content comes from many platforms (YouTube, Twitter/X, Bluesky, Reddit, generic web). The card component renders each natively where possible and falls back gracefully.
Render ladder:
- Platform-native embed — native renderer if the platform supports it.
- Rich thumbnail + metadata — image, title, author, description.
- Thumbnail only — title overlay.
- Text card — title, description, platform-colored background.
Each level degrades to the next. Don't break the card if an embed script fails; fall to level 2.
Thumbnail resolution ladder:
- Own CDN pre-sized mip (when we have one) — fastest.
- Original platform URL — full quality, can be slow/hot-linked.
- Open Graph / Twitter Card / Bluesky embed metadata.
- Platform-colored placeholder.
Thumbnail mip generation happens asynchronously on approval; never block approval on it.
Chrome-extension caveat: MV3 has strict CSP. Any embed that requires remote scripts must be iframed; direct <script> injection is forbidden. Prefer thumbnail-first rendering; native embeds are level-1 only for iframe-safe platforms.
Platform badge, accent bar, browse-title override: same as the source document.
10. Color semantics
Color encodes identity.
- Every surface has one accent — from the catalog, not a hardcoded constant.
- Every group has one color (from the catalog) — applies to its pill and to content cards within it.
- Every facet has one color in the tag explorer.
- Platforms use their brand colors only on the platform badge.
- Status colors are reserved: red = rejected/destructive, amber = pending/warning, green = approved/success. Never a status color as a brand accent.
Color is sparingly applied — one accent bar, one platform badge, maybe one tag chip per card. Neutral background everywhere else.
Default theme accents come from the catalog; the client never ships a constant map of group→color.
11. Approval gating
user submits → [pending] → moderator reviews → [approved] → visible
\→ [rejected] → hidden
- Default list endpoints return approved only. Unapproved never leaks to anonymous clients.
- Moderators reach the pending queue with
?status=pendingon an authenticated request. - Approval is one action, with optional rejection reason. No multi-step workflow.
- Rejected content is retained (soft-hidden), never deleted.
- Approval is atomic; downstream work (thumbnail mips, notifications) is fire-and-forget.
Chrome-extension submitter flow: the extension icon in the toolbar captures the current tab's URL + title and POSTs to /content/submit. The user sees their own pending items marked with a visible pending badge; they never think content is published before approval.
12. Pagination
Offset/limit, not cursor:
- Deep-linkable —
?offset=500&limit=30is bookmarkable. - Predictable — users can jump to an arbitrary page.
- Simple across dynamic filter sets.
Tradeoff: deep offsets get slow. Mitigate via an offset cap (10 000), indexes on the default sort column (approvedAt), and _id as a stable tiebreaker.
Feed vs reference:
- Feeds (content lists) — infinite scroll with a fallback "Load more" button (keyboard users, slow networks, unreliable intersection observers).
- Reference lists (tags, users, admin tables) — discrete pages.
Full pagination spec: API.md.
13. Shared tag vocabulary
Because we currently have one content kind, "shared vocabulary" means one tags collection serves as the sole source of truth. If we add a second content kind later, it draws from the same pool — no parallel taxonomy.
Implementation is a single tag collection with slug as the primary key and a multikey index on Content.tagSlugs — see TAXONOMY.md.
Why arrays of slugs, not a junction collection:
- Tag cardinality per content is small (tens); vocabulary is thousands globally.
- Membership queries (
tags contains X) are the hot path. - MongoDB's multikey index gives O(log n) membership. A junction collection adds a lookup without benefit at this scale.
A junction appears only when the relationship carries attributes (e.g. "this piece was submitted by that user with role X"). Attributeless links stay as arrays.
14. Related content
A detail page shows a "see also" rail. Multiple mechanisms feed one unified list:
| Mechanism | V1? |
|---|---|
| Shared tag overlap | Yes — cheap, always available |
| Shared channels | Yes — O(1) lookup |
| Explicit moderator pins | Later |
| Signal aggregation (views, clicks) | Later |
V1 scoring: 2 × shared_channels + shared_tag_count. Cap at N items, exclude the current content. Moderator pins override the default order when we add them.
15. Admin surfaces
Admin capabilities appear in-place, not in a separate admin app.
- Non-admin: plain browse UI.
- Admin: same UI plus pencil icons next to editable fields, a pending-count indicator, "add group/tag" buttons where appropriate.
- A dedicated
/adminroute in the extension exists for batch operations (pending queue, user/role management, taxonomy edits).
Destructive actions have friction. Deleting, rejecting, or renaming requires a confirmation with an impact preview ("this will remove the tag from 42 content items"). One unconfirmed click is not enough.
Soft delete always. isActive = false; list endpoints filter by isActive. Hard delete is an audit-trail concern, not a data concern.
Role/permission model: see AUTH.md.
16. States
Every list view has four states and all four must be designed:
- Initial loading — skeleton cards matching the final layout (not a spinner).
- Loaded with data — the real grid.
- Loaded but empty — explain why, suggest a CTA ("Clear filters?" / "Be the first to submit").
- Error — retryable, never a blank page.
Skeletons prevent layout shift and tell the user what's about to appear. Spinners tell them nothing.
17. Accessibility
Non-negotiable:
- Keyboard navigation end-to-end (Tab through filter bar, arrow keys in grid, Enter to open detail).
- Visible focus indicators.
- Icon-only controls have accessible names.
- Live regions announce filter changes ("Now showing 42 results").
- Color is never the only signal — status pairs color with a labeled badge.
prefers-reduced-motionrespected.- Contrast: 4.5:1 body, 3:1 large text.
- "Skip to content" link bypasses nav chrome.
- Search has a visible label.
18. Performance
Server:
- Paginate everything. Default 30, max 200.
- Index every filter column; multikey on
tagSlugs. - Cache catalog with ETag; revalidate on taxonomy writes.
Chrome extension:
- Cache catalog in
chrome.storage.local; revalidate on cold start of the service worker. - Skeleton on first fetch; never a white panel.
- Debounce text input 300ms.
- Prefetch detail on hover (200ms delay to avoid accidents).
- Image lazy-loading via native
loading="lazy"; aspect ratios declared to prevent CLS. - Thumbnails at card resolution, not full.
Networking:
- One list/search endpoint per surface.
- Consistent
{ success, data, pagination }envelope on every list response. GET /metaloaded once per cold start; cached in extension storage.
19. Anti-patterns
- No hamburger-only nav.
- No infinite filter chips — if a filter has 50+ values, it's a search box, not pills.
- No confirmations on non-destructive actions.
- No two sources of truth for state — URL and UI must agree or fail loudly.
- No shadow copy of taxonomy in the client — catalog is the source.
- No client-side filtering of server-paginated data.
- No custom scroll containers in main feeds.
- No auto-refreshing lists — show "N new items, refresh?" banner, never replace silently.
- No feed items that change shape mid-scroll.
- No routes whose meaning changes across clients.
- No loading a full list to render a count — count comes from a count query.
20. Chrome-extension adaptations
The source document was written for a full web client. Adaptations for MV3:
| Concept | Web browser | Chrome extension |
|---|---|---|
| URL as state (§4) | Shareable across machines | Shareable inside the extension only |
| Catalog caching (§3) | HTTP cache + ETag | chrome.storage.local + ETag |
| Auth tokens | localStorage or cookies | chrome.storage.local, accessed only by the service worker |
| Embeds (§9) | <script> and iframes | Iframes only; no remote <script>; sanitize any returned HTML |
| Background work | Persistent tab / SSE | MV3 service worker — suspends after ~30s idle; treat as stateless |
| Surfaces (UI context) | Pages and routes | Popup, side panel, new tab page, options page — each is its own container |
| Submit flow | Dedicated form page | Toolbar icon captures current tab → POSTs to /content/submit |
Every cold start of the service worker re-reads tokens from chrome.storage.local, revalidates the cached catalog against the server's ETag, and proceeds. Don't rely on in-memory caches.
A word on taste
Every convention here exists because a looser approach failed somewhere. When tempted to deviate, identify which problem the convention solves; if this project doesn't have that problem, deviate deliberately. Otherwise, keep the convention.
Deviations from the source that are intentional here:
- Single-surface simplification. We have one content kind; multi-axis routing is deferred, not rejected.
- Chrome-extension as the client. A few patterns (URL sharing, script-embed, always-on workers) are weakened or replaced.
- Deferred scoring.
trendingsort and signal-weighted related content are v2 — v1 uses tag/channel overlap and recency.