The new reader app replaces the old UI: Checklist with a rail (focus
pills, Next to expire, today's dailies, pinned routines), Timeline in a
new and a classic look, Routines, Archive and a Settings area, in a
Structured (default) and a Relaxed interface. It follows the approved
Round 6 B and Round 7 mockups, corrected screen by screen with the owner.
Reader data moves to a v2 store (gacha-tracker:v2:*). Existing v1 data
is migrated on first load, a recovery copy is written first, and the v1
keys are never removed, so a rollback finds them unchanged. Redeem codes
for Genshin arrive as their own feed.
Also: live's urgency ramp, editable per theme; completion and
section-complete fireworks; a returning-user tour; the design mockups,
specs, plans, reviews and handoff record.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Routine gains clock?: Region, code only. Its day key, its countdown and
its catch-up days are cut on that server's reset rather than the
reader's: for a cycle that ends at one instant on every server. Make it
mine leaves it behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A third period, { unit: "cycle", days, from }, cut in day-key space like
weeks and months, so each game's per-server reset carries over. Tasks on
it are tallied and switched apart (showCycleTasks, on by default), show
the days left in their chip, and the lower row's heading now names the
periods it holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Most shops sell both banners' pulls. A shop's premium line is offered like any
built-in; its standard line (Acquaint Fates, Star Rail Pass, Master Tapes,
Resonite Crystals) only when the reader switches on Include standard-banner
pulls under the built-ins switch. A new pref, standardPulls, absent meaning
off, so no install gains anything until it asks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The strip's Edit panel lists each game's chore, then its built-ins, then its
events, each with Stop tracking, and Track · N offers everything a game could
track and does not. Your own lists every built-in of the games that are on,
untracked ones included and marked, so none is off with no way back.
Renames Remove from dailies / Add an event to Stop tracking / Track.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When merge fuses two sources' copies, the loser's id leaves the calendar, and it may already hold a reader's ticks from a day it was published alone. Until now that needed a hand-edited RETIRED_IDS entry per fusion.
The survivor now lists what it absorbed in an optional mergedIds field. When a feed arrives the client turns those into carry-forward pairs, skipping any id the feed still publishes, and an import takes the same table. Optional, so an older cached feed still validates and an older bundle drops it unread. RETIRED_IDS stays for parser changes, whose old ids no feed will name again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chores had one global switch and built-ins only a per-game opt-in, so the
two answered the same question two different ways. perGame.ts is now the
one rule for both: a global answer, then the games that differ.
Nothing is migrated. routineGames keeps its meaning as the built-ins'
on-list and an absent showRoutines still reads as off, so every reader's
answers, and an older cached tab, see what they saw before. The strip
stops asking about one game once the reader has answered for all.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Giving Genshin's "To Temper Thyself and Journey Far" its five-of-seven goal
meant finding it in the list, opening its sheet, saying it repeats and only
then setting the goal. The strip now has an Edit mode beside Catch up that
lists its events by game with their goals, opens the sheet's goal control in
place, and adds any running event a game at a time. It calls the same
setDaily/setGoal the sheet does, so it can store nothing the sheet would not.
Weekly and monthly tasks are not due tonight, so they move to a row under the
dailies, and each period can be switched off (showWeeklyTasks,
showMonthlyTasks; default on, so nothing disappears on upgrade).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A card above the lists shows the entries the reader hasn't seen, five at
most; Got it records the newest. The footer opens the changelog: release
notes with their commit range and specs, the short entries each covers,
and the rest by month.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First-run setup marks a new reader up to date, since changes mean nothing
before you've used the app. An absent marker therefore means an install
from before the changelog, which is what lets the launch be announced to
existing readers once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Make it mine's copying was inline in App and untested; it is now a pure
function with a test, so a change to either store can't make the copy
silently arrive empty. The question's buttons now say what they answer
when heard out of context. The prefs block and the spec now match what
shipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reload can be served the browser's cached feed for minutes. The button
beside the data's age asks the server past that copy, swaps in a newer
feed without reloading, and says honestly what came back. Newer is
judged on events and sources, never the build stamp. The age now says
"just now" rather than "ended ago" when a fresh feed is a moment ahead
of the page's clock.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The switch sits beside the daily chores and answers the strip's question
too. Built-ins are listed with a link to their dated source. Make it mine
copies one into the reader's tasks, numbers and ticks included, and hides
the original while the copy exists. It's additive, so deleting the copy
brings the original back as it was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The strip asks once, in one line, about a game on screen that has
built-ins. Yes puts them beside its dailies, drawn and counted like the
reader's own tasks, with a tooltip giving the source's date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They live in Your own, beside the reader's games and events. Every field
has a default the reader can accept unread (weekly, from Monday, ticked
off once) except the target, which nobody can guess for them. Save is
offered only when the store would accept what was typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some events want a number of points rather than a number of days. The
goal control gains Days and Points. Switching period keeps the measure
and the number, and the sheet keeps this period's number editable in
place. A target is capped where the schema reads it back, so a huge
number can't silently become no goal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tasks sit at the end of their game's group and are counted by their own
period, so a chore due Sunday is not "waiting on you today" every
morning. A number chip opens an editor under the strip instead of
ticking, since a chip is one target. Catch up offers last period's
number the way it offers yesterday's tick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They exist on this device and nowhere else, so an export without them is
a lossy backup. Both are additive keys, as custom games were: older files
lack them and still import. Tasks travel as stored, including any this
build cannot read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found three ordinary actions that deleted a goal this build could
not read: "It repeats daily", pressing an already-pressed Off, and "not
daily" without saying so. Only "not daily" clears a goal now, and its
warning keys off what is stored, not what parses.
The count field was bound straight to the goal, so emptying it snapped
the old number back and typing appended to it. It now keeps a draft.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The goal sits under the checklist, where its count shows. With a goal
set, the caption counts against it instead of calling unticked days
missed. Reaching an event-wide goal offers Mark event done but never
takes it. Saying "not daily" says up front that it removes the goal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An event wanting five days of seven stays in the strip all week with its
count on the chip. Once the week is met it reads as done this week, and
it counts toward the header without a tick being invented. The burst
now watches today's ticks, so meeting a week by backfilling stays quiet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The footer's staleness notice is an instruction, and the only remedy it
offers is "go and check that game's source page". That is not something a
reader can act on for a game they turned off and cannot see a single row
of — and with nineteen lanes, naming eleven they do not play buries the one
they do. A new lane arrives switched off (F8), so an untouched install was
being warned about most of the catalogue.
Scoped in two places, not one: the named list, and the count the
summarising branch measures against. Without the second, that branch would
never fire for a reader with most of the calendar off and they would get a
list where a sentence was the readable answer.
The notice now also states whose games it counted, in the words NextUp
already uses for the same set. Narrowing what the footer measured while
still printing "nothing has refreshed" would turn a claim about four games
into one about eighteen, in the one paragraph on the page whose job is
being trusted about age.
The headline age and the credits stay whole: the first is a fact about the
feed and feeds the bug form, the second is attribution owed to every source
we read regardless of what is on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Today's dailies carries two different things: each game's standing chore —
"Commissions, resin", a fixed list nobody publishes — and any event with a
checklist. The first is the app guessing at a routine the reader never asked
for, and it was the only part of that strip with no way out.
So the switch removes exactly that. Events keep their checklists whatever
their source, including ones the reader added themselves and marked daily,
which was the requirement most at risk of being filtered away by a switch
aimed at something else.
Nothing is discarded. The ticks live under `dailies:<game>` and nothing here
reads or writes them, so switching back on restores every logged day and
every streak — the same promise `detectDaily` already makes. Defaulted on,
because everyone has these today and a setting that silently removes
something on upgrade is worse than one nobody notices.
Gating where the chore is built rather than where it is drawn means the
counts follow for free: "N still waiting on you today" is derived from the
items, and a game left with nothing contributes no group at all, so the
strip's own empty guard drops it rather than leaving a heading with no rows.
Named for what it removes. A switch called "dailies" would read as broken
while the strip stayed on screen showing the reader's own events.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the new settings row opening nothing for a rule whose `until`
precedes its `startsAt`. That parses — nothing ties the two together — and
yields no occurrence at all, so nextOccurrences and the anchor fallback both
come back empty, nearestOccurrence returns null, and openRow cannot resolve
a bare rule id. It was the undeletable record this index exists to rescue,
now with a button that lies about it.
So resolution moves out of App into displayEventFor, with the rule itself as
the floor: a stored id always names something the reader can edit and
delete, whatever the rule does or does not generate. Exported because
nothing here can click and no test renders App, which is exactly how a dead
button shipped — the chain is now covered without a DOM.
Two of the caption tests could not fail, proven by mutation rather than
read: deleting the whole "starts" branch and removing the null-end guard
both left the suite green, because `not.toContain("ended")` was never
watching the branch at risk. They assert what the caption says now.
And a repeating series that has stopped said only how often it repeats — in
the one place whose job is explaining why an event is on no other surface. A
healthy cadence explains nothing; it says when it stopped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An event of theirs that had ended was on no surface at all. Every list and
the board drop a row once its end has passed, and settings only counted the
events a game held rather than naming them — so a one-off became unreachable
the day it finished: impossible to edit, and impossible to delete out of a
store nothing else can see. Repeating events escaped only because their
occurrences roll forward.
So settings names them now, under the game they were filed against, each row
opening the same detail sheet a row on the front page does. Nothing about
how they are managed changes; what was missing was the way back to them.
Two things the index has to get right or it leaves the same hole it closes.
The lists hold occurrences, never rules, so a repeating rule's own id opens
nothing — nearestOccurrence bridges that, and answers for a finished series
too by falling back to the first occurrence, since a rule whose `until` has
passed would otherwise be exactly as stuck. And an event filed under a game
we track has no row of theirs to nest under; the form allows that, so those
get their own heading rather than trailing the list and reading as though
they belonged to whichever game came last.
Each row says why it is not on the front page — ended and when, or its
cadence — because a list of bare titles leaves you guessing which of two
entries is the dead one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lists only ever build allRows from a rule's first two occurrences
(LIST_OCCURRENCES), but the timeline draws every occurrence the board window
admits. Clicking a bar past the second set openId to an id allRows could not
resolve, so the detail sheet silently failed to open and per-occurrence
completion was unreachable for those bars. occurrenceForId parses the day an
occurrence id names and regenerates just that neighbourhood via occurrencesOf,
so App.tsx can resolve through the rule when the direct lookup misses.
Separately, strandedBy's `if (record.repeat === null) return 0` hid the
warning on exactly the transition where it matters most: a reader marks a
plain event done, then edits it to add a repeat, and the save re-keys every
row from `id` to `id#<date>` with no notice that the mark under the bare id
is about to become unreachable. strandedOccurrences checks the record's own
id when it has no repeat yet, and its next occurrences once it does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Occurrence ids carry their own start day, so moving the anchor or the
interval re-keys every occurrence and the marks under the old ids stop
being reachable. Nothing is rewritten — removeEvent makes the same trade,
and useMarkSet never removes because nothing else holds a copy — but the
reader is told the count first, the way removeGame reports blockedBy
instead of cascading.
Informs, never blocks. Renaming still costs nothing: the token is random
precisely so fixing a typo never moves an id, and movesOccurrences is what
keeps the warning off a rename and off a bare change of `until`.
cadenceLabel sits beside the form's own vocabulary so the sheet cannot
describe a rule differently from the control that set it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The detail sheet looked its record up by the row's id. For an occurrence
that id carries a #date suffix and is not a key in the store, so `own` came
back undefined and the edit and delete buttons vanished on every recurring
row — and a save would have reached editEvent with an id it could not find
and quietly done nothing.
The suffix is deliberate: marks key off the occurrence so each time round
carries its own completion. There is still only one record to edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lists carry two occurrences because they answer "what ends soonest";
the board exists to show rhythm, so it draws every occurrence in view.
expand is called with the settled window rather than returning rows up
front, and the ordering is the point: boardWindow takes its max from the
ends it is handed, so feeding expanded occurrences back into it would widen
the window, generate more, and widen it again — a rule with no until would
never terminate. Compute the range from the base rows, then expand into it.
Merged rows are re-sorted only when there is something to merge, so a
reader with no repeating events sees identical behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lists exist to answer "what ends soonest", so a rule contributes the
occurrence that has not finished and the one after it — no more, however
often it repeats. A weekly rule would otherwise put thirteen rows into the
list F1 is built to keep short.
occurrencesIn answers the timeline's different question and covers a whole
range. It skips non-repeating events deliberately: those are already in
rows, and returning them twice would double every plain event on the board.
useCustom takes the clock as an argument now, because the rows have to
change as time passes — the occurrence on screen rolls to the next one when
the current finishes.
CustomForms.tsx now passes repeat: null when saving a draft — EventDraft
gained the field but the form has no repeat control yet, so every event it
saves stays a single occurrence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Running now" header had a hint reading "next after this ends in …", and the
commit before this one fixed a real defect in the string it produced — an
unannounced end became the word "ended". Both were beside the point: the branch
cannot render at all.
`Section` shows `action ?? hint`. The hint needed a second live row to have
anything to say, and two live rows are two visible rows, which is exactly the
condition that puts the sort control in the same slot. So the action was present
whenever the hint was, and won every time.
What it would have said is already on the page. The headline panel's "Then" list
names the deadlines behind the closest one and counts each of them down, which is
the same answer with more room. So the hint slot goes, `followingDeadlineMs` goes
with it as its only caller, and `Section` is left with the one control slot it
actually uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other surface here is handed `now` — the headline, the board, the dailies
strip, the detail sheet — for the reason the parsers are: a function that reads
the clock cannot be rendered against a fixed instant, so nothing about it can be
asserted. The list row was the one exception, calling the wall clock twice for
its window caption and its "starts in".
Two things follow from fixing it. Those numbers were counted from a different
instant than the `clock` sitting beside them in the same row, which is a
disagreement nobody would ever notice and nobody could ever prove. And the
countdown is now testable: rendering one row at two instants gives the two
answers the injected clock implies, which is what the new test pins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panel was one open block of everything in two columns. That reads fine at
four games and stops working at eighteen: the game list alone is eighteen rows
of four controls each, and it sat above the checkbox somebody had scrolled down
here to tick. Nothing was findable and everything was in the way.
Collapsing it into groups is only half an answer, though — a group showing
nothing but its name turns "what is my region set to?" into a click, which
trades one kind of friction for another. So each summary carries its group's
current state: "Europe · Dark", "17 of 18 on · A–Z", "plus finished, not
started". Closed, the panel is a five-line report of how the app is configured;
opening one is for changing an answer rather than reading it. The states are
derived from `prefs` at render, never stored, so they cannot drift from the
controls they describe.
Native `<details>`, for the reason the reorder arrows are ordinary buttons:
keyboard and screen reader reach it with no second implementation. `summary` is
not an `a`, `button` or `[tabindex]`, so it needed its own focus-visible rule —
the shared one does not reach it.
Two things fell out along the way. The region and appearance rows had no
accessible name at all, so a screen reader read six unlabelled buttons in a row;
they and the board's split pills are one `PillGroup` now, with the `role="group"`
the board's own controls already carry. And the empty states that point at
"Show events that haven't started" now name the group holding it, which is what
makes shipping the groups closed safe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Export is the whole of this app's backup story. Progress, streaks, ignores and
every game and event the reader typed in live in one browser and nowhere else —
no account, no server, nothing to restore from — so the download either happens
or the data was never backed up.
Two things made it a coin toss. The anchor was never in the document, which is
not reliably clickable, and the object URL was revoked in the same task as the
click, which can pull the blob away before the download has read it. Both fail
silently and look exactly like a successful export.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The section header said "next after this ends in {live[1]}", which got the
question wrong in both of the ways lens.ts exists to prevent.
An event whose end was never announced has no time remaining, and the `?? 0`
that stood in for it made `formatRemaining` return its expiry string — so a
second row with `endsAt: null` had the header reading "next after this ends in
ended". That is the one rule this product is built on, inverted: an unknown end
announced as an expiry.
And `live[1]` is the second *row*, not the second deadline. The list is sorted
by whatever mode the reader picked, so under "doing first" the header named
whatever they were second-most partway through — the same mistake firstToExpire
was written to keep out of the headline, one row further down.
`followingDeadlineMs` asks the deadlines instead and returns null when there is
no second dated end, and the header then says nothing at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This project has no linter and does not want one, but `strict` says nothing
about a binding that is simply never read — and that is the one kind of dead
code a reader cannot tell apart from a deliberate seam. `tsc` already walks
every file, so it may as well answer the question.
Three had accumulated: an unread `type View` import in App, and a
`useGameMeta()` resolver in Controls and YourOwn, both of which draw their
colours from somewhere else — the game-order list has its own resolver, and
YourOwn reads the reader's own records directly. None of them were wrong, they
were just leftovers that read as intent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The focus bar, the settings list, the timeline's lanes and the dailies strip all
go through `orderGames` now, resolved once in App. `games` itself stays in feed
order on purpose: `adoptNewLanes` diffs it and `knownGames` is seeded from it,
so ordering it at source would let a display preference reach the code that
decides which of a reader's games get hidden — a reordering bug would become a
game-silently-switched-off bug.
The dailies strip also groups. It was `[...chores, ...repeating]`, which put
Genshin's commissions and Genshin's own login event at opposite ends with a
dozen games between them; a game's chore and its repeating events are now
adjacent, chore first, events in the order they arrived. Collapsed that is
adjacency alone — no per-game headings, because this is the part of the page
answerable in ten seconds and a heading each would make it the tallest block on
it, pushing "next to expire" down the page.
Two things moved to where they belong. Skipping a standing chore for a lane the
reader invented is `dailyGroups`' rule, not the call site's: filtering those
lanes out in App also cost a reader's own game its place in the order its events
group under. And the expanded catch-up panel is its own exported component, so
which days it offers and whose clock they were cut on are testable rather than
trapped behind a `useState`.
`Welcome` drops its own comparator for the shared rule — the picker runs before
any stored order, so it asks for the A–Z case and cannot drift from the four
surfaces behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was the board's alone, sitting between two app-wide filters and
carrying a line of small print to explain why it was different. It is the
same events answering the same question in either view, so it is now one
switch: the board plots them, and the checklist keeps its "Not started
yet" section.
That means the section is off by default, which is a visible change for
every existing reader — deliberate, and the same argument the board makes.
This app answers what expires next; on fourteen lanes the queued patches
are more rows than the thing they came for.
`timelineUpcoming` becomes `showUpcoming`, because the old name would now
be false, and `adoptRenamed` carries a stored answer across on load.
Nothing would be lost by dropping it — `prefs` is one blob under one key,
not a key space — but a reader who had switched the future on would find
it off with no explanation, and they should not have to say a thing
twice. A stored new name always wins, so it cannot overwrite a fresher
answer with a stale one.
Gating the section alone would have opened a hole: nothing running and
everything held back rendered an empty column, because the "nothing to
show" line keyed off there being no rows at all rather than none listed.
It now counts what is held back and names the switch, as the board does.
The split pills stay the board's: the checklist gives these a section
with a heading either way, so there is nothing there to mix them into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"In their own group" is the shape of a patch — what is on now, and what
is queued behind it. "Mixed in" is the question a Gantt chart exists for:
what runs out first, whether or not it has opened. On real data that is
not a cosmetic difference — mixed, five events currently running sort
below an Arknights rerun that has not started, because it closes before
they do.
Which is why it is a pair of pills and not a checkbox: neither answer is
the absence of the other, and "mixed in" is a different order rather than
the heading switched off. It sits under the row that puts the future on
the board at all, and only while that row is ticked — a choice about
arranging unstarted events is unanswerable with none of them on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It shipped as a pill in the board's own header, beside the stacking and
scale controls. That is the wrong company: those two reshape what is
already on the board, which is why they are reached for while reading it,
and this one decides what is on it at all — the same question
"Show events I've finished" and "Show events I'm ignoring" answer, from
the panel where both of those live.
It is the only one of the three scoped to a single view, so the row says
so instead of reading as a promise about the whole app. The board loses
the count the pill carried; with nothing left running it still says how
many are waiting and names the setting, and the page header has said
"N live · N upcoming" all along.
Controls had no tests at all, so the three view filters get some: what
each checkbox is bound to is invisible in a diff and obvious only to the
reader it happens to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two halves of one reading. The board answers "how does the time I am in
lay out?", and plotting every lane's next patch stretched the window
weeks past today and squeezed the running bars down to nothing — so it
holds them back, and holds them back itself rather than being handed a
shorter list, because the control has to say *how many*. A board that
quietly withheld a third of the schedule is indistinguishable from a
quiet fortnight, which is the wrong thing for this app to imply by
accident. A board with nothing left running says so and points at the
control instead of reading as an empty calendar.
Switched on, a bar drawn right of the now rule is only implicitly in the
future, and implicitly is not a standard we hold anywhere else a date is
involved. Gacha schedules are not a smooth stream of starts either — a
patch ships six things at once — so the unit is the clump: a dashed rule
per group of starts, labelled in words. Clumps merge by distance on
screen rather than by calendar, since a week apart is one mark at six
pixels a day and two at a hundred and eight, and a merged one states the
span it covers rather than claiming a date the board is not ruling at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`prefs.theme` is dark, light or system, defaulting to dark: a reader
whose laptop is in light mode has said something about their laptop, not
about this page, and following the device would move every existing
reader the first time they loaded this. No control yet — the value can
only arrive from storage.
App resolves it and writes it to the document. It is also where a hue
meets the theme: doing that in the lane resolver means every label, chip,
rail and bar gets the readable answer without a component knowing a theme
exists, which is why the colophon moves onto that resolver too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The board's header said "One lane per game" and left it at that; now it
offers the other stacking too, in the same place and the same pill shape
the list's sort control uses — you reach for it while looking at the
board, not in settings.
Merged, there is no lane heading to say whose event a bar is, and hue
alone cannot answer that once every game shares one stack. So each bar
carries its game's short name, and its tooltip carries the full one. A
bar under 96px has no room for a tag and a title both, and a chopped
game name reads as a broken word — those keep the title and say the game
in the tooltip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Past `lg` the page is a pinned rail of instructions beside the lists, and
the focus bar was still a full-width strip above both columns: it spent
the whole width of a wide screen on a row of chips and pushed "next to
expire" — the thing the reader opened the page for — down behind it, while
sitting nowhere near the panels it narrows.
The top of the rail is still above everything it affects, and now it pins
with the deadlines and dailies it filters. On a phone, and on the
timeline, which has no rail, nothing moves: the same rule puts it back at
the top of the page. One element, rendered once per view.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One density cannot answer both questions the board is asked. A patch cycle is
six weeks, a login campaign runs for months, and 13px a day is a compromise
between "what am I in the middle of this week?" and "how do the next three
months line up?" that serves neither well.
A pair of controls steps through a ladder of day widths and the choice is
remembered, on the same argument as the view tabs: a reader who has said how
they want to read this should not say it again on the next load.
Two things it had to get right. Zooming holds the middle of the view still —
rescaling around the left edge of a three-month board throws away whatever
they had scrolled to, and re-opening at today would undo the scrolling that
got them there. And the dated ticks thin out as the scale shrinks, because a
week is 42px at the widest setting and the dates would sit on top of each
other; the gridlines stay weekly, since they carry the rhythm rather than the
reading.
The scale is stored as the measurement, not a step number, and read through
`snapDayWidth`. An export written against a different ladder then opens on
something close to what its reader chose, and a corrupt value opens on the
default rather than a board one pixel wide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding a source is our decision, not the reader's. Eleven lanes became
fourteen this week, and for someone who plays two that is not a feature
arriving — it is their calendar filling with events they will never open,
which is the thing the first-run picker exists to prevent.
`knownGames` records every lane a reader has been offered; a lane missing from
it is new to them, so it is recorded and hidden on sight. The games chips in
settings list every lane, on or off, which is where they take one up.
The case that had to be right is the reader who installed before any of this
existed: they have no `knownGames` at all, and reading that as "has been
offered nothing" would switch off every game they already read. Absent means
unrecorded — the first pass records what is already on their screen and
changes nothing else. Lanes they invented are recorded but never hidden;
typing a game in is asking for it.
The decision is a pure function so this is provable rather than watched for.
One real cost, stated in the PRD rather than hidden: a reader whose game
finally arrives is not told, which makes the colophon roadmap matter more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Ending soon" described the sort, not the job — and it sat one line above a
sort control whose own option reads "Ending soonest", so the tab and the
ordering looked like the same control said twice. Checklist is what a reader
with four games is doing with it.
The stored id stays "soon". That value is in `prefs.view` on real devices, and
a label is copy: renaming one must never move a reader to the other view.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every screen got the same 672px column, so a desktop reader read a phone page
with two thirds of the window empty — and the one design criticism the release
thread produced was formed from a screenshot.
Past lg the page splits along the line this codebase already draws: what it
tells the reader to do — the next deadlines, tonight's dailies — pins to a
rail on the left and stays there while the lists it shows them scroll beside
it. The footer and the settings panel become columns rather than a long fall
of small grey text. Below that breakpoint nothing moves: the same split puts
the instructions first in one column.
The rail's rule sits on the panel rather than the column. The panel is short
and the list beside it is long, so a full-height divider would spend most of
its length walling off a gap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reader who reported this had two games switched on, twenty-one live
events, and said the list stopped being usable — and every game added doubles
down on that. Each section now shows six rows with an explicit "show all N".
It truncates the view and nothing else. The slice comes off the front of a
list already in the order the reader chose, so the hidden rows keep their
place, stay counted in the header, and stay on the timeline. Expanding is
per-visit state: it is something you do while reading one list, not a
statement about how the app should work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>