Skip to content

Shape the calendar: modals over content, periods, and events in their calendar's color - #278

Merged
monorkin merged 19 commits into
mainfrom
worktree-modal-overlays
Aug 22, 2026
Merged

Shape the calendar: modals over content, periods, and events in their calendar's color#278
monorkin merged 19 commits into
mainfrom
worktree-modal-overlays

Conversation

@monorkin

Copy link
Copy Markdown
Collaborator

Nineteen commits of shaping the TUI's calendar, and one thing that turned out to be a bug
behind three of them. Best read a commit at a time — each says why.

Modals over the content

A picker used to replace the view it was opened from. Now it is drawn over it: the label and
collection pickers share one frame, the account picker composites over what was on screen,
and the habits, to-dos and calendars menus are the same frame again. What just happened is a
toast in the top-right for two seconds rather than a line in the header, everywhere.

The calendar reads periods, from the calendars that are on

A calendar's recordings and a period's are not the same answer. A calendar lists what it
holds, and a recurring event is one row in it; a day, a week or a year expands that row into
the occurrences inside its window. So the day showed a weekly meeting once, on the day it
was created, and every week after looked empty.

The day and the week read the period HEY serves for them now, and the year reads the grid it
serves for that. There is no longer one calendar being read either — HEY draws a period from
every calendar the identity has switched on — so the picker is a multi-select over that
selection, writing the toggle and taking the answer back rather than keeping its own.

Needs SDK 0.17.0 for the period reads, the toggle, and the selection the calendar list
carries.

Events are blocks in their calendar's color

Which calendar an event was on could not be answered by looking at it. Every event is now a
block filled with its calendar's color and its title inverted over it, in all three spans —
which is also what the web app draws.

Getting the ink right took three goes and the last one is the interesting one. An ANSI slot's
nominal value says nothing about what a reader sees: a theme retints the running terminal
over OSC 4, so blue is #80 in the table and a light periwinkle on screen, and contrast
measured against #80 put white text on it. Omarchy's colors.toml has been answering this
all along, so Theme carries Background and Hues now and the ink is whichever of the theme's
paper or its own text color reads better on the real fill. Two colors the theme states,
measured against each other — no branching on light or dark, and it follows a theme switch
because it is decided while rendering.

One vocabulary across the three spans

The week and the year were boxed tables, every cell walled in. The day never had a box: it is
a header, an axis, and events on open ground. Both now borrow that — dotted rules as guides
rather than walls, chrome for the structure a reader looks past, each span naming itself with
the keys that move it, so p/n and t leave the help bar. Days run to the bottom of the screen
whether or not anything is on them.

The week gained a band of the habits kept each day, as icons, and gathers its all-day events
at its foot. Both assign lanes across the whole week, so a week-long event is a single bar
straight across rather than a staircase.

Three bugs found on the way

Everything was drawn in UTC. A Recording's times were strings rendered into UTC and
parsed back, so the zone was thrown away at the edge. A 14:00Z meeting sat on the 14:00
column wherever the reader was. They are time.Time now and read through Starts/Ends, which
answer on the local clock — except an all-day event, whose timestamp is a calendar date
haystack serves as UTC midnight on purpose.

The week never showed habits. It matched a habit's StartsAt against each day, but that is
the day the habit was taken up. Which day it was kept on is what its completions say, and
those were folded into a single CompletedAt and dropped — lossy past one day.

A journal entry was drawn as an event. splitRecordings named the todos and the habits and
let everything else fall through to events, so a Calendar::JournalEntry — which has no
title — came out as a bar of bare color across the day. It names what it wants now.

Notes for review

Two commits were dropped in the rebase: an earlier SDK bump superseded by #272, which did the
same response-cap work more thoroughly, and a 0.16.0 waypoint superseded by the 0.17.0 bump.
The journal conflicts against #275 were resolved by keeping the new feed and applying the
toast change to it — the confirmation ask for x moved to the help bar, where the habits
picker already puts its own.

vendorHash recomputed and verified against a real Nix build after the rebase.

One gap this keeps running into, server-side: _calendar.jbuilder serves a calendar's color
only unless calendar.personal?, so events on the reader's own calendar arrive with no color
and fall back to the theme's accent. Every other HEY client reads it from the model directly.

The two were byte-for-byte copies of each other's drawing: the same
compositor, the same rounded border, the same scrolling column of names,
differing only in the title and which slice they walked.

