Progressive Disclosure for Dense Detail Pages | LlamaPress Cookbook
LlamaPress
Cookbook
Design Stable

Progressive Disclosure for Dense Detail Pages

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".

view · controller

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

  1. 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.
  2. Name the 3-Second Question — what must be answerable with no scrolling and no clicking. Exactly one per page.
  3. Cap layer 1 at five elements. Everything that doesn't answer the 3-Second Question drops to layer 2 or lower.
  4. Split the second job into a tab (?tab=, server-rendered — no JS).
  5. Collapse the stream to one line per item; expand only the newest.
  6. 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:

  1. 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.
  2. 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.
  3. 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-marker is a separate rule from list-style: none. Skip either and you get a stray triangle.
  • A raw-SQL order makes a relation irreversible. If a stream scope uses order(Arel.sql("sent_at ASC NULLS LAST")), then .last raises ActiveRecord::IrreversibleOrderError anywhere in the app. Postgres already sorts NULLs last on ASC, so prefer plain order(:sent_at, :id) for a has_many default 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-right doesn'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

  1. Replace Ticket/Message with your record and its stream (order, comment, event, revision). The pattern needs only "a record + a chronological list of related items".
  2. 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.
  3. Rewrite state_summary in your own priority order. Keep it returning one label and a :attention/:calm tone. Resist adding a third tone.
  4. If your record has no second job, drop the tabs entirely — a single-purpose page shouldn't grow navigation it doesn't need.
  5. If your stream is short (≤2 items), the expand_all branch already renders everything open; the pattern degrades to a plain list on its own.

Related