Skip to content

feat(sidebar): align every row's leading edge, group pinned projects, and sweep running titles - #1160

Open
zeroy1024 wants to merge 13 commits into
vastsa:mainfrom
zeroy1024:feat/sidebar-row-state
Open

zeroy1024 wants to merge 13 commits into
vastsa:mainfrom
zeroy1024:feat/sidebar-row-state

Conversation

@zeroy1024

Copy link
Copy Markdown
Contributor

Sidebar rows had drifted apart in ways that made a list of them hard to scan: each kind of row decided its own left inset, pinned state was marked by a glyph that shifted the title it belonged to, and the running indicator only animated inside project groups.

What changed

One leading glyph per row. A project row used a chevron for disclosure and a folder beside it, so it was the only row that started 13px further right than everything else. Disclosure now rides the folder itself — a closed folder when collapsed, an open one when expanded — which says the same thing with one glyph and keeps the title on a single left edge.

A reserved leading slot on session rows. The leading icon used to move with whatever the row carried: a pin, a Pi badge, or nothing, so titles in one list began at three different positions. A fixed-width slot now reserves that column whether or not anything fills it. The transient permission/completed/failed glyph borrows the same box instead of hanging further left than the row's own icon, and the Pi badge — text, so it cannot fit an icon slot — rides after the title as metadata.

Running state as a sweep across the title. The leading dot that marked a running session is gone; the title itself now takes the pass. The band is measured from the row's own width rather than expressed as a share of it, so a three-word session and a thirty-word one get the same thickness and the same speed instead of reading as two different effects. The dip is mixed from currentColor rather than a semantic token, because --ds-text-muted resolves as translucent white in dark and an opaque grey in light, and a band built from it disappears against light-theme text.

Pinned projects in a group of their own. Pinning was a badge on a row, which said the same thing twice — the row was already sorted to the top — and pushed every pinned title 18px right of every unpinned one. Pinned projects are now drawn into a group above the list and the badge is gone; being in the group is the marker. Dragging a project across that boundary pins or unpins it, decided from the list containers rather than from a row, because the half being entered can be empty and would then offer nothing to drop onto.

Pinned state marked by angle, not by fill. The pin action filled its outline to say "pinned", which at row size stopped reading as a pin. There is no pinned glyph in the icon set, so the angle carries the state instead.

Verification

  • pnpm build:js — clean
  • pnpm --filter @pi-desktop/desktop test — 3022 passed
  • pnpm test:e2e — 23/23 (2 live-model suites skipped, no API key)
  • pnpm test:e2e:session-completion — 6/6
  • pnpm test:e2e:theme-surfaces — pass, including light and custom themes
  • pnpm test:e2e:layout — 192/192

Known gaps

Cross-bucket dragging (pin/unpin by drag) has unit and source coverage but has not been exercised by a scripted end-to-end run, and the spacing rhythm between time-group captions and the rows under them was adjusted by inspection rather than measured.

A session's pin and archive states were only reachable through the row's
overflow menu, so tidying the list meant opening a menu per row. The row
already revealed one hover control; it now reveals the two frequent
toggles beside it, ordered overflow, pin, archive.

The reveal moves from the overflow button's own class to a shared
`.thread-item-action` class, so one rule covers every hover-revealed row
control and a fourth needs no new selector. The overflow button keeps
`thread-item-more` as a DOM hook for its menu anchor.

Each trigger runs the same store action as its menu item and stops the
click before it reaches the row that opens the session. The archive
trigger swaps its glyph with the state so the row reads without a menu.
The inline pin trigger drew the same outline pin in both states, so the
row could not say whether it was already pinned without opening a menu.
Filling the glyph with the state answers it in place, matching the way
the archive trigger already swaps its own glyph.

It keeps the pin glyph rather than switching to the filled star, because
the star already marks a pinned project and the two pins must stay
distinguishable.
Session rows sat at three different background widths. Project group
headers spanned the full width, nested session rows were inset 22px by
their body's margin and padding, and the standalone and pinned lists were
inset 2px on each side. Hover and selection fills therefore started and
ended at different edges depending on which section a row belonged to.