modal.go now holds that once — overlayModal for the centered composite,
modalFrame for the border and title, framedList for a picker's body, and
stepListCursor for the arrow-key ladder each was carrying its own copy
of. The frame takes any body, so a form fits it as well as a list.

Nothing about what is drawn changed, which is why both pickers' tests
pass untouched.
The account picker blanked the content area and took the screen, with
five branches through the model to make that happen — its own case in
View, in updateHelpBindings, in handleKey, in canToggleHelp. It is a
modal now, in the same frame as the label and collection pickers, drawn
over whatever section it was opened from.

That needed the err/loading/activeView ladder pulled out of View as
contentView, which is the seam a layer composites over. The first two
users are the picker and a toast: notify(...) raises one from anywhere,
the model holds it in the top right corner for two seconds and takes it
away, and its timer names the toast it belongs to so a stale clock cannot
clear a newer one.

Nothing raises a toast yet — the sections still write their own notices.
A confirmation was written into the section that raised it, which meant
it was shown wherever that section happens to put a notice — and a thread
covers the posting list's header, so "Message forwarded" was said to
nobody. There was a test working around exactly that.

Every mutation that says what just happened now returns notify(...) and
the model draws it over whatever is on screen: threads moved, seen and
unseen, labels and collections, contacts and their notes, journal
entries, bulk replies, attachments, Screener decisions.

What stays an inline notice is anything describing the state of the
screen — a list that has stopped following the server, a load that failed
and names the key that retries it, a confirmation waiting to be answered,
a thread's partial-read notice. Two seconds is exactly wrong for those.

The journal's notice row went with it, so an entry gets that line back.
The day view spoke its own visual language and got a few things wrong.

It borrows the mail list's vocabulary now: chrome for the structure a
reader looks past — the hour axis, an event's border, a section's rule —
and bright bold for the thing they came to read. Its sections are
labelled the way the posting list's are, the day names itself above its
hours, and the week's to-dos are pinned under the grid as "Sometime this
week" rather than scrolling away inside it, because a to-do is not due at
an hour. An hour rule falls from every label to the bottom of the grid,
dotted so it reads as a guide rather than another box's border, and the
axis closes on another 00: twenty-four hours are twenty-five lines. That
replaces "(no events)" — an empty day is its hours.

Habits were wrong, and it was data rather than drawing. HEY answers a
habit and *doing* the habit as separate recordings, and a completion
carries no title and no icon — only the parent it belongs to. Both were
being listed as habits, so a day with three habits done showed three
nameless circles and no habit marked done. A completion now marks the
habit it names and is never listed itself.

Managing them moved off the calendar's keys into a modal on b, as in the
web app, with the hint on the section header where the cover puts "x to
peek". Enter ticks a habit off for the day on screen or clears it; the
ring wears the habit's own color; the icon is the emoji standing in for
HEY's SVG. The edit form stopped asking anyone to spell "meditate": icon,
color and days are pickers, and the wall of accepted values underneath
them is gone along with two classes of error.

A read behind a modal no longer claims the spinner. Ticking a habit off
reads the day again, and a spinner for that is a flash of nothing where
the day used to be.
The calendar only ever showed today. ← and → — or p and n — now move it
by its own unit: a day in the day view, a week in the week view, a year
in the year view, since "the one before this" means whatever the view is
showing. t comes back.

Today is the zero value rather than today's date, so t is a reset and a
view sitting on today keeps following the clock past midnight, which is
what reading it on every fetch was for. The keys that move the day are
said on the line that names it, where the cover puts "x to peek", and t
joins them once it would do something; the week and the year have no such
line, so the help bar still carries theirs.

Only the first read puts the spinner over the calendar now. Once a day
has been drawn, a step to the day either side keeps it on screen until
the answer lands — the way the mail list keeps its list while it reads
the page below — instead of blanking the screen on every arrow press.

Ticking a habit off also lands on the day being looked at rather than on
today, which is what its own comment already claimed.
"Sometime this week" was a line of to-dos you could read and nothing
else. s opens it as a modal, in the same frame as the habits one, and
that is where they are managed: enter ticks one off or clears it, a names
a new one, e renames, x twice deletes.

A new to-do is filed on the day on screen, sent as a bare date so the day
is the reader's rather than UTC's — step to Friday and it lands on
Friday. A rename carries the title alone, so the day it is filed on stays
where it was, and an unedited input is no rename: it was filled with the
title as the screen shows it, so saving it back would rewrite whatever
the sanitizer took out.

