45 Commits
Author SHA1 Message Date
connor 35632a3547 Give the site a favicon
Check / guardrails (push) Successful in 1m25s
Check / bundle (push) Successful in 1m19s
Image / image (push) Successful in 2m45s
Check / check (push) Successful in 4m49s
The tab has said "Runway" next to a blank page-shaped placeholder since M1.
Two files, both generated from the 1254px source: a multi-resolution `.ico`
at 48, 32 and 16, and a 180px apple-touch-icon.

The source has about 114px of transparent margin on each side, which at 16px
is a pixel and a half of nothing on every edge -- so it is trimmed before
being resized, and re-centred on a square canvas first, because the trimmed
artwork is 1026x1036 and every downsample from it otherwise comes out a pixel
narrower than it is tall. At 32px the plane, the swoosh and the calendar grid
all still read. At 16px the grid flattens to a pale block; that is the
artwork's level of detail rather than the resampling, and 32 is the one most
displays ask for.

They are copied rather than hashed. Trunk renames an icon asset the way it
renames the stylesheet, and these two are the only files in the bundle whose
URL is not ours to choose: Caddy serves the frontend with an `index.html`
fallback, so anything that guesses at `/favicon.ico` rather than reading the
tag -- a bookmark, a feed reader, a browser with no page loaded -- would get
the page's HTML with a 200. That renders nothing while looking like it
worked, which is the failure mode this repository keeps finding in other
places and is no more welcome here.

The apple-touch-icon is the one that is flattened onto the icon's own blue.
iOS composites a home-screen icon onto black and then applies its own corner
mask, so a rounded square that still has an alpha channel arrives with black
sitting in the corners it had already rounded off.

`assets` joins the `[watch]` list for the reason written above `[watch]`
already: Trunk rebuilds on what it is told to watch, and without it changing
an icon in development changes nothing and the browser keeps serving the
previous one.
2026-09-09 11:40:21 -04:00
connor 50cb77556d Deploy the backend, not just the frontend
Check / guardrails (push) Successful in 1m23s
Check / bundle (push) Successful in 1m14s
Image / image (push) Successful in 2m15s
Check / check (push) Successful in 1m14s
`compose up -d` decides whether to replace a container by comparing its
configuration, and the configuration is the string `runway:latest` whether or
not the image behind that tag has moved. It never moves. So every deploy since
this script was written swapped the frontend and left the backend exactly where
it was, and the first one to matter was the one just now: the sidebar's new
editor asks `/api/calendars` for `own_color`, and the backend answering it was
built three days before that field existed.

This is the third variation on one theme and the worst of them. The first
failed loudly, the second failed quietly and exited 1; this one reports
success, and what it leaves behind is precisely the state the single-image
design exists to prevent -- a frontend calling an API the running backend does
not have. `--force-recreate` is the fix.

The check after it is the part worth keeping. Nothing above it could have
caught this, because nothing failed: podman was asked to bring a service up, it
was already up, and it said so. So the last thing the script does before
recording a deploy is compare what is actually running against what it just
pulled, and refuse to write the stamp if they disagree. A run that half-happens
now leaves no record, which is what makes the next tick try again.
2026-08-31 15:03:40 -04:00
connor 227e21bc04 Run rustfmt over the new module
Three assertions past the width. Caught by CI rather than here, because I ran
the tests, clippy and the wasm target and not `cargo fmt --all --check`.
2026-08-31 15:03:39 -04:00
connor fd298f4977 Add and change what the sidebar lists
Check / guardrails (push) Successful in 1m27s
Check / bundle (push) Successful in 1m24s
Image / image (push) Successful in 2m39s
Check / check (push) Failing after 1m4s
Every one of these calls has existed in the typed client since M9 and M10 and
had nothing wired to it: create, delete and update for calendars, subscribe,
update and unsubscribe for feeds. The only field the interface ever sent was
`visible`, from the checkbox. So a person could see the calendars on their
server and hide them, and could not make one, rename one, recolour one, reorder
them, or subscribe to anything at all -- the "Subscribed" section did not even
draw unless a feed was already in the database, which no interface could put
there. M14 was scoped as "calendar list, visibility toggles" and nothing after
it picked up the rest.

It goes next to the list rather than into Settings. The sidebar already is the
list of sources; Settings is about the person reading them. A calendar's name
and colour are neither -- they belong to the calendar, they are written to the
CalDAV server, and every other client sees them.

That distinction needed one thing from the API. `color_source` was already
reported so the interface could offer "use the calendar's own colour", but the
colour itself was only ever sent after the override was applied, so an editor
had no way to know what the calendar's own colour was. One control for both
would have written somebody's private override onto the shared calendar the
first time they saved anything. `CalendarView` now carries `own_color` beside
`color`, and the editor shows them as the two different things they are.

Reordering sends only the rows that actually move. Each one is a PATCH and a
round trip to the CalDAV server, and "no position at all" is a different state
from "position 0" -- so the first move on a fresh account writes every row and
every move after it writes two. The arithmetic for that is pure and tested, as
is turning a CalDAV colour into something a colour input will accept: Apple's
`calendar-color` is `#RRGGBB` or `#RRGGBBAA`, and a control with no notion of
alpha reads the eight-digit form as garbage and silently shows black.

Deleting a calendar destroys everything in it and there is no undo underneath,
so the menu item leads into the editor with its confirmation already showing
rather than doing it from the menu. The menu offers what exists: a calendar at
the top of the list is not offered "Move up".

The browser checks in `shoot.mjs` cover the whole round trip against a real
server -- made, coloured, renamed, deleted -- and that a link which cannot work
is refused before it costs a request. They cannot run yet: the file stops at
its all-day overflow assertion, because `seed.py` hard-codes the week of
2026-08-24 and that week has now passed. That is a separate problem and older
than this change.
2026-08-31 14:54:27 -04:00
connor e1e253319e Stop the deploy succeeding without deploying
The chown from the last commit unblocked the updater, and the updater then went
on not deploying -- quietly, and now with exit 0. Three separate reasons, none
of which the script could see from where it stood.

It compared the image it had just pulled against the image it had before, which
answers "is there anything new?" rather than "is what is running current". The
failed run had already pulled, so the next run saw nothing new and stopped. It
now records the image in `dist.deployed` at the end of a run that finished, and
compares against that; a run that dies half way leaves no record and the next
one does the whole thing again.

It deployed into the wrong podman. The stack on this machine -- Caddy, Authelia
and forty others -- is root podman, and the timer was a user unit, so `podman
compose up` built a perfectly healthy backend in a rootless namespace that
Caddy has no route to, and said so approvingly. The unit is now a system unit.
That failure is worth naming because nothing local to the script can detect it:
the container starts, the command succeeds, and the only symptom is somewhere
else entirely.

And Caddy could not see the frontend anyway. A bind mount holds an inode, so
renaming `dist` out from under it leaves Caddy serving the directory that was
there when it started -- which is also why rolling back by `mv`, as the README
promised, would have done nothing at all. Caddy now mounts `web/`, the holder,
so the rename happens inside what it can see. The database stays outside that
directory, which is the reason for the extra level rather than mounting
`data/runway` itself.

One more, found by fixing the second: a container started from a `oneshot` unit
inherits that unit's cgroup, so systemd tore down the backend the moment
ExecStart returned. `restart: unless-stopped` put it back, which is precisely
what made it hard to see -- the site was fine and the unit was red.
`KillMode=process` leaves the container where podman put it.
2026-08-28 16:02:00 -04:00
connor ff52fa1a1c Say why a deploy could not happen
Check / guardrails (push) Successful in 1m19s
Check / bundle (push) Successful in 1m11s
Check / check (push) Successful in 1m4s
Image / image (push) Failing after 36s
The updater pulled the image, failed, and exited 1 with nothing in the journal
explaining itself. The cause was ordinary -- `~/data/runway` is root-owned,
left that way by v1, which deployed by `sudo rsync` over SSH and so never
needed a user to write there. Replacing the frontend swaps one directory for
another, which is a write to the directory holding them.

The permission is the smaller half of this. A deploy that fails without saying
why is worse than one that fails loudly for a stupid reason: the timer runs
every ten minutes, so it would have gone on not deploying, quietly, at a
distance from anyone reading the log. The check now runs before the pull and
prints the exact `chown` to fix it, and the requirement is written down in the
setup steps where it should have been in the first place.
2026-08-28 13:28:41 -04:00
connor 8df5860c75 Build the image on the branch that exists
Check / check (push) Successful in 1m35s
Check / guardrails (push) Successful in 1m20s
Check / bundle (push) Successful in 1m20s
Image / image (push) Successful in 2m30s
The release workflow watched `master`. The repository's branch is `main`, so
the job that builds and pushes the image would have sat there never firing,
and the first sign of it would have been a deploy that never arrived.

