Shape the calendar: modals over content, periods, and events in their calendar's color - #278
Merged
Conversation
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.
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
force-pushed
the
worktree-modal-overlays
branch
from
August 22, 2026 20:28
de800bf to
e7776d4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 notitle — 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
xmoved to the help bar, where the habitspicker already puts its own.
vendorHashrecomputed and verified against a real Nix build after the rebase.One gap this keeps running into, server-side:
_calendar.jbuilderserves a calendar's coloronly
unless calendar.personal?, so events on the reader's own calendar arrive with no colorand fall back to the theme's accent. Every other HEY client reads it from the model directly.