While the input is open every key belongs to it, so a types an "a" rather
than starting a second to-do, and an unnamed one is refused rather than
sent. The section line stays on an empty week — it is where the first
to-do is added from.

calendarRequestHabitMutation and habitMutationMsg lose the habit in their
names, since a to-do write is the same thing: it blocks the same keys,
blocks an account switch, and refreshes the day on success. The message
carries its own failure wording now, so a to-do that could not be added
says so instead of inheriting "Delete failed".
The row above the grid held the calendars and the span was a key that
cycled — which is backwards. There are three spans, always the same
three, which is exactly what a row of tabs is for; a reader can be on any
number of calendars, shared ones come and go, and a row cannot show more
than fits.

So the row is Day, Week and Year now, picked by 1, 2 and 3 the way a box
is picked in the mail list, or with ←→ from the row itself. The rule above
it names the calendar being read and the key that changes it, where the
day's line carries its own keys. C opens the calendars as a menu, in the
same frame as habits and the to-dos; with one calendar there is nothing
to switch between, so it does not open and the rule does not offer it.

v is gone with the cycle it drove. Three named keys and a row that shows
which one is on beat one key that moves you somewhere you have to read to
discover.
The second row is the span now — Day, Week, Year, each wearing its own number
the way the boxes do, so 1, 2 and 3 leave the help bar: a shortcut printed in
the tab it belongs to does not need saying twice.

That frees the rule above the row to name the span that is on, as the box row's
rule names the open box. Which calendar is being read moves into a menu, where
the row it opens on is marked, since the menu is now the only place that is
said.

g opens it, not shift+C: the model reads the section shortcuts before a view
sees a key, so C jumps to the calendar and never reaches here.
It carries the calendar's period reads — a day, a week and a year, each scoped
to the calendars the reader has switched on — plus the toggle that switches
them and the selection the calendar list answers alongside it.

Nothing reads them yet. Bumping first so the commit that does has something to
call.

vendorHash recomputed and verified against a real Nix build. Note the order:
`go get` leaves stale go.sum lines that `make tidy-check` rejects, and pruning
them moves the hash, so tidy has to come before the hash rather than after.
@monorkin
monorkin requested a review from a team as a code owner August 22, 2026 20:21
Copilot AI balanced review requested due to automatic review settings August 22, 2026 20:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

A calendar's recordings and a period's are not the same answer. A calendar lists
what it holds, and a recurring event is one row in it; a day, a week or a year
expands that row into the occurrences inside its window. So the day drawn from a
calendar's recordings showed a weekly meeting once, on the day it was created,
and every week after that looked empty. The day and the week now read the period
HEY serves for them, and the recurrence arrives already expanded.

That also means there is no longer one calendar being read. HEY draws a period
from every calendar the identity has switched on at once, so calIndex is gone and
the picker is a multi-select over that selection: space writes the toggle and
takes the answer back, and the period behind it is read again so the reader sees
what they changed. The selection is never guessed at — the toggle answers it, and
the calendar list carries it for the first read.

Each row wears its own color as an ANSI slot, for the reason styles.go and
covers.go give. HEY has one color enum for calendars and habits, so habitColors
became heyColors and gained black, which takes the foreground slot rather than
lipgloss.Black — the reader's ink, legible on either theme, where a literal black
would vanish on half of them.

The personal calendar is not in the list. It has no name of its own, HEY keeps it
selected, and there is no way to switch it off, so a blank unswitchable row is
all it could have been. It still always counts as drawn, which is what decides
whether habits are the reader's to manage.

The year is the odd one. HEY serves a year as the grid it is drawn as — the days,
and the events that span more than one — because a year of expanded occurrences
is not what opening a year asks for. So the year keeps its grid and gives up two
things it used to show: a day's timed events, which the year read does not carry,
and a named day's title, which is a field on a recording there are none of. The
web app's year shows neither.

A day's cell shows every event on it now. The cap was two and a "+N more" line,
which named a number instead of the thing the reader was looking for; the grid
already sizes a week to its tallest cell, so a busy day just makes that week
taller.
Which calendar an event is on was not answerable by looking at it. Every event
was the same bright bold, so a day with three of them told you when they were and
nothing about whose they were.

So an event is a filled block now, in all three views: its calendar's color as the
background and its name inverted over it, which is what the web app draws. The
foreground is colorOnAccent, the one the mail list's pills already use on a filled
background, so it stays legible whichever way the theme goes. The color is a new
field rather than the one already there — Recording.Color is a habit's own color,
and HEY keeps the two apart too.