The check workflow runs on every push and so was never affected, which is
exactly why this needed finding on purpose rather than by noticing.
2026-08-28 13:13:30 -04:00
connor d156b4eb15 Drop two dependencies nothing was using
The first thing CI caught, on its first run, in the commit that added it.

`runway-caldav` depended on serde and never mentioned it. `runway-cli`
depended on tracing while using tracing-subscriber, which is a different
crate; the subscriber stays.

I had run `cargo machete` before committing the workflow and reported it
clean. It was not: the tool prints a paragraph of advice about ignoring false
positives *when it finds something*, and I had tailed the output, seen that
paragraph and "Done!", and cut the two findings off the top. So the check I
claimed to have made was the one thing I had not made.

Which is a decent argument for the guardrail existing. v1 shipped reqwest,
ical, regex and indexed_db_futures into a WASM bundle that used none of them,
and it shipped them because nobody was looking -- not because looking was hard.
2026-08-28 13:12:49 -04:00
connor b0da3afafc Build and ship both halves from one commit
Check / check (push) Successful in 2m29s
Check / guardrails (push) Failing after 29s
Check / bundle (push) Successful in 1m6s
The audit's other "do it differently" (F76). v1's backend was an image built by
CI; its frontend was `rsync -azX --delete` from a laptop, run by hand, with the
API's absolute URL compiled into the WASM by an environment variable set in the
same script. Two artefacts, one pipeline, and nothing keeping them in step.

Here there is one image with the server binary at `/usr/local/bin` and the
built frontend at `/srv/dist`, and the frontend asks for `/api/...` relative to
wherever it is served -- so the artefact is the same in every environment and
the two halves cannot be deployed apart. Caddy still serves the static files
from a directory, because that is what the reverse proxy in front of everything
already does; the updater lifts them out of the image rather than out of a
build on somebody's machine. It stages them and moves the directory into place,
since `index.html` names hashed files and a browser that fetches new HTML with
old JavaScript gets a blank page.

The image is 97 MB and holds no build tooling. Migrations are compiled into the
binary and run at startup, and the database is created if it is missing, so
there is no `sqlx-cli`, no entrypoint script, and nothing that can decide to
carry on after a failed migration -- v1's start.sh ran migrations with
`|| echo "Migration failed but continuing..."`. TLS is rustls with its roots
compiled in, so there is no OpenSSL to keep patched. There is no dummy-source
dance for dependency caching either; a BuildKit cache mount does what that
trick was inventing, and the binary is copied out of the mount because a cache
mount is not part of the layer.

Deployment is a timer on the server rather than CI reaching into it. Nothing in
the workflow holds a credential for the machine it deploys to, and a bad build
cannot take the site down on its own; the cost is a few minutes between push
and deploy, and `deploy/runway-update` for when that is too long.

The rest of this is the CI that was promised at M1 and never written.

"Rules that only live in a doc get forgotten, so these are lints and CI checks."
The lints landed. The CI half did not exist, which meant for twenty-seven
milestones the forgetting was still perfectly possible -- it was just mine
rather than the repository's. `cargo fmt`, `clippy -D warnings` and the whole
test suite now run on every push, alongside the three guardrails that were only
ever configuration:

`cargo machete` passes. `deny.toml`, written at M1, did not: three permissive
licences its allow-list had not anticipated, and two `unmaintained` advisories
arriving through Leptos's macros. Both are now allowed by name with a date and
a reason rather than by widening a category. MPL-2.0 came out of the list,
because nothing uses it and an allow-list should say what is actually there.
That file had been quietly wrong since the day it was written, which is the
argument for CI in one line.

The bundle budget is 1.8 MB against today's 1.31 MB and v1's 2.5 MB, printed on
every run. A bundle grows one convenient dependency at a time.

The print rule is deliberately not in CI: it already exists as a test, it
covers the CalDAV client too, and a shell grep cannot tell a call from a comment
about a call -- the first draft of that step failed on the paragraph in
`observability.rs` that explains the rule.

Two things the image found that reading could not.

`/db` is created in the image now. SQLite creates the database file if it is
missing but not the directory holding it, so the container started only when
something happened to be mounted there and otherwise died with "unable to open
database file", which says nothing about what is wrong.

And the graceful shutdown only listened for Ctrl-C. A container runtime stops a
service with SIGTERM, waits ten seconds, and sends SIGKILL -- so `podman stop`
took ten seconds and killed the process outright, and the handler written to
stop a restart dropping a CalDAV write half-way through worked everywhere
except deployment, which is the one place restarts happen. It listens for both
now, and the container stops in two.
2026-08-28 12:47:53 -04:00
connor 907a0f2b01 Let a shape change more than the corners
The shape axis shipped as four radii, a border width and three shadows, and
turning the dial barely moved the app. Two reasons, and the second is the
embarrassing one.

The first is that I had drawn the boundary in the wrong place. Keeping shape out
of density's business is right -- it is what makes three of each six blocks
rather than nine -- but "not a size" is a much weaker constraint than the one I
had actually been enforcing, which was closer to "not anything". Density owns
sizes and distances. The palette owns colours. Everything else about how this
looks was sitting unclaimed.

So a shape now owns the typeface and the weights, whether small labels are set
in capitals and how far they are tracked, whether a panel has an edge or floats
on a shadow and which surface it sits on, and -- the one that matters -- how an
event is drawn. `--event-wash` is how much of a calendar's colour fills the box
and `--event-rule` is how much of it is kept as a rule down the leading edge, so
a day can read as a list of things with a colour attached rather than as a stack
of coloured bars. That is the character of the whole week, and it was not
previously anybody's to change.

The second reason is that `--border-width` was a token almost nothing read.
Seventy-nine elements take their edge from Tailwind's `border` utility, which
sets a flat 1px, so the axis that is supposed to own edges was being overruled
by a utility class on nearly every element that has one. Tailwind's utilities
live in `@layer utilities` and unlayered declarations beat layered ones whatever
the order, so five rules at the top of the stylesheet reconcile the two. Every
border in this app is one of ours; there is no third-party markup here to
surprise.

Four shapes, then, and they are meant to be told apart across a room. Flat is
square -- including the things that are usually round, because squaring the
boxes and leaving today's date in a circle is not committing to anything --
with heavier weights, wider tracking and nothing raised. Soft is rounded, set in
a rounded face, and its panels give up their edge entirely to be held off the
page by shadow alone. Quiet washes the events and keeps the colour as a rule.

`--font-ui` moved from density to shape, where it always belonged: a typeface is
not a size. It had been sitting in the density blocks since M12 because it feels
like it goes with the type sizes, which is exactly the kind of drift that ends
in two axes moving the same thing.

Three tests hold the line. One rejects any name declared by both a shape and a
density -- a token owned by two axes has a value that depends on which block the
browser read last. One rejects a shape that differs from the default only in
radii, because that is the state this commit is fixing and nothing else would
have caught it. The browser check reads the four shapes off real elements rather
than off the tokens, which is the only way to notice a token that nothing is
reading: it reports what actually moved, and it is the check that would have
said "border width: 1px, 1px, 1px, 1px" months ago if it had existed.

The weekday abbreviation got a testid, because the first version of that check
probed the hour marks -- which are numerals, have no case to change, and
reported "none" in every shape while proving nothing.

Two things a shape still may not touch. Colours are the palette's: a shape picks
which role is used, never a value. And the ring that separates two touching
events of the same calendar is not a matter of taste, so every shape keeps it --
`--event-lift` is a shape's to give, the seam underneath it is not.
2026-08-28 11:02:18 -04:00
connor e98d79b67a Make a palette seven numbers
v1 shipped twelve themes and three layout styles as thirty-six hand-maintained
stylesheets: 8,300 lines of CSS, 553 custom properties, 116 `!important`
declarations. Reading them now, nine of the twelve themes set nothing but an
accent colour and a gradient, and four of those -- nord, dracula, midnight,
charcoal -- put a dark accent on light surfaces, because no combination of the
thirty-six was ever looked at. The last fifteen commits in that repository were
tweaks to this.