Drop the indents so the row, its group header and the standalone lists
share one left and one right edge. Content insets are unaffected: the
row's own padding and the title's left padding still hold the text off
the edge.

This also drops the nested-body indent that carried the hierarchy after
D297 removed the tree rail, so a session row under a project no longer
reads as its child by position alone. The project header's own status
tag and the section labels remain the hierarchy signal.

The `.temporary` body rule went with it: it existed only to set that
indent and the class is no longer rendered anywhere.
The running and selected states both spent a dot on the leading edge, and
a running row could only show the running dot: the running branch sits
ahead of the selected one, so a selected row that was still working
looked the same as one that was merely working.

Give the two states the signals they already have room for. A running row
sheens its own title, so the run reads inside the row and the sweep has
the title's box to travel across. The selected row keeps its own
background, which is already a stronger fill than hover.

Permission, completed and failed keep their dots unchanged, so those
three still share one leading edge. Reduced motion freezes the sheen at
its bright half, and a renderer without background-clip shows the run as a
still brighter title rather than losing it.
The sweep ran from 160% to -60% on a background twice the title's width.
Both ends park most of that background outside the element: at 160% it
spans -1.6W to 0.4W, at -60% it spans 0.6W to 2.6W. Only the part of the
background that lands on the element fills its glyphs, and the transparent
text colour this technique needs left the rest of the title invisible, so
a running row lost part of its name for most of the animation.

Sweeping 0% to 100% keeps the background covering the element at every
frame: 0% spans 0 to 2W, 100% spans -W to W, and the positions between
span more than the element needs. The sheen is still clipped to the glyph
outlines, so the light crosses the letterforms rather than a box.

Reduced motion holds the middle of the sweep, which is the glyph-shaped
highlight at its brightest rather than a bare colour.
The gradient made a dim base with one bright band, so the title sat at the
dim colour for the whole cycle and only a slice of it ever lit up — the
run read as a darkened title rather than a travelling sheen.

Invert it. The base stops are `currentColor` and the travelling band is
`--ds-text-muted`, so the title keeps the colour the row already gives it
and a dim streak travels over the glyph outlines. Reading the base from
`currentColor` also keeps the streak honest through the row's hover and
selected states, which set their own colour, instead of pinning it at rest.

The fill can no longer be emptied through `color`, because the base stops
resolve `currentColor` against it. `-webkit-text-fill-color: transparent`
hands the glyph fill to the clipped background and leaves `color` alone.
The disclosure chevron said nothing the folder beside it did not, and
it pushed every project title 13px further right than the rows without
one. Collapsed projects now show a closed folder and expanded projects
an open one, so the state reads off the glyph itself and the title
keeps one left edge either way.

Session rows were less consistent than the project rows above them: the
leading icon moved with whatever the row happened to carry — a pin, a
Pi badge, nothing — so titles in one list began at three different x
positions and the hierarchy read as noise. A fixed-width slot now
reserves that column whether or not anything fills it, the transient
permission/completed/failed glyph borrows the same box instead of
hanging further left than the row's own icon, and the Pi badge, which
is text and cannot fit an icon slot, rides after the title as metadata.
The sheen sized its band and its travel as shares of the title, so the
same declaration produced a different effect on every row: a 39px
session crawled at 22px/s behind a fat bar, a 200px one raced at
112px/s behind a thin one, and a list of running rows never looked like
one rule. Pinning the travel to the longest title the sidebar can lay
out fixed the speed and the band but bought a dead pause on every
shorter row instead.

Neither problem is reachable from CSS. The band has to travel
`width + band` pixels to cross a row and clear both edges, which ties
the duration to the title, while `animation-duration` is resolved
before layout and `calc()` cannot read the element's own size. The
pause is worse: holding the band a fixed second would need a keyframe
split that moves with the title length, and `var()` is not a valid
keyframe selector at all — a stylesheet that tries it silently loses
that frame.

So a hook measures each running row's title and owns the animation,
while the stylesheet keeps the part that is genuinely declarative: the
gradient clipped to the glyph outlines. The band is mixed from
`currentColor` rather than a semantic token, because `--ds-text-muted`
resolves as translucent white in dark and an opaque grey in light, and
a band built from it disappears against light-theme text.
Pinning was a badge on a row, which said the same thing twice: the row
was already sorted to the top of the list, and the star pushed every
pinned title 18px right of every unpinned one, so the left edge the
list had just been aligned on broke again on exactly the rows that
were meant to sit apart.

