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