So a palette here declares seven numbers and no colours. Two hues (schemes that
are a different colour by night than by day are a real thing: Solarized is cream
in the light and deep teal in the dark), two tints, an accent hue and chroma, and
how far from white or black its paper sits. One derivation turns those into all
forty-odd tokens, down a ladder every palette shares.

That last part is the point rather than a side effect. Hue and chroma barely move
contrast; lightness is nearly all of it, and no palette gets to choose its
lightness. Legibility stops being something each palette gets right or wrong and
becomes a property of the system -- which is what makes eight of them cost less
than v1's twelve, and what makes a ninth safe to add.

Both polarities live in one declaration each, via `light-dark()`. "Follow the
operating system" is then not a theme at all: it is the pair left unresolved and
`color-scheme: light dark` handing the question to the browser. No media query,
no second copy of forty names. `color-scheme` inherits and the derivation is
selected on the attribute, so a *part* of the page can be in a different palette
and polarity from the page around it -- which is how the print sheet already
worked and now costs nothing.

Four axes where there were two, because each of v1's was two questions wearing
one name. Its theme list had "Nord" next to "follow the operating system", which
are not the same kind of thing; its style list moved corner radii and border
widths together with spacing and type sizes, so wanting a tighter calendar also
meant squarer corners. Now: palette x mode, shape x density. Shape owns corners,
edges and depth; density owns spacing, type and the measurements a calendar is
built from. They share no tokens, so three of each is six blocks to maintain
rather than nine.

Four axes is only fewer things to maintain than two while they stay independent,
and that is exactly the property that rots quietly. A test rejects any rule in
the stylesheet selected on two of them at once, at any nesting depth, and another
rejects a palette that declares anything but its seven seeds -- a palette writing
a colour of its own is one opting out of the ladder. A third checks the names in
the pickers against the blocks in the file, both directions, so a palette that
does nothing and a block nobody can reach are both build failures.

A palette of your own is four of those seven numbers, written onto `<html>` as an
inline style. No new CSS: it is the same derivation reading numbers that arrived
from a preference instead of from a rule. Lightness is deliberately not among
them, so there is nothing to drag it to that cannot be read.

The bounds on those four are not decoration. The browser check sweeps all sixteen
palette-and-polarity pairs and all four corners of the custom sliders, and it
caught the first accent ceiling at 4.25:1 -- a very saturated hue gains luminance
at a fixed lightness without gaining any of the contrast that buys. The ceiling
is now exactly the loudest shipped palette. It also caught two rungs of the ladder
that Nord's lifted paper had pushed under the floor, which is what `--lift` costs
and why the faint end sits lower than it would need to on paper-white.

`--danger` and its friends are the one part a palette has no say in: a red that
means "this deletes something" stops meaning it the moment it is allowed to be
the palette's idea of red.

The sheet takes a palette and a polarity separately now, which makes the common
thing sayable. "Print this lighter" is a mode; before, it could only be said by
printing in somebody else's colours.

`theme` and `style` became `palette`, `mode`, `shape` and `density` in the
preferences, with the custom palette's four numbers as four typed columns rather
than a JSON document -- v1 kept colours, visibility and a theme name in one blob
and could not constrain any of them. The polarity is checked by the schema and
the palette is not, on purpose: there will never be a third polarity, so a third
value means the data is wrong, while palettes are a list that is expected to
change and somebody whose favourite was retired should get a readable calendar
rather than a row the database refuses to hand back.
2026-08-28 10:23:24 -04:00
connor 19dbb60ad1 Print in the theme the calendar is read in
Paper was white in every theme, on the reasoning that a dark scheme is a
choice about a screen in a room. That is a reason to offer white, not to
impose it: a week printed in a scheme you have never seen is a different
document from the one you were just looking at.

So the sheet carries `data-theme` itself. Every theme in the stylesheet is an
attribute selector rather than something only `<html>` can wear, so this cost
one attribute and no new tokens, and the sheet can be in a different theme from
the app around it — which is what "print this one lighter" means. The picker
sits with the paper and the orientation, starts at whichever theme is being
read in, and changes the sheet alone: it is a decision about one piece of
paper, not about the calendar somebody goes back to afterwards.

Unset rather than copied, for the same reason the hour band is: this page can
be opened cold, and the stored preferences arrive after the first paint. A
sheet that took a copy of the theme when it mounted would sit in the default
one for ever. The browser check now reloads the print page before looking, so
that path is the one being tested.

`--event-ground` is declared again on the sheet. It is `var(--surface)` written
at the root, and a custom property resolves its `var()` where it is declared —
so what reaches the tree is the *root's* surface, and a sheet in a different
theme from the app was ringing its events in the app's colour.

What was "ink: colour or saver" is now three: as on screen, on white, and ink
saver. The middle one is the old behaviour — the theme's accent and the
calendars' own colours, with the ground given up — and it is there for when the
printer has an opinion the screen does not.

Display styles will want the same treatment when there are some worth picking
between. The mechanism is already here: `data-style` is an attribute selector
too.
2026-08-28 00:16:22 -04:00
connor 410eefc5a3 Print a calendar the size of the paper
The audit's one "the implementation is bad and I need this anyway". v1's print
preview was 617 lines of Rust and 1,320 of CSS; it cloned the DOM into a hidden
node, read `client_height` back off the clone, mutated inline styles on the
result, and threaded `print_mode`, `print_pixels_per_hour` and
`print_start_hour` into the interactive week view so the modal could borrow it.
The last fifteen commits in that repository were tweaks to it.

Printing is a route here. `/print/:view/:date` draws the same components,
fetched the same way, inside a box the size of the paper -- and that is the
whole mechanism, because the views are grids of fractions and already fill
whatever they are given. "Fills the page and nothing more" is a width and a
height. Nothing measures anything.

`paper.rs` is the table: what a sheet is, which way round to hold it for a
view, how twelve months divide, and which hours are worth ruling. The `@page`
rule the browser is handed and the box drawn on screen are formatted from the
same two numbers, so the preview and the paper cannot disagree about the size.
`size` is the reason any of this has to be generated rather than declared: it
exists only in an `@page` rule, which cannot be selected by an attribute or
read out of a custom property.

Three sizes a component used to write inline became custom properties instead
-- the ruler's height, the month's row template, the year's month grid.
An inline declaration beats every rule in the stylesheet, and paper has to be
able to disagree. The values still come from the components; only the shape of
the box they go in is now negotiable.

The band of hours is a window onto the same twenty-four-hour ruler rather than
a second one built for print: the same grid, in a box taller than the space it
is seen through, slid up by the hours being skipped. Both numbers are
proportions, so the arithmetic is the browser's. Where the band starts is
suggested by what is in the range -- the hours the events actually occupy,
widened downwards to at least six -- and then left alone, because somebody who
knows about the six o'clock start should be able to say so without it being
moved back under them.

The year is the view that needed the page the most, and the one whose printing
was in doubt when it was built. On screen it fits as many months across as the
window allows and scrolls; on paper it is four by three landscape or three by
four portrait, every month filling its cell, all twelve on the sheet.

Paper is white in every theme. A dark theme is a choice about a screen in a
room and printing it is neither what it is for nor what a printer does well;
what carries over is the accent -- today is still tinted with it -- and the
calendars' own colours, with a legend across the top saying which is which.
Ink saver takes the fills out and leaves a rule of colour down the edge of each
event, which is enough to tell two calendars apart for almost no toner.

The sheet does not answer a pointer. The views on it are the real ones and
their chips still carry every handler they have, including the one that starts
a drag -- which would move an event on the server while showing nothing,
because the preview that follows a drag is not on that page. One declaration,
rather than a print-only variant of every view.

Fetching moved out of the shell into `load::in_range`, so the printed calendar
and the one on screen ask the server the same question rather than two that
have to be kept in step.

The browser checks measure the sheet against Letter and A4 both ways round,
read the page rule back, count the hour labels actually on the page for a
07:00-19:00 band, count the year at both shapes with nothing hanging off the
edge, and print five PDFs with `preferCSSPageSize`: one page each at the right
MediaBox, and an agenda that runs to four, because a list that does not fit is
not a list with its last days cut off.

One thing paper cannot promise: a month row is a fixed height, so a day
holding more than the row can show is cut at the row's edge. The "+N more"
count knows what the layout dropped and not what the page did.
2026-08-27 21:30:30 -04:00
connor 0693ce0e43 Say a reminder while somebody is looking
The alarms that matter were already being written to the server, which is the
half that counts: a phone reads them at six in the morning with this tab long
closed. Two things were wrong with the writing, and the telling was missing.