Pinned projects are now drawn into a group of their own above the
list, and the badge is gone — being in the group is the marker. The
two halves are sliced from the one sorted list rather than filtered at
render time, so each keeps the order the chosen sort gave it.

The group sits beside the projects section instead of inside it: nested,
it read as "projects, which contain a pinned subgroup" and the two
labels stacked. Being outside the scroller it now carries its own
height bound, and its label-to-first-row inset and last-group air are
pinned to match the list below, so the distance to the next section no
longer depends on whether the last group is expanded.

Cross-bucket dragging comes free from the boundary that reorder
already refused to cross: a row dragged over the other half is offering
to pin or unpin, decided on release. The zone is read from the list
containers rather than from a row, because the half being entered can
be empty and would then offer nothing to drop onto.
The hook watched the projects scroller, but pinned and temporary
sessions are siblings of that scroller rather than descendants of it.
Those two lists were never measured and never animated, so the running
sweep appeared to belong to projects — while the stylesheet rule that
drew it has been global the whole time.

Widening the container is not enough on its own. Rows are keyed by
session id, and the same key under a different parent is a different
node: pinning a session that is already running moves it out of its
project group and into the pinned list, and the running set does not
change when it does. The replacement arrived with no width and no
animation and quietly never got one, and the node it displaced
detached, at which point the resize observer stops reporting for it as
well. Child-list watching rebuilds the observed set when that happens,
and covers groups added while the sidebar is open.

Rebuilding does not mean rebuilding: a row whose width has not changed
keeps its animation, because a cancel-and-create to arrive at the same
place is a stutter the phase carry-over only hides. Rows that left the
list are dropped, so a detached node does not hold a running animation
forever.
The pin action filled its outline to say "pinned". At the size the row
renders it, the solid head stopped reading as a pin at all — it was a
blob with a stem — and next to the archive button, which fills
legibly because its two states are two distinct outlines, it looked
like a different weight of icon rather than the same one in a state.

The icon set has no glyph for the pinned state, only the pin and the
pin-off, so the angle carries it instead. Both places that draw a pin
take the 45° now, so the glyph reads the same wherever it appears.

The leading slot glyph was the odd one out at 11px, sized down for
clearance the neighbouring folder never needed: it sits in a 13px box
holding a 13px folder with no margin and looks right. Matching it also
lifts the stroke from 0.80px to 0.95px, which is what made it blurry.
Turning the pin does grow its bounding box to about the full grid, but
the slot does not clip and the title is a further 5px away, so it fills
its box rather than losing any strokes.
The pinned projects group shipped its label to three locales and no
others, and the English catalog — which is where the catalog type is
derived from — was never given the key at all. Nothing caught it: the
desktop typecheck reads the built package, so it saw a stale dist, and
the locale tests assert behaviour rather than catalog shape. The first
full build of the i18n sources did, and reported the key as missing
from six catalogs.

Each locale takes the word it already uses for pinned sessions, since
both groups are labelled the same thing. This is why the key has to
start in the English catalog: every other locale is type-checked
against it, so a partial rollout is a build failure rather than a
fallback to English at runtime.
A session title starts at 26px: 2px on the row, 6px of padding, the 13px
leading slot, and its 5px gap. The time-group label above it, and the
load-more affordance below the list, both sat at 20px.

That was correct until the slot was split out of the row. Before it,
the row's inset and its title shared one edge at 20px, so a label at
20px lined up with the text it headed by coincidence. Splitting the slot
moved the title 6px right and left both labels behind, which put every
"yesterday" and "older than two weeks" caption visibly off the column
of titles it groups.

Both move together because they are the same kind of label over the
same list, and the comment records where 26px comes from so the next
change to the slot does not silently reintroduce the drift.
@zeroy1024

Copy link
Copy Markdown
Contributor Author
image

This branch was previously deployed

1 inactive deployment
Preview — 83869011 Deployed Sep 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant