{"slug":"progressive-disclosure-detail-page","meta":{"title":"Progressive Disclosure for Dense Detail Pages","slug":"progressive-disclosure-detail-page","category":"Navigation","summary":"A layout method for record pages that have too much to show — name the one job, cap the glance layer at five elements, put everything else behind tabs and collapsed rows, and spend colour only on \"a human must act\".","tags":["ux-default","layout","progressive-disclosure","cognitive-load","tabs","details","admin","design"],"status":"stable","layers":["view","controller"],"related":[{"title":"Filterable Index with Slide-Out Detail Drawer","url":"/cookbook/filterable-index-with-detail-drawer","summary":"The index-side sibling. That guide keeps a TABLE readable by hiding columns; this one keeps a DETAIL PAGE readable by layering it."},{"title":"Cmd+K Global Search","url":"/cookbook/global-search-command-palette","summary":"When a page has grown past layering, search is the next escape hatch."}]},"body":"# Progressive Disclosure for Dense Detail Pages\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nA record page starts clean and rots by accretion. The model has twenty fields, so twenty\nfields go on the page. Each one felt important while it was being built, so each got a\ncoloured badge. Nothing on the page is *wrong*, and the page is still unusable: with\neverything shouting, the eye has no entry point, so a person has to read **everything**\nto find **anything**.\n\nThis guide is the fix. It is a layout method plus the Rails/Tailwind code to implement\nit: **name the one job the page serves, cap what's visible at a glance, and push the\nrest behind one click.** The result reads in three seconds instead of thirty, and it\nstops re-rotting, because the layer budget makes every future addition pay for itself.\n\n\u003e **When to use:** any show/detail page with more than ~8 facts — a record plus its\n\u003e stream of related items (messages, comments, events, line items, activity).\n\u003e **When not to:** a page with one job and five fields. Don't add tabs to a login form.\n\n---\n\n## The 80/20 in one breath\n\n1. **Write the Job Sentence.** One sentence: *\"A `\u003cwho\u003e` doing `\u003cwhat\u003e` needs to decide\n   `\u003cone thing\u003e`.\"* If it needs \"and also\", that's a second tab.\n2. **Name the 3-Second Question** — what must be answerable with no scrolling and no\n   clicking. Exactly one per page.\n3. **Cap layer 1 at five elements.** Everything that doesn't answer the 3-Second\n   Question drops to layer 2 or lower.\n4. **Split the second job into a tab** (`?tab=`, server-rendered — no JS).\n5. **Collapse the stream** to one line per item; expand only the newest.\n6. **Spend colour on one meaning only:** amber = a human must act. Everything else grey.\n\nThe rule that makes it stick: **to promote a sixth thing to layer 1, you must demote\nsomething.** A fixed budget is what stops the page from re-accumulating.\n\n---\n\n## Layer 1 — Write the layer map into the view\n\nPut the map in the template as a comment. It is what a reviewer checks the design\nagainst, and it's the first thing the next agent reads before adding a field.\n\n```erb\n\u003c%# app/views/tickets/show.html.erb %\u003e\n\u003c%# JOB:     an operator triaging a queue decides \"does this need me, and what do I say?\"\n    3-SEC Q: does this need me right now, and what did they say?\n    LAYERS:  1 = who · ONE state badge · newest message · the primary action\n             2 = older messages, one line each\n             3 = full bodies (click)\n             4 = internal notes (separate TAB), related records (links out)\n\n    COLOUR BUDGET: amber = a human must act. blue = interactive. slate = everything\n    else. A hue never means two things. Don't add a fifth. %\u003e\n```\n\n---\n\n## Layer 2 — Tabs without JavaScript\n\nTwo jobs on one record = two tabs, not two stacked sections. Server-rendered via a query\nparam, so every tab is a linkable URL and there is no JS to load, break, or test.\n\n```ruby\n# app/controllers/tickets_controller.rb\ndef show\n  @ticket  = Ticket.find(params[:id])\n  @messages = @ticket.messages.chronological\n\n  # Default to the tab that answers the 3-Second Question, not the one you built first.\n  @tab = params[:tab].presence_in(%w[activity notes]) || \"activity\"\nend\n```\n\n```erb\n\u003c%# app/views/tickets/show.html.erb %\u003e\n\u003cdiv class=\"flex items-center gap-1 border-b border-slate-200 mb-6\"\u003e\n  \u003c% [[\"activity\", \"Activity\", @messages.size], [\"notes\", \"Notes\", nil]].each do |key, label, count| %\u003e\n    \u003c%= link_to ticket_path(@ticket, tab: key),\n          class: \"px-4 py-2 text-sm font-medium border-b-2 -mb-px #{@tab == key ?\n                  'border-slate-900 text-slate-900' :\n                  'border-transparent text-slate-500 hover:text-slate-800'}\" do %\u003e\n      \u003c%= label %\u003e\u003c% if count %\u003e \u003cspan class=\"text-slate-400 font-normal\"\u003e\u003c%= count %\u003e\u003c/span\u003e\u003c% end %\u003e\n    \u003c% end %\u003e\n  \u003c% end %\u003e\n\u003c/div\u003e\n```\n\nThe active tab is marked with **weight and a dark underline**, not a colour — colour is\nreserved (see below).\n\n---\n\n## Layer 3 — One state badge, resolved in the model\n\nThe most common way a page turns to soup is a badge per fact. Six badges is the same as\nnone. Give the record **one** headline state, resolved in priority order, and put the\nrest on the secondary tab.\n\n```ruby\n# app/models/ticket.rb\n# THE state of this record, as one label — deliberately singular.\n# `tone` is :attention (amber — a human must act) or :calm (grey). Nothing else.\ndef state_summary\n  if closed?\n    { label: \"Closed\", tone: :calm }\n  elsif draft_reply?\n    { label: \"Draft not sent\", tone: :attention }\n  elsif awaiting_us?\n    { label: \"Waiting #{ActionController::Base.helpers.time_ago_in_words(last_inbound_at)}\",\n      tone: :attention }\n  else\n    { label: \"Answered\", tone: :calm }\n  end\nend\n```\n\n```erb\n\u003c%# app/views/tickets/show.html.erb — sticky context bar, the ONLY place state appears %\u003e\n\u003c% state = @ticket.state_summary %\u003e\n\u003cdiv class=\"sticky top-0 z-30 bg-white/95 backdrop-blur border-b border-slate-200\"\u003e\n  \u003cdiv class=\"max-w-5xl mx-auto px-4 py-2.5 flex items-center gap-3\"\u003e\n    \u003c%= link_to tickets_path, class: \"text-slate-400 hover:text-slate-700 shrink-0\" do %\u003e\n      \u003ci class=\"fas fa-arrow-left\"\u003e\u003c/i\u003e\n    \u003c% end %\u003e\n    \u003cspan class=\"font-semibold text-slate-900 truncate\"\u003e\u003c%= @ticket.title %\u003e\u003c/span\u003e\n    \u003cspan class=\"ml-auto shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold\n                 \u003c%= state[:tone] == :attention ? \"bg-amber-500 text-white\" : \"bg-slate-100 text-slate-600\" %\u003e\"\u003e\n      \u003c%= state[:label] %\u003e\n    \u003c/span\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n```\n\nA sticky bar matters more than it looks: on a long stream the title scrolls away in one\nflick, and \"which record am I in, and does it need me?\" is exactly what gets lost.\n\n---\n\n## Layer 4 — Collapse the stream, expand only what's needed\n\nEvery item is a `\u003cdetails\u003e`. Open on load **only** what the reader came for: the newest\nitem, plus anything needing action. Pure HTML — no Stimulus controller.\n\n```erb\n\u003c%# app/views/tickets/_message.html.erb %\u003e\n\u003c% newest = @messages.last %\u003e\n\u003c% expand_all = @messages.size \u003c= 2 %\u003e\n\n\u003cstyle\u003e\n  .msg \u003e summary { list-style: none; cursor: pointer; }\n  .msg \u003e summary::-webkit-details-marker { display: none; }\n  .msg[open] \u003e summary .msg-preview { display: none; }\n  .msg[open] \u003e summary .msg-chevron { transform: rotate(90deg); }\n  .msg-chevron { transition: transform .15s ease; }\n\u003c/style\u003e\n\n\u003c% @messages.each do |message| %\u003e\n  \u003c%# OURS is indented with a left rule; THEIRS is flush. That indentation IS the role\n      cue — see the colour rule below. %\u003e\n  \u003cdetails id=\"msg-\u003c%= message.id %\u003e\"\n           class=\"msg scroll-mt-28 rounded-lg border border-slate-200 bg-white\n                  \u003c%= \"ml-8 border-l-2 border-l-slate-300\" if message.from_us? %\u003e\"\n           \u003c%= \"open\" if expand_all || message == newest %\u003e\u003e\n    \u003csummary class=\"px-4 py-2.5 hover:bg-slate-50/70 rounded-lg\"\u003e\n      \u003cdiv class=\"flex items-baseline gap-2\"\u003e\n        \u003ci class=\"fas fa-chevron-right msg-chevron text-[10px] text-slate-300 shrink-0\"\u003e\u003c/i\u003e\n        \u003cspan class=\"text-sm font-medium text-slate-900 shrink-0\"\u003e\u003c%= message.author_name %\u003e\u003c/span\u003e\n        \u003cp class=\"msg-preview text-xs text-slate-400 truncate min-w-0\"\u003e\n          \u003c%= truncate(message.body.to_s.squish, length: 90) %\u003e\n        \u003c/p\u003e\n        \u003cspan class=\"ml-auto text-xs text-slate-400 whitespace-nowrap shrink-0\"\u003e\n          \u003c%= message.created_at.strftime(\"%b %-d\") %\u003e\n        \u003c/span\u003e\n      \u003c/div\u003e\n    \u003c/summary\u003e\n\n    \u003cdiv class=\"px-4 pb-4 pl-10\"\u003e\n      \u003cdiv class=\"text-sm text-slate-700 whitespace-pre-wrap leading-relaxed break-words\"\u003e\n        \u003c%= message.body %\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n  \u003c/details\u003e\n\u003c% end %\u003e\n```\n\n**Every collapsed row must show a preview.** A row that says only \"Message\" is a locked\ndoor; a row showing the first 90 characters is a door with a window. Hiding content with\nno visible cue is the classic way progressive disclosure goes wrong.\n\n### Jumping to a collapsed row\n\nAn anchor link to a closed `\u003cdetails\u003e` lands on the summary and looks broken. CSS cannot\nforce `open`, so this is the one place JS earns its keep:\n\n```erb\n\u003c%# app/views/tickets/show.html.erb %\u003e\n\u003cscript\u003e\n  (function () {\n    function openTarget() {\n      var hash = window.location.hash;\n      if (!hash || hash.length \u003c 2) return;\n      var el = document.getElementById(hash.slice(1));\n      if (el \u0026\u0026 el.tagName === \"DETAILS\") { el.open = true; el.scrollIntoView({ block: \"start\" }); }\n    }\n    window.addEventListener(\"hashchange\", openTarget);\n    document.addEventListener(\"turbo:load\", openTarget);   // Turbo swaps the body...\n    document.addEventListener(\"DOMContentLoaded\", openTarget); // ...so this alone isn't enough\n  })();\n\u003c/script\u003e\n```\n\n---\n\n## The colour budget\n\nPick at most **three semantic colours plus neutrals**, write down what each means, then\nnever use it for anything else.\n\n| Colour | Means | Use for |\n|---|---|---|\n| **Amber** | a human must act | overdue, waiting, unsent, needs review |\n| **Red** | broken / destructive | errors, delete |\n| **Blue** | interactive | links and buttons **only** — never status |\n| **Slate** | everything informational | all other text, chips, borders |\n\nTwo rules that do the heavy lifting:\n\n- **Roles are structural; colour is exceptional.** \"Theirs vs ours\", \"parent vs child\",\n  \"sent vs received\" are *roles*, not alerts. Show them with **indentation, position, or\n  a subtle left border** — like a chat transcript. Tinting one side blue and another\n  purple is what turns a page into confetti, and it burns the colours you need for real\n  alerts.\n- **One saturated element per screen** — the primary action. If two things are the\n  brightest thing on the page, neither is.\n\n```erb\n\u003c%# The single saturated element: the one thing the operator came here to do. %\u003e\n\u003c%= link_to \"Reply\", reply_ticket_path(@ticket),\n      class: \"inline-flex items-center px-4 py-2 rounded-md bg-blue-600 text-white\n              text-sm font-medium hover:bg-blue-700\" %\u003e\n```\n\n---\n\n## Ship checks\n\nRun these before calling it done:\n\n1. **Squint test.** Blur your eyes, or zoom the browser to 25%. Can you still tell where\n   to look first? Uniform grey mush = no hierarchy. Confetti = too many colours.\n2. **5-second test.** Show it to someone for five seconds, hide it, ask what the page is\n   for and what needs doing. If they can't say, layer 1 is wrong.\n3. **Subtraction pass.** Name what you *removed or demoted* this round. If that list is\n   empty, you didn't design — you accumulated.\n\n---\n\n## Gotchas\n\n- **Empty fields must be omitted, not rendered as \"—\".** A column of em-dashes is pure\n  noise that costs a read each. `sections.select { |_, v| v.present? }` before rendering.\n- **Demote, don't duplicate.** After moving a block to a tab, assert it is *gone* from\n  the first tab. It's easy to \"move\" something and leave the original behind, which\n  doubles the page instead of halving it.\n- **Don't repeat the state badge.** Once it's in the sticky bar, delete it from the\n  sidebar, the section header, and the row. Duplicated status is the sneakiest source of\n  clutter, because each copy looks reasonable on its own.\n- **`\u003cdetails\u003e` needs its marker killed in CSS**, and `::-webkit-details-marker` is a\n  separate rule from `list-style: none`. Skip either and you get a stray triangle.\n- **A raw-SQL `order` makes a relation irreversible.** If a stream scope uses\n  `order(Arel.sql(\"sent_at ASC NULLS LAST\"))`, then `.last` raises\n  `ActiveRecord::IrreversibleOrderError` anywhere in the app. Postgres already sorts\n  NULLs last on `ASC`, so prefer plain `order(:sent_at, :id)` for a `has_many` default\n  scope.\n- **Tabs default to the wrong side by habit.** Default to the tab that answers the\n  3-Second Question — usually the customer-facing stream, not the internal notes you\n  spent the most time building.\n- **Don't invent a new card/badge/header style.** Copy the nearest existing page in your\n  app. A familiar layout costs nothing to parse; consistency is itself a cognitive-load\n  reducer.\n- **Font Awesome isn't guaranteed** on every instance. If `fa-chevron-right` doesn't\n  render, swap in an inline SVG or a plain `›`.\n\n---\n\n## Files this pattern touches\n\n```\napp/controllers/tickets_controller.rb     # @tab, scoped queries per tab\napp/models/ticket.rb                      # state_summary — ONE badge\napp/views/tickets/show.html.erb           # layer map comment, sticky bar, tabs\napp/views/tickets/_message.html.erb       # collapsed \u003cdetails\u003e rows\n```\n\n## How to adapt to your schema\n\n1. Replace `Ticket`/`Message` with your record and its stream (order, comment, event,\n   revision). The pattern needs only \"a record + a chronological list of related items\".\n2. Write the Job Sentence for **your** operator first. Don't copy the ticket one — the\n   layer map is the design, and it changes per screen.\n3. Rewrite `state_summary` in your own priority order. Keep it returning **one** label\n   and a `:attention`/`:calm` tone. Resist adding a third tone.\n4. If your record has no second job, **drop the tabs entirely** — a single-purpose page\n   shouldn't grow navigation it doesn't need.\n5. If your stream is short (≤2 items), the `expand_all` branch already renders everything\n   open; the pattern degrades to a plain list on its own.\n"}