`RELATED` was being dropped. The alarm row read the offset out of the trigger
and wrote a new one back with `related: Start` hard-coded, so an alarm made
elsewhere as "ten minutes before it ends" became ten minutes before the start
the first time this app touched the event -- the exact v1 bug the model was
given the parameter to prevent. It is a control now, beside the offset, and
changing either keeps the other. The offsets also cover "when it starts" and
"fifteen minutes after", which are things people set.

`alarms::due` is the arithmetic, and it is a pure function of the events on
screen and the time: only `DISPLAY` alarms, because acting on an `EMAIL` or an
`AUDIO` one is not a browser tab's business; relative triggers off whichever
end they name; absolute ones converted through the difference between the two
spellings of the occurrence's own start, so no zone database is needed for it.
A five-minute window decides what is late enough to skip -- a page opened at
noon should not recite the whole morning, and one that was behind another
window for four minutes should not lose what came due while it was there.

The telling is one effect on the clock that was already ticking for the
now-line, and one set in memory of what has been said. Either the browser says
it, tagged so a second tab replaces the notice rather than stacking beside it,
or the page does, in a note that can be dismissed -- never both, because the
granted one arrives when the tab is behind something else, which is when a
reminder is worth having. v1 spent a 2,008-line component polling every thirty
seconds, a `localStorage` ledger that grew without bound, and a service worker
that could not read that ledger and so did nothing but ping the tab that was
already polling.

Permission is asked from settings, on a click. A prompt fired at a page nobody
has looked at yet is one they dismiss without reading, and a dismissed prompt
is indistinguishable from here from a refusal. The panel re-reads where it
stands each time it opens, since that is the browser's to change from a
control this page does not own.

Chips that carry any alarm say so with a small mark -- any action, not only
the ones this browser would act on, because the mark means "somebody will be
told" and the phone reading the same calendar is somebody.

The browser checks cover both channels: a reminder due three minutes ago
arriving on the page and being dismissed and not coming back on the next tick,
a trigger hung off the end surviving a round trip through CalDAV, and, with
the notification API standing in for the real one, the same reminder going to
the browser instead and nothing being said twice.
2026-08-27 20:26:39 -04:00
connor 4fafed742c Move, resize and draw out events with the pointer
Pointer Events rather than mouse events, so one code path covers a mouse, a
trackpad, a pen and a finger, and `setPointerCapture` so a drag carried into
Wednesday keeps being delivered to the column it began in. v1 used mouse
events with `e.layer_y()` and an `e.offset_y()` fallback repeated at seven
call sites, and `pixels_per_hour = if time_increment == 15 { 120.0 } else
{ 60.0 }`.

The pointer's pixels become grid units once, against geometry read from the
page when the drag begins, and everything after that is arithmetic on times.
The same call decides what the preview shows and what is saved, so they cannot
disagree -- there is only one of them. Nothing recomputes the grid: one
absolutely positioned element is repositioned per pointer move, using the same
percentages every chip is placed with.

Dragging an occurrence of a repeating event asks which occurrences it applies
to, because it is the same edit the form makes, arriving a different way. The
sending moved to `crate::write` so both use it rather than one reaching into
the other. A new event drawn out on empty grid opens the form over that span:
it has no title yet, and an untitled event on somebody's calendar is worse
than one more step.

Three things the browser found that reading could not:

  the now-line's wrapper spans its whole column, so it was swallowing every
  pointer that landed on today;

  `offsetY` is relative to whatever was pressed, and a press on a resize edge
  lands on the edge, where every offset is nearly zero -- so every grab read
  as "the top";

  the chip's pointerup bubbled to the column, which ended the same drag a
  second time and cleared the record that it had happened, so the click that
  follows a drag opened the event that had just been moved.

The seed now empties each calendar before filling it, including the one Baikal
makes for a new account, and `dev.sh shoot` reseeds. The checks create, move
and delete real events; a run starting from whatever the last one left behind
was measuring drift as often as behaviour.
2026-08-27 17:01:12 -04:00
connor af34ca7e83 Ask which occurrences an edit applies to
Saving or deleting an occurrence of a repeating event now asks: this one,
this and the ones after it, or all of them. v1 dispatched the same three
choices on string literals -- "delete_this", "this_and_future",
"all_in_series" -- in fifty-three places, where a typo was a runtime
fallthrough onto the wrong branch rather than a compile error.

The interesting part is that the three scopes want the event in different
frames, and the form only holds one. It opens on the occurrence that was
clicked -- somebody who clicked the eighth of September expects to see the
eighth of September, not the Tuesday in March the series started on -- so:

  this one, and this and following, send the occurrence with the series'
  pattern stripped off it, because an override carrying an RRULE would be a
  second series inside one resource;

  all of them sends the master, with the move made to this occurrence
  measured and applied to where the series begins. Making the eighth of
  September an hour later makes every occurrence an hour later; it does not
  drag the series' start to September.

A question with one answer is not asked. An override is already one
occurrence pulled out of its series, so "this and following" and "all of
them" would each be a way of throwing it away without saying so -- it saves
and deletes as itself, and the form says why.

Editing the whole series carries an override along rather than overwriting
it, which the browser check now asserts: after changing all of them, the one
that had been changed on its own is the one still saying what it said.
2026-08-27 14:26:33 -04:00
connor eea7c666ce Add the recurrence editor
The controls emit an RRULE string. v1 sent recurrence as four separate fields
-- a frequency word, a seven-long Vec<bool>, an interval and an end -- for the
backend to reassemble, and the monthly options it drew a whole UI for
(monthly_by_day, monthly_by_monthday, yearly_by_month) were dropped on the way
to the API and never reached a calendar at all. Those are the two cases the
browser check now writes to Baikal and reads back.

Writing and reading the rule are one module with one test suite, because the
only thing that matters is that they agree: a rule opened in the form and
saved untouched has to reach the server as the same rule, and that is the
assertion.

What it refuses is the point. RRULE is far larger than any reasonable set of
controls, so a rule carrying BYSETPOS, several BYMONTHDAYs or a BYWEEKNO does
not parse into something editable -- it is shown as itself, said so, and
written back unchanged. Parsing it into an approximation and saving the
approximation would silently delete the part that decides when somebody's
meeting happens, which is what v1 did by another route.

The tab shows the rule in English underneath, rendered from the RRULE that
will actually be written rather than from the controls. Anything the writer
and the reader disagree about therefore shows up as raw text instead of as a
confident sentence.
2026-08-27 13:11:54 -04:00
connor 49b685f9b1 Add the event form
Tabbed, and driven by the VEvent itself. v1 had EventCreationData -- a form
model carrying its own copies of EventStatus and EventClass -- converted to
the wire format through a twenty-two element tuple that serialised enums with
format!("{:?}").to_uppercase(). Fields were lost at every hop between the four
representations, which is how the monthly recurrence options came to exist in
the form and never reach the API at all. Here the signal holds a VEvent, every
control writes part of that one value, and what is sent is what was held.

Reading dates back out of it is the only part with anything to get wrong, so
that is a module of its own with tests: an all-day event's stored DTEND is the
day after it finishes, and a form showing that would ask somebody to type a
date the event does not happen on. Moving the start carries the end with it,
because typing a new day does not make a meeting longer.

A right-click on empty grid offers a new event at the hour it was aimed at,
snapped down to the chosen granularity -- being given 09:45 for a click below
the ten o'clock line reads as having missed. Editing opens from the menu or
from the detail panel, and deleting takes two presses because there is behind
it no undo.

What the form will not open on is an occurrence that has been changed away
from the rest of its series: that is addressed by RECURRENCE-ID and written
with a scope, and sending its VEvent as the series master would overwrite
every other occurrence with this one's copy. A repeating event it will open,
and says so above the fields -- saving changes all of it, and the dates shown
are the series' own rather than the occurrence that was clicked.
2026-08-27 12:33:30 -04:00
connor b28ef7013f Close the settings panel by clicking away from it
It was a `details` element, which only closes when its own summary is clicked
-- so a panel opened by accident had to be dismissed by finding that button
again. Everything else that opens over the page closes by clicking away or on
Escape, and a control that behaves differently from its neighbours is one the
hand has to think about.