The day's blocks also stop being as tall as their titles. An event's box is the
span it covers, so its height has to come from the day: one event is as tall as
the grid, two that overlap take half each, three a third, and the title centres in
whatever that leaves. A lane never drops below the three rows a block needs, so a
day with more overlaps than rows grows and scrolls rather than drawing blocks with
no inside. The borders are gone with it — the fill says where an event starts and
stops, and an outline drawn in the color read as a box around empty grid.

One gap worth knowing about: `_calendar.jbuilder` serves a calendar's color
`unless calendar.personal?`, so an event on the reader's own calendar arrives
without one and falls back to the theme's accent. Every other client reads that
color server-side. Serving it would make the fallback unnecessary.
colorOnAccent answers this for the theme's accent and no further, so every event
got black text whatever it was filled with. Black on ANSI red or blue is about
2:1, which is why a light terminal full of filled events was hard to look at
while the same fills read fine on a dark one.

So the ink is asked per fill now, the way applyTheme already asks it for the
accent: whichever of black or bright white reads better on this particular
background. A gold event keeps its black text; a red or a blue one gets white.

It is decided while rendering rather than cached in newStyles, which is what
lets it follow a theme switch. Omarchy retints a running terminal on a keyboard
shortcut, so an event filled with the accent — every event on the personal
calendar, since HEY does not serve that calendar's color — has to re-ask on the
next frame rather than keep the answer it got at startup.
An ANSI slot's nominal value says nothing about what a reader sees. A theme
retints the running terminal over OSC 4, so blue is #80 in the table and a
light periwinkle on screen — and contrast measured against #80 put white text
on it, which is why green and blue were the hard ones to make out.

Omarchy's colors.toml has been answering this all along:

    background = "#060B1E"
    blue = "#7d82d9"   green = "#92a593"   red = "#ED5B5A"

So Theme carries Background and Hues now, an event is filled with the theme's own
value for its calendar's color, and the ink is whichever of the theme's paper or
its text color contrasts better with that fill. Two colors the theme states,
measured against each other. On a dark theme every hue arrives light and they all
take the dark paper; on a light one the same hues arrive deep and the rule flips
by itself, with nothing here asking which mode it is in.

That is also why the light-mode blue was dark on dark. colorPaper came from
theme.Dark, and a theme file's mode wins over the terminal's own report by
design — so a light theme whose file says `mode = "dark"` got black ink on a
light background. Reading `background` sidesteps the question.

A terminal with no theme file still gets the ANSI slots and their nominal values,
which is correct there: nothing retinted them.
A calendar carries a day's own records alongside its events: a
`Calendar::JournalEntry` where the day has been written on, a
`Calendar::DayBackground` where it has a picture, a `Calendar::TimeTrack` where
time was logged. splitRecordings named the todos and the habits and let
everything else fall through to events, so all of those were drawn on the grid.

A journal entry has no title, and a grid draws an event as its name — so it came
out as a bar of bare color across the day. The fill made it obvious; the empty
box was there before and just looked like grid.

So the classifier names what it wants instead of skipping what it does not. A
time track has a title and is still not an event, which is why "no title" was the
wrong test for this.
The week gets a band across the top holding the habits kept on each day, as their
icons alone: there is room for seven days of icons and none for seven days of
names. Every day's band is as tall as the busiest one, so the rule closing it off
is straight and each day's events start level with its neighbours'. A week nobody
kept a habit in has no band and no rule rather than an empty stripe.

Getting there turned up that the week has never shown a habit at all. It matched a
habit's StartsAt against each day, but that is the day the habit was taken up —
Read starts in 2024 — so it never landed in the week on screen.

Which day a habit was kept on is what its completions say, and those were folded
into a single CompletedAt and then dropped. Folding is lossy past one day: a habit
kept on three days of a week has three completions and only the last survived. So
splitRecordings answers them alongside the fold now, and the week joins a
completion to its habit by parent_id for the icon and the color. The day view
keeps the fold, which is the right shape for one day.

The habits leave the day columns with this, since the band is where they are now.
padTo comes back — it went out with the year view rewrite that got reverted.
The arrows stepped the period as well as p and n did, which left them meaning two
things: at the nav and subnav rows they move between sections and spans, and in
the content they moved the date. Now they only ever move between things, and p and
n move the period — so what a key does no longer depends on where the focus sits.

The hint says "p/n day" rather than "pn day", which read as noise.

