Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,30 @@ badges describe unresolved discussions; they do not represent per-thread unread
receipts. Do not color them orange based on `open` status. Any future per-thread
unread indicator must implement the acknowledgement lifecycle above using
viewer-specific state rather than a local color toggle.

## Changes since the last visit

Treat updates as reading signposts. Use a small orange dot beside each
changed heading, leaving paragraphs, code, tables, and diagrams in their
original surfaces and spacing. An unheaded introduction
gets one dot at its first block. Do not tint whole sections or draw rails
down the document.
On phones, keep the dot's full hit area inside the document card. Give the
notice horizontal padding and put its actions below the summary.

A compact notice reports the changed section count and offers History and
Dismiss. Reserve its space even when hidden so a live update never pushes
the document down. A heading that stays fully in view for three seconds
loses its attention cue. Scrolling past quickly does not count. Hover or keyboard
focus on the dot keeps it visible and shows who last edited that section,
with relative and exact times. Keyboard focus remains blue. Dismiss clears
all cues; preserve their footprint so nothing moves under the reader.
If a section receives another live edit, its dot returns for that new edit.
New and renamed sections receive dots too. Dismiss acknowledges the current
updates; later edits can bring attention back, including the rewrite notice.
After a rewrite notice is dismissed, ordinary edits get fresh section dots.
Presentation mode defers the display of changes until the show ends; retain
every queued section's last editor and any extensive-update notice.
Extensive changes get one Updated throughout notice and a history link
instead of marking every section. These cues are per viewer and disappear
on the next visit.
172 changes: 136 additions & 36 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -1732,55 +1732,130 @@ img.avatar {
.mt-md { margin-top: var(--space-md); }
.mb-md { margin-bottom: var(--space-md); }

/* "Changed since you last looked" — one-time, per-viewer section
highlights (changed_sections_controller). Quiet left accent + faint
tint; gone on the next visit. */
/* A run of adjacent changed blocks is one band, not one box per block:
the gap between blocks in a run becomes padding inside the tint instead
of untinted margin showing through, so a changed section reads as a
single stretch rather than a stack of stripes. Scoped under
.markdown-rendered to outrank its per-element margins. */
.markdown-rendered .section-changed {
box-shadow: inset 3px 0 0 var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
border-radius: 0;
padding-left: var(--space-sm);
margin-left: calc(-1 * var(--space-sm));
margin-bottom: 0;
padding-bottom: var(--space-md);
/* Changes since the last visit are small signposts beside section starts.
No bands, padding changes, or overrides of prose/code/table surfaces. */
.markdown-rendered .section-updated {
position: relative;
scroll-margin-top: 9rem;
}

/* Headings inside a run keep their breathing room, as padding within the
band rather than a gap through it. The run's own first heading is
.section-changed--start and keeps its margin — that space separates the
band from the unchanged content above it. */
.markdown-rendered .section-changed:is(h1, h2, h3):not(.section-changed--start) {
margin-top: 0;
padding-top: var(--space-xl);
/* Give the small dot a comfortable target without changing heading layout.
The empty span keeps update metadata out of copied text and section slugs. */
.section-update-marker {
position: absolute;
left: -23px;
top: calc(0.65em - 8px);
width: 22px;
height: 22px;
border-radius: 50%;
cursor: help;
}

.markdown-rendered .section-changed--start {
border-top-right-radius: var(--radius);
padding-top: var(--space-sm);
.section-update-marker::before {
content: "";
position: absolute;
left: calc(50% - 2.5px);
top: calc(50% - 2.5px);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--color-warning);
}

.markdown-rendered .section-changed--end {
border-bottom-right-radius: var(--radius);
padding-bottom: var(--space-sm);
margin-bottom: var(--space-md);
.section-update-marker::after {
content: attr(data-tooltip);
position: absolute;
left: 23px;
top: calc(100% + 2px);
z-index: 12;
width: max-content;
max-width: min(28rem, calc(100vw - 3rem));
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow);
font-size: var(--text-sm);
font-weight: 400;
line-height: 1.5;
letter-spacing: normal;
white-space: pre-line;
overflow-wrap: anywhere;
opacity: 0;
visibility: hidden;
pointer-events: none;
}

.section-update-marker:is(:hover, :focus-visible)::after {
opacity: 1;
visibility: visible;
}

.section-update-marker:is(:hover, :focus-visible) {
background: var(--color-interaction-hover-bg);
}

.section-update-marker:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 4px;
}

.section-update-marker { transition: opacity 180ms ease; }
.section-updated--viewed .section-update-marker {
opacity: 0;
visibility: hidden;
pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
.section-update-marker { transition: none; }
}

.changed-sections-note {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-md);
gap: var(--space-xs) var(--space-md);
padding: var(--space-sm) 0;
margin-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
font-size: var(--text-sm);
}

/* Keep the notice's footprint even before the first live update. Override
the global [hidden] display rule only for this reserved reading space. */
.changed-sections-note[hidden] { display: flex !important; visibility: hidden; }
.changed-sections-note--dismissed,
[data-editing="true"] .changed-sections-note { visibility: hidden; }
.changed-sections-note__summary { flex: 1 1 16rem; }
.changed-sections-note__controls { display: flex; align-items: center; gap: var(--space-sm); }

.changed-sections-note button,
.changed-sections-note a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
border: 0;
border-radius: var(--radius);
padding: 4px 6px;
background: transparent;
color: var(--color-text-muted);
font: inherit;
white-space: nowrap;
cursor: pointer;
text-decoration: none;
}

.changed-sections-note button:hover,
.changed-sections-note a:hover {
color: var(--color-primary);
background: var(--color-interaction-hover-bg);
}

.changed-sections-note button:focus-visible,
.changed-sections-note a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}

/* Rendered markdown */
Expand Down Expand Up @@ -7292,7 +7367,27 @@ img.avatar {
}

.plan-document {
padding: var(--space-md);
/* Leave room for the update dot's full hit area inside the card. */
padding: var(--space-lg);
}

.changed-sections-note {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
padding: var(--space-sm);
}

.changed-sections-note__summary {
flex: none;
min-height: 1.5em;
line-height: 1.5;
}

.changed-sections-note__controls :is(a, button) {
min-height: 36px;
display: inline-flex;
align-items: center;
}

.references-section,
Expand Down Expand Up @@ -7340,6 +7435,11 @@ img.avatar {
}
}

/* The summary wraps on small phones; reserve the same room for live edits. */
@media (max-width: 360px) {
.changed-sections-note__summary { min-height: 3em; }
}

/* ═══ Expanded view ═══════════════════════════════════════════════════════
The shared takeover surface (coplan/expander.js) behind an expanded
Mermaid diagram and an expanded data table. The chrome — title bar,
Expand Down
10 changes: 6 additions & 4 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -738,22 +738,24 @@ def load_needs_attention
end

# What changed after the viewer's last visit (see Plans::ChangedSections):
# section keys to highlight, or a rewrite to mention. Nothing on a first
# visit — highlighting the whole document would say nothing.
# section keys to mark, or extensive updates to mention. Nothing on a
# first visit — there is no earlier read to compare.
def changed_sections_since_last_visit
seen_at = PlanViewer.where(plan: @plan, user: current_user).pick(:last_seen_at)
return Plans::ChangedSections::NONE if seen_at.nil?

current = @plan.current_plan_version
return Plans::ChangedSections::NONE if current.nil? || current.created_at <= seen_at

base = @plan.plan_versions.where(created_at: ..seen_at).order(revision: :desc).first
base = @plan.plan_versions.where(created_at: ..seen_at).reorder(revision: :desc).first
return Plans::ChangedSections::NONE if base.nil?

Plans::ChangedSections.call(
result = Plans::ChangedSections.call(
old_content: base.content_markdown,
new_content: current.content_markdown
)
@section_updates = Plans::SectionUpdates.call(plan: @plan, keys: result.keys, since_revision: base.revision)
result
end

# Looking at a plan advances your last-seen mark (so the "changed since
Expand Down
5 changes: 5 additions & 0 deletions engine/app/helpers/coplan/plan_events_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ def history_actor_name(item)
item.agent_name.present? ? "#{item.agent_name} (via #{name})" : name
end

def section_update_details(version)
{ by: history_actor_name(version), at: version.created_at.iso8601, revision: version.revision,
ago: "#{time_ago_in_words(version.created_at)} ago" }
end

# Render a one-line, human-readable summary of a PlanEvent for the
# history feed. Each event type gets a tailored "X → Y" or "added X" /
# "removed X" phrasing instead of a generic field/before/after dump,
Expand Down
Loading
Loading