The toggle sits above the backdrop rather than under it. Under it, a click on
the button would be caught by the sheet that means "not this", closing the
panel, and then reopen it on the way back up.
2026-08-27 11:42:00 -04:00
connor c36d52ce25 Add the detail panel and the context menus
Clicking an event shows everything it holds -- which is a lot more than v1
ever showed, because v1's wire format flattened attendees and categories to
comma-separated strings and dropped the rest of the RFC on the way out. Here
the whole VEvent arrives intact, so the panel shows all of it, and only the
parts that are there: a panel of empty rows is a panel nobody reads.

That meant saying RFC 5545 out loud. FREQ=MONTHLY;BYDAY=1MO and
TRIGGER;RELATED=END:-PT1H are exact and unreadable, so there is a module that
turns them into English -- and it says only what it can say. A rule with a
part it does not understand comes back as the rule itself rather than as a
confident sentence with the deciding half missing; paraphrasing BYSETPOS into
silence would misdescribe when somebody's meeting actually happens, which is
the same move as v1's title-matching deduplicator.

Right-clicking an event or a day opens a menu, positioned by arithmetic on
sizes the stylesheet guarantees rather than by rendering it and measuring --
measuring means a frame in the wrong place and imperative DOM surgery inside
a declarative framework, which is how v1's print preview reached 617 lines.
The tokens it reads are registered with @property so their computed values
come back as lengths; an ordinary custom property reads as the string "13rem",
which is not a number.

The menus offer what exists: open the details, go to that day, that week,
that month. Editing, duplicating and deleting arrive with the milestones that
build them. An item that greys itself out is a promise the interface cannot
keep, and v1 shipped a recurrence form full of fields that reached no API.

What is open lives in one place, offered through context rather than passed
down: the thing that opens a menu is a chip several components deep, and the
ones in between have no business carrying a callback they never call.
2026-08-27 11:19:37 -04:00
connor 32598427cd Add the now-line, and make the overflow indicators go somewhere
The now-line is the last of the view features v1 had and this did not. It is
drawn on today's column only, over the events rather than under them -- a line
visible only when nothing is happening is a line for the hours nobody needs
it -- with the clock itself in the gutter, which is the part that says what
time and the part v1 never had. It moves on a thirty-second interval; v1
re-rendered its entire 1,431-line week view every five seconds to move the
same line.

Which meant answering what time it is, and that is not the same question as
what time it is where the browser is. The zone a calendar is drawn in is a
preference, so somebody reading a London calendar from Denver was being shown
a line seven hours out and the wrong column highlighted as today. Both now and
today are asked of Intl.DateTimeFormat in the chosen zone; the browser has a
zone database and the WASM bundle does not. Today is a memo over the clock, so
a tick moves the line and redraws nothing else.

The all-day strip caps its lanes at four, so what did not fit now opens rather
than only being counted, and the strip sits on the band the themes have been
declaring for it. In the month grid the day number and the "+N more" both go
to that day, as does a week-view day heading: a cell has no room for twenty
events and the day view has nothing but.

The browser check waits for the line to move. Comparing it against the clock
proves it is in the right place, which a line frozen at page load also would
be for the first minute.
2026-08-27 10:09:10 -04:00
connor 8137557490 Add the agenda and year views
Neither existed in v1, so there was nothing to be inspired by and nothing to
avoid -- only the question of what each is for.

An agenda is a list of what is happening, so days with nothing on them are
left out, and a multi-day event is listed on every day it covers: a Wednesday
in the middle of a conference is not a free Wednesday. What is not wholly
inside a day says so rather than inventing times for itself -- "until 2:00",
"from 22:00", "all day" for a day in the middle of something longer. An
evening ending at midnight ends at 24:00, because 19:30 - 0:00 reads as
running backwards.

A year answers two questions -- when was it busy, and take me there. Every
day is shaded by how much is on it, counted across all the days a week-long
event covers rather than only the day it starts, and measured against the
busiest day of that year so a quiet calendar and a full one both read. Every
day and every month name is a button.

The shading runs between two ends rather than up from nothing: the faint end
has to be visible and the dark end has to keep the number on it readable.
Every shade moves a cell towards the middle of the lightness range, which is
where both a light and a dark number run out of contrast, so the ceiling was
measured in a browser per theme rather than reasoned about. The check now
holds every year cell to the readable floor for small text in all three.
2026-08-27 09:40:03 -04:00
connor a38b8e185a Outline event boxes so touching ones stay distinct
The cascade draws one event partly over another on purpose, and two events
from the same calendar are the same colour, so the seam between them was
invisible. Two events merely adjacent in time had the same problem.

Every occurrence box now carries two rims. The border is the fill mixed
towards the page's text colour, so it darkens a fill in a light theme and
lightens one in a dark theme without either colour being named -- it works
for a calendar colour nobody has picked yet. Outside it, a ring the colour
of the ground the boxes sit on, which is what a box laid over another cuts
out of it. The ring is a box-shadow rather than an outline so it is painted
rather than merely reserved, and takes no space.

--event-tint was declared by three themes and read by nothing; --event-edge
now means how far the edge is mixed rather than a lightness nobody used.
A line of month content grew by its own outline, so --day-line grew with it.

The browser check measures both rims as contrast ratios against the fill
they sit on, in every theme, by painting each computed colour and reading
the pixel back -- the mix resolves to oklab, which no amount of parsing rgb
strings would have caught.
2026-08-27 09:08:53 -04:00
connor ce6a72f604 Draw slightly-overlapping events over each other, not beside
Three cases, not two. Two events that do not overlap each take the whole
width. Two that start together must be split, because whatever went on
top would cover the other's title. But two that overlap while starting
far enough apart need neither: the later can be drawn over the earlier,
shifted right, with both nearly full width. Splitting those made a day of
staggered meetings a row of slivers when it could be a legible cascade.

"Far enough apart" is a pixel question wearing a duration's clothes:
enough vertical distance for one line of text. The view answers it with
one grid increment, which works because the stylesheet keeps an increment
at least --slot-min tall and --slot-min above --event-min, one line. So
the packer needs no measurements and the rule explains itself -- the grid
you chose is the resolution you work at, and two things closer together
than one line of it are simultaneous. It follows the setting, too: at a
sixty-minute grid a pair half an hour apart really are simultaneous and
get split, and at five minutes they do not.

Depth is the longest chain a span sits on top of, not a tally of what it
covers. Two spans can each cover the same one thing and still cover each
other; counting gives them the same depth, so the same position, so the
later hides the earlier entirely. Taking the longest chain makes depth
strictly increase along every cascade, which makes it a drawing order as
well as a position. The shift is then divided by the deepest chain in the
pile rather than being a fixed step, so a pile eight deep still fits and
nothing is squeezed to nothing.

The claim the whole thing rests on is checked against what the browser
drew rather than trusted: at every granularity and every density, no
event's title is covered by another. The seed carries one of each case so
a regression that collapsed them would fail rather than pass quietly.
2026-08-27 00:02:47 -04:00
connor 99686caa08 Place overlapping events side by side
An algorithm with edges, so it lives in the core crate away from any view
and is tested against a timetable rather than a browser. v1 wrote it
inline in a week-view component, where two of those edges were wrong and
neither could be checked without building and squinting.

Piles are transitive, not pairwise. v1 gathered the events one event
directly overlapped, which is not a pile: 09:00-10:00, 09:30-10:30 and
10:00-11:00 are one, but the ends do not touch each other, so each end
was told the pile was two columns wide while the middle was told three.
Columns drawn at a half then overlapped columns drawn at a third, and the
drawing disagreed with itself about how wide it was.

Events widen into the room beside them. Left at a third of the width, a
meeting with a free hour either side reports the shape of the pile rather
than its own.

Packing runs per day column, so a crowded Tuesday does not narrow
Wednesday, and each half of a midnight-crossing event competes only with
what is actually beside it.

A zero-length event is a real thing to put on a calendar and the case a
half-open overlap test quietly gets wrong -- nothing can be strictly
inside an instant, so it would draw on top of whatever it lands in and
hide both. It is treated as the point it names.

The property the module exists for is asserted over a deliberately
awkward day -- nested, chained, identical, back-to-back, instants, and a
long one across it all -- rather than over shapes chosen to pass: nothing
that overlaps in time is ever drawn over anything else, and nothing runs
past the edge.

Chips wrap onto a second line only when there is a second line to land
on. Half a line showing through the bottom reads as broken where an
ellipsis reads as deliberate, and a chip narrowed to a third of its
column is exactly where that bites. The gap between neighbours comes out
of the width: an absolutely positioned box with both left and width set
ignores margin-right, so a margin there looks right in the source and
does nothing on the screen.
2026-08-26 22:58:01 -04:00
connor 25fe9beb1c Size the smallest gradation for landing a drag on
Not for merely rendering. Picking a fine grid and then being unable to
hit it is the worse of the two failures, so the floor is a gradation you
can put a pointer on and a five-minute grid is allowed to make the day
very tall indeed. That is a fair price for asking for one.