t drops out of the hint once today is on screen. Stepping away and back leaves the
anchor pinned to today's own date rather than cleared, and onToday asks whether the
view is following the clock — true only for a cleared anchor — so the hint kept
offering to take a reader to the day they were already looking at. That is a
second question, showingToday, and t still clears a pinned anchor either way. It
is just not worth a hint.
The week was a boxed table, every cell walled in with solid ─ │ ┌ ┬ ┐, which read
as a spreadsheet of the week rather than as the week. The day never had a box: it
is a header, an axis, and events on open ground. A week is the same thing with
seven days across instead of twenty-four hours.

So it borrows the day's vocabulary. The rule between days is the dotted ┊ the day
uses for its hours — a guide behind the events, not another box's wall. The day
names are the axis, in the chrome the hour labels wear, rather than the bright a
reader is meant to stop on. And the days run to the bottom of the screen whether
anything is on them or not, because the rules between them are the grid: a quiet
week still reads as seven days rather than as a paragraph that stops.

The week names itself now — "August 17 – 23" — and carries the keys that move it
on that line, where they belong to the date they act on. They leave the help bar,
which was only holding them because the week had nowhere to say them. The year
still has nowhere, so the bar keeps them for that one.

The habits band gets a section header instead of a rule, which is both the day's
pattern and a better divider: a rule with a name on it says what the band above it
was. It stands whether or not anything was kept that week, so stepping through the
weeks does not shift the grid underneath the reader. It goes altogether only for
somebody who keeps no habits at all, since then there is nothing to head.

The band tests keyed off row numbers and broke the moment the layout moved. They
find their rows by what those rows say.
The year gets what the week got. No box — it was the last boxed grid left — the
days dotted apart with the rule the day uses for its hours, and the year naming
itself with the keys that move it. It keeps a solid rule between weeks, which the
week view has no need of: a row there is one line, and here it is as tall as its
busiest day, so without one there is nothing to say where January's last week ends
and February's first begins. And it grows no column-name header, because each of
its cells already says its own weekday — the week needs one because its cells do
not.

With that, every span names itself, so p/n and t leave the help bar altogether.

All-day events are blocks in their calendars' colors now, in both the day and the
week. The day drew them [like this─────], which said "all day" by reaching across
and said nothing about whose they were; a timed event is a solid block standing up
in the grid, so an all-day one is that block lying on its side.

The week gathers them at its foot under an "All day" header, as the day does. They
belong to no hour, so leaving them at whatever depth each day's timed events
happened to reach put them at seven different heights. Laying each day out on its
own had a second problem: a day with a one-off all-day event pushed the week-long
one down a row there and nowhere else, so a holiday spanning the week came out as
a staircase. The band assigns lanes across the whole week instead — longest first,
one row held for every day an event covers — so a week-long event is a single bar
straight across and the short ones fill in around it.

weekGridBorder goes with all this. Nothing draws an event as an outline any more.
HEY answers every timestamp in UTC — ApiRequest#set_utc_timezone sets the zone for
every JSON request — and the calendar was drawing them as they arrived. A 14:00Z
meeting sat on the 14:00 column wherever the reader was, the week printed "14:00"
beside it, and a 23:30Z one was filed under the wrong day for anybody east of UTC.

The cause was the round trip. A Recording's times were strings, rendered by
formatTimestamp into UTC and parsed back by parseEventTime, so the zone was thrown
away at the edge and nothing downstream could get it back. AGENTS.md already said
this was the thing to fix and why: "Formatting one for display and parsing it back
is how `hey journal list` printed the wrong day."

So they are time.Time now, carried across untouched, and read through Starts and
Ends — which answer on the local clock. formatTimestamp and parseEventTime are
both gone.

An all-day event is the exception, and haystack is explicit about it: its timestamp
is a calendar date served as UTC midnight, wrapped in Time.use_zone("UTC") in
`_recording.jbuilder` so no offset creeps in. Converting one would move a birthday
to the day before for every reader west of UTC, so Starts leaves it alone.

The grid tests name the hour the reader sees rather than the hour UTC does, since
that is the hour the column is. atLocal is that, and it keeps them honest on a
machine in any zone.
@monorkin
monorkin force-pushed the worktree-modal-overlays branch from de800bf to e7776d4 Compare August 22, 2026 20:28
@monorkin
monorkin merged commit 5615b79 into main Aug 22, 2026
33 checks passed
@monorkin
monorkin deleted the worktree-modal-overlays branch August 22, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants