Skip to main content

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

  1. The mental model the user carries
  2. Navigation hierarchy
  3. Server-driven navigation catalog
  4. URL / state as the single source of truth
  5. Responsive navigation
  6. View-density modes
  7. Search composes with filters
  8. Faceted taxonomy
  9. Content cards
  10. Color semantics
  11. Approval gating
  12. Pagination
  13. Shared tag vocabulary
  14. Related content
  15. Admin surfaces
  16. Loading, empty, error states
  17. Accessibility
  18. Performance
  19. Anti-patterns
  20. Chrome-extension adaptations

1. The mental model

Users hold two questions in mind:

  1. "What topic is this about?" — the group axis (e.g. AI, robotics, 3D).
  2. "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.

ResponsibilityOwner
What surfaces, groups, channels, facets, and platforms exist; labels; order; colors; visibilityServer (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.

ParamMeaning
?q=Free-text search
?tags=a,b,cTag filter (comma-separated, OR within; AND against other filters)
?sort=newest|oldest|alphaSort mode (trending/usage deferred — see API.md)
?platform=Platform filter
?period=24h|7d|30d|90d|allTime 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:

ModeUse case
CompactScanning many items
StandardDefault reading
MagazineFirst 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:

FacetAnswers
topicWhat is this about? (ai, robotics, 3d, crypto)
formatHow is it delivered? (video, article, thread, paper, podcast)
levelWho is it for? (beginner, intermediate, advanced)
source-typeOrigin flavor (official, community, personal)
unclassifiedNot yet sorted

Facets are a column on the Tag entity, not separate tables. See TAXONOMY.md.

Why facets:

  1. Discovery — "beginner + video + 3d" is compositional; flat tags conflate it.
  2. Display/tags explorer 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:

  1. Platform-native embed — native renderer if the platform supports it.
  2. Rich thumbnail + metadata — image, title, author, description.
  3. Thumbnail only — title overlay.
  4. 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:

  1. Own CDN pre-sized mip (when we have one) — fastest.
  2. Original platform URL — full quality, can be slow/hot-linked.
  3. Open Graph / Twitter Card / Bluesky embed metadata.
  4. 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.

  1. Every surface has one accent — from the catalog, not a hardcoded constant.
  2. Every group has one color (from the catalog) — applies to its pill and to content cards within it.
  3. Every facet has one color in the tag explorer.
  4. Platforms use their brand colors only on the platform badge.
  5. 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=pending on 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:

  1. Deep-linkable — ?offset=500&limit=30 is bookmarkable.
  2. Predictable — users can jump to an arbitrary page.
  3. 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.


A detail page shows a "see also" rail. Multiple mechanisms feed one unified list:

MechanismV1?
Shared tag overlapYes — cheap, always available
Shared channelsYes — O(1) lookup
Explicit moderator pinsLater
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 /admin route 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:

  1. Initial loading — skeleton cards matching the final layout (not a spinner).
  2. Loaded with data — the real grid.
  3. Loaded but empty — explain why, suggest a CTA ("Clear filters?" / "Be the first to submit").
  4. 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:

  1. Keyboard navigation end-to-end (Tab through filter bar, arrow keys in grid, Enter to open detail).
  2. Visible focus indicators.
  3. Icon-only controls have accessible names.
  4. Live regions announce filter changes ("Now showing 42 results").
  5. Color is never the only signal — status pairs color with a labeled badge.
  6. prefers-reduced-motion respected.
  7. Contrast: 4.5:1 body, 3:1 large text.
  8. "Skip to content" link bypasses nav chrome.
  9. 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 /meta loaded 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:

ConceptWeb browserChrome extension
URL as state (§4)Shareable across machinesShareable inside the extension only
Catalog caching (§3)HTTP cache + ETagchrome.storage.local + ETag
Auth tokenslocalStorage or cookieschrome.storage.local, accessed only by the service worker
Embeds (§9)<script> and iframesIframes only; no remote <script>; sanitize any returned HTML
Background workPersistent tab / SSEMV3 service worker — suspends after ~30s idle; treat as stateless
Surfaces (UI context)Pages and routesPopup, side panel, new tab page, options page — each is its own container
Submit flowDedicated form pageToolbar 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. trending sort and signal-weighted related content are v2 — v1 uses tag/channel overlap and recency.