A quarter-hour grid now gives each gradation its own band rather than
sharing the height a whole hour had before.
2026-08-26 22:28:22 -04:00
connor 8917786220 Make the grid granularity mean something, and stop shading weekends
The Grid picker saved a value nothing read. It now rules the hour: six
choices, six rulings, hour boundaries drawn heavier than the gradations
between them. A menu of six entries where two of them look identical is a
menu of two entries with four lies in it.

A fine ruling grows the ruler rather than packing twelve lines into an
hour nobody can read. `--slot-min` is the smallest gradation still worth
drawing, so a five-minute grid gives each hour twelve legible rows and a
sixty-minute one leaves the density setting alone. Granularities that do
not divide the hour round to ones that do, so no row is ever a different
height from its neighbours.

Saturday and Sunday are no longer tinted. Which days somebody works is
not something a calendar knows, and shading two of them asserts an answer
to a question nobody asked it.

The tint that remains is drawn before the hour lines rather than after.
Painted after, it hid them: today's column, and both weekend columns, had
no hours ruled on them at all, which is not a column of a time grid.
2026-08-26 22:22:48 -04:00
connor 41cbd50755 Draw the week and day views on one hour ruler
One component behind both: a day view is a week view with one column. v1
had two, of 1,431 and 900-odd lines, which drifted apart until a fix in
one had to be found again in the other.

The geometry is fractions of a day, produced by a module tested without a
browser and turned into percentages of a column that is 24 hour-tokens
tall. So an event's position follows the density tokens, survives any
window size, and can be checked without looking at anything.

The ruler is wall-clock: twenty-four rows every day, including the ones
that are really twenty-three or twenty-five hours long. On a
spring-forward day an 01:00-04:00 meeting covers three rows and lasts two
hours, and that is the honest drawing — the labels down the side say
01:00 and 04:00, and those are the times on the invitation.

An event crossing midnight is drawn on both days, clipped to each, with
the cut end left square so it reads as continuing rather than as two
unrelated meetings. v1 drew it once and let it run off the bottom of the
column into nothing.

Long timed events go in the strip above the ruler rather than on it. A
three-day conference drawn as three full-height columns would bury every
meeting underneath and say nothing a bar across the top does not say
better. A 22:00-to-02:00 event is not one of these: it stays on the
ruler, where its hours are the point.

The grid opens at the working day, or earlier if something is. Opening at
midnight means eight rows of nothing above the first thing anybody wants
to see; opening at the first event lets last night's spillover decide
where every morning starts.

Occurrences are now positioned in the viewer's zone. An occurrence keeps
the calendar's own spelling of its times, because that is what an edit
sends back as RECURRENCE-ID -- so a 09:00 London meeting was being drawn
at 09:00 on an Eastern grid, seven hours out. The server resolves the
grid coordinates, since it has a zone database and the browser should not
need to carry one. The month view was reading the same wrong field.

The three things a view draws with -- the occurrence, where it sits, and
the colour of the calendar it came from -- now travel as one value
instead of parallel vectors zipped back together at each call site. That
works until the third one, and then stops working quietly.

Bar packing is shared with the month grid rather than written twice. The
interesting part, a bar that starts before the row and ends after it, is
the part that gets written twice and fixed once.
2026-08-26 22:01:20 -04:00
connor df7562e333 Draw the month view
Each week is one CSS grid. Row 1 holds the day numbers, the next rows
hold multi-day bars placed with `grid-column: a / span b`, and the
remainder holds that day's timed events. There is no pixel arithmetic
anywhere: the spans and lane numbers come from a pure layout module that
is tested without a browser, and every measurement is a density token.

v1 could not draw an event across two days in its month view at all, and
its week view spent 1,431 lines computing offsets that could only be
checked by building, deploying and squinting.

Two exclusive ends get undone before anything is drawn. An all-day
event's DTEND is the day after it finishes, and a timed event ending at
midnight finishes on the previous day; treating either as reaching one
day further is the classic month-grid bug.

Rows are sized from what they draw rather than from a shared worst case.
A month with four empty weeks and two busy ones used to scroll, so you
scrolled past the emptiness to reach the part with anything in it. Each
row now asks for its own lanes, entries and "+N more" line, and `1fr`
above that floor shares out whatever room is left.

Trunk was not watching styles/main.css. Only the generated stylesheet is
linked from index.html, so Trunk never learned the source was a source:
editing it changed nothing and the browser kept serving the previous
build. A very quiet way to lose an hour.
2026-08-26 21:24:09 -04:00
connor 4cf22bbe4d Let the dev frontend bind to a chosen address
HOST=0.0.0.0 makes the app reachable from another machine. Only the
frontend moves: Trunk proxies /api from the server side, so the backend
stays on the loopback and never needs exposing.

Prints the reachable addresses from the routing table rather than
`hostname -I`, which is empty on some systems, and skips the container
bridges, which are not useful to anyone.
2026-08-26 20:56:46 -04:00
connor ba90a30e41 Add the app shell: sidebar, header, and routing
The view and the focused date live in the URL. That makes the back button
work and a week shareable, and gives "which week am I looking at" exactly
one answer -- v1 kept the selected date in localStorage *and* in the
database with no defined source of truth between them.

Date arithmetic is a pure module with its own tests, run natively rather
than in a browser. v1 did this inline in a 1,431-line week view where the
only way to check it was to build, deploy and look. Thirteen tests,
including that a month covers whole weeks and needs six rows when the
month does -- v1 shipped "Fix print preview to display all 6 rows for
6-week months" -- and that stepping a month from the 31st lands on the
last day of a shorter one instead of skipping February.

The sidebar shows each calendar in its own colour, read from the CalDAV
server rather than hashed from its path, and hiding one narrows the
time-range query rather than filtering in the browser.

Two things the browser loop found. chrono::Local traps on wasm without the
wasmbind feature, which surfaces as a bare "unreachable" with no message
in a release build. And toggling a calendar's visibility silently
reordered the sidebar, because the insert wrote position 0 -- position is
nullable now, for the same reason colour is: "never arranged" is a
different statement from "first".

e2e/dev.sh brings the whole stack up seeded with a week of events. It
exists partly to record that trunk serve owns dist/: running trunk build
against it concurrently rewrites the files without rewriting the integrity
hashes in the served index.html, and the browser then blocks its own
scripts, which looks like a blank page and is nothing of the sort.
2026-08-26 20:48:56 -04:00
connor 6eee422421 Add the typed API client and the stores behind it
One client covering every endpoint, returning Result<T, ApiError> with a
matchable code. v1 had seventeen raw RequestInit call sites across three
modules, each with its own header, error and JSON handling, all returning
Result<T, String> -- so a 401 and a parse failure arrived looking
identical.

The types crossing the wire are runway-core's own. Preferences moved there
because it is the body of /api/preferences and both ends hold it; a copy
on the frontend would be a fifth parallel representation of the kind that
lost data at every hop in v1. Moving it surfaced that the schema's DEFAULT
for theme and the type's own Default disagreed, which is the same drift in
miniature. They now agree on 'system': following the operating system is a
better first impression than picking a side.

Two stores offered through context, each with one writer, replacing v1's
2,008-line component with 35 use_state hooks prop-drilled through
components that only passed them along -- and localStorage acting as a
hidden global any component could write to.

Preference saves are coalesced, which the browser loop proved is not an
optimisation. Changing four settings quickly started four requests whose
replies did not come back in order, and the answer to the first change
overwrote the third: "opens on Month" was silently still Week after a
reload. Only one save is in flight now, and whatever the person has landed
on when it returns is what goes next. The screenshot script checks all
four settings survive a reload, which is how the bug was found.

runway-web is a library as well as a binary, so the client is a public
surface rather than dead code awaiting its first caller, and the pure
parts have somewhere to be tested from.
2026-08-26 20:02:26 -04:00
connor 16b1ad9a14 Add the frontend shell, the token system, and a login screen
Two axes on <html>: data-theme for colour, data-style for shape and
density, independent of each other. v1 shipped 12 themes x 3 layout styles
as 36 hand-maintained stylesheets -- 8,300 lines of CSS, 553 custom
properties, 116 !important declarations, and the last fifteen commits in
the repo were print-preview CSS tweaks. Here a theme is a list of colours
and a style is a list of measurements. Every token is semantic: nothing is
named --blue-500, because a theme has to be able to change what blue is.

The release bundle is 209 KB, 87 KB gzipped. v1's was 2.5 MB, a
meaningful share of which was a 638-line dead CalDAV client that kept
reqwest, ical and regex in the dependency list. Feature-gating runway-core
is what that structural fix buys.

One typed API client returning Result<T, ApiError> with a matchable code,
replacing v1's seventeen raw RequestInit call sites across three modules,
each with its own header, error and JSON handling and all returning
Result<T, String>. The session cookie is HttpOnly and this code cannot
read it; asking the server who you are is the only way to find out.

Trunk proxies /api to the backend so the cookie is same-origin in
development exactly as in production, rather than weakening it to
SameSite=None for a local convenience.

And the browser loop the audit called the biggest change between v1 and
v2: e2e/shoot.mjs drives a real Chromium, screenshots every state across
both token axes using the actual controls, and reports whatever the
console said. It found that wasm-opt needed telling bulk-memory is
allowed, and that offering a reference number for a mistyped password
suggests a fault at our end.
2026-08-26 19:13:59 -04:00
connor 2e5bd6ec34 Add request tracing and the rules about what must not be logged
v1's backend had 157 println! calls with emoji prefixes, no levels, no
filtering and no structure -- and one of them printed "Password length:"
from the login handler. There was no way to turn any of it down, and no
way to find the lines belonging to one request.

Every request now gets an id, echoed in x-request-id and attached to every
line logged while handling it. A supplied id is kept, so a reverse proxy
correlates with us. Internal errors return that id in the body: "something
went wrong" is only useful if it leads somewhere, and the detail stays in
the log where it belongs rather than describing the inside of the server
to whoever asked.

The middleware instruments the inner future rather than tagging its own
events with a parent. A test caught the difference: with the parent form,
an error raised inside a handler logged outside the span, so the id the
client was told to quote led nowhere.

Four tests assert what must not appear. A password never reaches the log,
nor does its length -- knowing it is nine characters is knowing something.
A session token never reaches it either. Query strings are not logged at
all, because they carry calendar paths, and the path alone says what
happened.

And a test greps the source for println!, print!, eprintln! and dbg! in
runway-core, runway-caldav and runway-server. main.rs is exempt: genkey
printing a key to stdout is its whole job. A rule that only lives in a
document gets forgotten.
2026-08-26 18:49:30 -04:00
connor 3dcd8f76ad Add subscribed ICS feeds
The parse is the whole story. v1's importer had two open TODOs -- no
RECURRENCE-ID and no VTIMEZONE -- so a correct feed came back looking full
of duplicates, because a series master and its modified occurrences share
a UID and a SUMMARY and read as a flat list they look like one event
repeated. Rather than fix that, v1 added ~500 lines that stripped
punctuation from titles, grouped by the result, scored the collisions for
"completeness" and threw the losers away. Nothing here compares titles,
because nothing looks like a duplicate in the first place.

The regression test is two genuinely different meetings called "Stand-up"
and "Stand up" at the same moment: both survive. Against the real 1 MB
Outlook feed, 103 UIDs expand to 644 occurrences over twenty years with
every UID accounted for and no series producing two occurrences at one
instant.

Freshness has three answers, cheapest first: If-None-Match, then
If-Modified-Since, then a hash of the body -- and the hash is not academic,
because a published Outlook calendar sends neither validator, so without
it every poll would reparse a megabyte to discover nothing changed. v1 had
`let etag = None; // TODO`.

An unreachable feed serves yesterday's copy rather than blanking the week,
and says so. A URL that returns a login page with a 200 is refused at
subscribe time, when the person still has the link to hand, and the failed
subscription is removed rather than left as a broken row. webcal:// is
rewritten, because that is the scheme Outlook and Google hand people.
2026-08-26 18:16:44 -04:00
connor c619660adc Add the calendars and preferences APIs
Two kinds of setting, split deliberately. A calendar's name and colour are
properties of the calendar, so they go to the server with PROPPATCH and
every other client agrees -- v1 kept its colours in a JSON blob, disagreed
with all of them, and where the server said nothing it hashed the
calendar's path to invent one. Visibility and order are how one person
arranges their own sidebar and stay local: a household sharing a calendar
must not have one member's hidden calendar vanish for everybody. A
per-user colour override sits between the two, and the response says which
of the three a colour came from so the interface can offer to undo it.

PROPPATCH needed the multistatus parser to track per-property failures. A
207 is returned even when nothing changed, with the real answer inside, so
without that a refused rename would look like a success and simply not
happen.

New calendars get a readable slug rather than a uuid, because the path is
what other clients show in their settings, with a numeric suffix only when
the obvious name is taken.

Preferences are validated twice on purpose: the schema is the backstop
that makes a bad value impossible by any route, and the handler is the
layer that can say why instead of surfacing a constraint violation as a
500. A UTC offset is refused as a time zone with a message explaining that
an offset cannot tell January from July.
2026-08-26 17:51:02 -04:00
connor c4e8ede28c Add the events API
One path with an EditScope on the write verbs. v1 had a second parallel
tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating
the non-series handlers, dispatching on string literals in 53 places where
a typo was a runtime fallthrough.

What a scoped edit means to the stored .ics lives in runway-core::series,
pure and tested without a server, because that is the subtle part and v1
shipped it with no coverage at all. Editing one occurrence writes an
override and no EXDATE: an EXDATE says the occurrence does not happen, an
override says it happens differently, and writing both is contradictory.
Deleting one writes the EXDATE and removes any override that named it.

Splitting a series divides its bound rather than dropping it. Six weekly
occurrences split at the third become two plus four, not two plus
forever -- the count is what the person asked for and it should survive
being cut. Overrides after the split move to the new series; moving a
whole series shifts its overrides' RECURRENCE-IDs by the same amount
instead of leaving them pointing at occurrences that no longer exist.

Every write states a precondition. There is no unconditional path: an
update without an ETag is refused, and a stale one is a conflict rather
than a silent overwrite. UIDs are minted server-side, because a
client-supplied one could collide with and replace an unrelated event.

Reads use time-range and fan out across calendars concurrently. Zones that
cannot be resolved are reported in the response instead of being rendered
as though they were fine.

Two tests found real bugs: sub-second timestamps cannot survive
iCalendar's one-second resolution, and splitting at the first occurrence
was dropping the recurrence rule and quietly turning a series into a
single event.
2026-08-26 17:07:38 -04:00
connor c7e22f4431 Add authentication
Proving who somebody is and starting a session for them are separate
operations. login_with_caldav does the first by asking the CalDAV server
whether the credentials work; begin_session does the second and knows
nothing about how the question was answered. OIDC arrives as a second way
to reach begin_session, not as a second scheme threaded through everything
-- which is what v1 had, with a JWT for most of the app and a separate
SQLite session_token used only by the preferences API.

The token lives in an HttpOnly cookie and nowhere else. v1 kept a JWT and
the CalDAV password in localStorage, readable by any script on the origin,
and re-sent the password in a header on every request. Here the password
never leaves the server: it is encrypted with XChaCha20-Poly1305 and
caldav_for is the only path back, handing out a client rather than a
credential.

Failed decryption is an error, not a subtly wrong password -- the AEAD tag
is checked, so a tampered row surfaces here instead of as a mysterious
CalDAV rejection later. A wrong password and an unreachable server stay
distinct, because telling somebody their password is wrong when the server
is down sends them to reset one that was fine.

Errors carry a stable code alongside their message, so a client can branch
on them. Internal ones say nothing about the inside of the server; the
detail goes to the log.

Tests go through router(), the same function main calls -- v1's suite
rebuilt the route table and tested a copy until it stopped compiling.
Skipping is now loud: a skipped test reports "ok", so run.sh sets
RUNWAY_REQUIRE_CALDAV=1 and not running becomes a failure.
2026-08-26 16:27:59 -04:00
connor 82d05dc67a Add the database schema and repository layer
The per-calendar JSON blob is gone. v1 kept every calendar's colour, every
visibility toggle and a custom palette inside one calendar_colors TEXT
column, so hiding one calendar rewrote the whole document -- which is where
"Fix calendar visibility preservation during event updates" came from.
Those are rows now, and set_visible touches visibility alone.

Foreign keys are switched on. v1 declared external_calendars.user_id as
INTEGER against a TEXT users.id; SQLite enforces neither the type nor the
constraint unless asked, so it was decorative and could never match.

Session tokens are stored as SHA-256, never in the clear, so a copy of the
database cannot be used to impersonate anyone. The CalDAV password is the
one secret that cannot be hashed -- it has to be replayed to the server --
so it gets an encrypted column with the algorithm recorded alongside, and
one way in and one way out instead of v1's eight localStorage reads.

Preferences are one column each with CHECK constraints, so a bad view or a
nonsense time increment is refused whatever route it arrives by. A NULL
display timezone means "follow the browser", which is a state v1 could not
express -- as with a NULL calendar colour meaning "defer to the server",
which is why it hashed paths to invent one.

Feed caching stores a content hash beside the ETag, because a published
Outlook feed sends neither ETag nor Last-Modified and staleness has to be
detectable anyway.

Thirty tests against real in-memory SQLite, not mocks. One caught that
create() returned nanosecond timestamps while the column stores
microseconds, so a session never compared equal to itself read back.
2026-08-26 15:54:28 -04:00
connor c197e08af0 Default the CLI to the system time zone
It shipped with America/Denver hardcoded, taken from what dominates the
historical data on the server. That data is old; the reader has moved.
A zone belongs to whoever is looking at the calendar, and baking one in is
the same mistake as v1's offset-instead-of-zone in miniature -- it looks
right until the reader is somewhere else.

Falls back to UTC rather than to a populated guess: an obviously neutral
wrong answer gets noticed, a plausible one does not.
2026-08-26 15:38:55 -04:00
connor f8e4a497fa Add the CalDAV client and a CLI to drive it
Discovery is the three PROPFINDs RFC 4791 describes rather than a walk
through likely URLs. Queries use time-range, which v1 never did -- it
fetched whole calendars and filtered in the browser on every view change.
Every write states a precondition, so a stale ETag produces a Conflict a
caller can act on instead of silently destroying somebody's edit.

XML goes through quick-xml with namespace resolution. v1 matched prefixes
with six regexes tried in sequence and recompiled inside the loop; there
is a fixture here that is the same document under different prefixes, and
it parses identically.

Protocol parsing is split from transport so it can be tested against
responses recorded from a real Baikal -- including the second propstat
carrying 404s, which is what makes "this calendar has no colour" different
from "this calendar has an empty colour".

Live tests run against a real server, never a mock. tests/baikal/run.sh
starts a container, walks Baikal's install wizard, and runs them; each
test builds and destroys its own collection, so pointing it at a real
server touches nothing that was already there. They cover discovery,
round-trip, stale-ETag conflict, duplicate create, delete, time-range
filtering, a series returned whole with its override, and writing every
synthetic golden fixture to the server and reading it back.

libdav was evaluated first, as planned. Not adopted: its HttpClient trait
is defined over hyper::body::Incoming, so using it means replacing reqwest
everywhere, plus a DNS resolver for service discovery we do not do and a
second XML parser. Its precondition design is where Precondition's shape
comes from. Reasons are recorded in the crate docs.
2026-08-26 15:32:08 -04:00
connor 689b78154b Add recurrence expansion
Rules come from the rrule crate. What is ours is the layer above it:
reconciling a series master with the RECURRENCE-ID overrides that replace
individual occurrences, which is where the subtlety actually lives. An
override suppresses the occurrence it names, and an override whose master
falls outside the window is still emitted -- a published feed truncates
series at its edge, and those are real events.

Occurrences know their own RECURRENCE-ID. v1 encoded instance identity as
a "{uid}-{timestamp}" string and split it apart again in the view layer.

TZID resolution is a ladder: IANA, then Windows zone names through a CLDR
table generated from windowsZones.xml, then an assumption that is reported
rather than hidden. Exchange writes "Pacific Standard Time" and every zone
in the real feed maps. Deriving a VTIMEZONE's own offsets is not built --
nothing in the corpus needs it, and the definitions are already carried
should that change.

Daylight saving is settled explicitly rather than by unwrap. An ambiguous
time takes the earlier reading; a time inside a spring-forward gap slides
past it by the gap's own length, so 02:15 and 02:45 stay distinct and stay
in order instead of both snapping to 03:00.

Tested against known-good outputs: DST both directions, leap day, nth
weekday, BYMONTHDAY on short months, COUNT against UNTIL, EXDATE against
an override, and the real Outlook feed -- where the assertion is that no
series ever yields two occurrences at one instant, which is what the old
importer's title-matching heuristics were standing in for.
2026-08-26 14:50:52 -04:00
connor 7043a151f6 Add the iCalendar round-trip
Parsing goes through icalendar's low-level parser, which keeps properties
in order and keeps repeated ones. Writing is ours: that crate's writer
escapes a whole property value as text, so CATEGORIES:Work,Personal would
go out as one category named "Work,Personal" to every other client.

Anything the model does not interpret is carried rather than dropped --
X-MOZ-LASTACK, X-EVOLUTION-ALARM-UID, ACKNOWLEDGED, the X-MICROSOFT-CDO
set, unrecognised ATTENDEE parameters, and whole VTODO/VJOURNAL
components. A calendar has several clients writing to it and this one is
not the authority on which properties matter.

VTIMEZONE is modelled properly, and TZID is stored exactly as written:
Exchange names its zones "Pacific Standard Time", which no IANA lookup
resolves, and normalising at parse time would make the document
unrepresentable. Mapping to a real zone belongs at the point of use.

Tested against a golden corpus captured from the live Baikal (seven
producing clients over five years) and a published Outlook feed, scrubbed
of private content with the structure left byte-for-byte. Eight
hand-written fixtures cover what neither server had: DURATION, floating
times, RDATE, DST boundaries, leap day, and the full escape set.

The contract is that parse -> write -> parse is stable, plus a check that
no property name loses occurrences across the trip, since a parser that
dropped ATTENDEE entirely would round-trip perfectly and still be wrong.
2026-08-26 14:32:38 -04:00
connor 52cd5a961d Add the RFC 5545 domain model
VEvent and friends, transcribed from the previous calendar-models crate
and tightened so that the states which caused its timezone and recurrence
bugs cannot be represented.

The substantive changes from v1:

CalendarDateTime is an enum over the four forms RFC 5545 actually admits
(date, floating, UTC, zoned) instead of a NaiveDateTime plus a loose
Option<String> zone plus an all_day flag that could all disagree. A zoned
value carries an IANA identifier, never a UTC offset -- an offset cannot
tell standard time from daylight time, which is why recurring events
drifted an hour across DST.

EventEnd is an enum, because DTEND and DURATION are mutually exclusive.
Priority validates 0-9 on construction and on deserialisation. EditScope
replaces dispatch on strings like "this_and_future", which appeared 53
times and turned typos into silent fallthrough.

CalendarObject models a CalDAV resource as it really is: one UID, one
master, N RECURRENCE-ID overrides. v1 flattened this to a bare event list,
which made overrides look like duplicates and motivated ~500 lines of
title-matching heuristics that silently discarded events.

Dropped VJournal, VFreeBusy, VTimeZone, TodoStatus, FreeBusyType and
Period: defined but never used.

28 tests cover serde round-trips, the exact JSON shape (the model is the
wire format, so changing it should be deliberate), duration fallbacks,
validation boundaries and master/override separation.

uuid needs an explicit entropy source on wasm; without it the frontend
cannot compile the shared model. Verified that the default feature set
pulls in neither icalendar, rrule, chrono-tz, quick-xml nor reqwest.
2026-08-26 12:29:59 -04:00
connor bf63024711 Set up workspace skeleton
Five crates: runway-core (pure domain), runway-caldav (protocol),
runway-server (axum), runway-web (leptos), runway-cli (smoke tool).

runway-core is feature-gated into model/ical/recurrence so the frontend
can depend on the shared types without pulling icalendar and rrule into
the WASM bundle. The previous iteration shipped reqwest, ical and regex
to the browser for a dead module; the feature split makes that mistake
structurally hard to repeat.

Guardrails are compiler- and CI-enforced rather than aspirational:
workspace lints deny unwrap_used/expect_used, dead_code and unsafe_code,
clippy.toml caps function length and arity, deny.toml pins licences.

Toolchain is pinned per-project so the machine-wide default is untouched.
Cargo.lock is committed this time.

docs/legacy-audit.md carries the marked-up feature decisions and is the
spec for the rewrite.
2026-08-26 12:07:44 -04:00