Blog ยท release
Latch 0.7.0
Share cards: three kinds, seven themes, and the badges on them. Closes issue #5 โ the
/share page could pick a shape and a photo; it could not pick what the card was about,
what it was drawn in, or which stats went on it.
- Three kinds, because they answer three different questions. Right now is the live state at one moment. The last stretch counts a span โ 7, 14, 30 or 90 days โ and prints the span's real dates. One badge is a record of something earned.
- ๐ด A MILESTONE CARD IS AS-OF THE BADGE, NOT AS-OF TODAY. Every number on it is what it was when the thing was earned, so an August trophy shows August's streak. Stapling today's figures onto an old achievement is the P0 stamped-overlay lie in a new place โ a three-week-old photo wearing today's numbers โ and it is the reason this kind exists in the honest form rather than the obvious one. Guarded by rendering the card, moving the world on by a month in every way that has ever rewritten an old caption, and demanding byte-identical output.
- ๐ด AND IT REFUSES A PHOTOGRAPH. A photo would put a third moment on the card โ the
badge's, the shutter's, and the reader's assumption that a card handed to them is about
today.
widerefuses a photo on a layout argument; this refuses one on an honesty argument. Both refuse quietly and render the rest. - ๐ด A WEEK CARD CLAIMS NO CALENDAR PERIOD. It prints "2 SEP - 9 SEP 2026", never "THIS
WEEK", because a card headed THIS WEEK on a Wednesday is a claim about three days wearing
the name of a week โ the same thing
core/badges.pyalready refuses when it waits for a month to close before deciding its winner. - ๐ด The
windowtoken group reads TWO clocks, and says nothing without both.Ctx.sinceis optional and everywin_*token returns None when it is absent, which is exactly what the existing contract already means: not measurable here. So a window token left in a right now card's list is silently inert instead of printing a week's figure on a card about this minute. Extending the catalogue rather than giving the week card its own parallel stats system is what keeps one definition of "locked" in the app. - โ ๏ธ
win_lockedis each session's overlap with the window, not a difference of two cumulative totals. Measured, the two agree for every relationship a session can have to the window โ but only becausetotals_athappens to clip at its own instant, which is a property nothing else states or tests. Written as an overlap the correctness is visible; a test now pins the equivalence so a change to that clipping fails loudly here. (An earlier draft of this note claimed the subtraction was wrong. It is not; the claim was checked and corrected before it shipped.)
Themes
- Seven palettes, six of them the badge metals. Steel, Moss, Sapphire, Amethyst and Gold
are the five badge rarities; Plate is the cream plate a badge sits on. Midnight is the
default and is the card that already existed โ
theme.MIDNIGHT's literals arerecap._ground's, and a test renders both grounds and compares every pixel. A theme system whose default drifts is a silent redesign of every export anyone has ever made, and it would ship looking like a refactor. - ๐ด A THEME MAY NOT RECOLOUR A SEMANTIC SIGNAL.
stamp.TONEcarries two kinds of colour: decorative (ink, muted, dim, line, accent, accent2) and semantic (ok, bad, warn, ice, cyan).delta_colorputs ADDED time in RED โ counter-intuitive enough that stamp.py gives it exactly one home. A gold theme that swept every colour into its metal would erase the sign of every adjustment on the card and still look beautiful. - ๐ด A THEME CARRIES ITS OWN INK. The obvious model โ a theme is a background and an
accent โ produces near-white text on the near-white
plateground: it renders, raises nothing, and is unreadable. Every theme is held to a measured WCAG contrast ratio against its own ground, and the guard is proven to fire by building that exact mistake. - ๐ด
BADGE_METALis a hand transcription of the forge'sRARITYand that is forced โtools/is not in the Docker image (.dockerignoreis an allowlist admitting onlyrequirements.txt,app/anddocs/), so an import would work on the Studio and fail on the pod. A duplicate you cannot avoid needs a guard, not an apology:test_badge_metals_match_the_forgeloads the real file off disk and compares field by field.
Badges on a card
- A badge is DRAWN, never printed.
Rule.iconis an emoji and every emoji is.notdefin the bundled face, so printing it puts literal boxes on the one artefact built to be shared. The card draws the same hexagon-and-metal-ring the forge composes at 1024 px. Tested by giving a badge a wild icon and demanding the card not change. - ๐ด RARITY COMES FROM THE RULE'S
xp, NOT FROMbadges.tier. That column defaults to 0 and not one rule in the app ever sets it โ kit'sbadges.pyopens by rejecting tiers by name. A mark keyed on it would draw every badge in the same grey while looking exactly like a working rarity system: present, plausible, and constant.tierstill wins if a future rule sets one; it just cannot be the only input. A test asserts no module's catalogue sets it, so the day one does, this comment gets re-read. - ๐ด A badge is dated by its EVENT, not by
awarded_at. The first is what the award is about; the second is when the sweep ran, and for a monthly superlative they are a month apart. A card dated byawarded_atputs August's trophy in September. - The lower half is never empty: photo โ badges โ the events that moved the clock most.
A
?badges=1card with no badges falls through to the ribbon rather than printing "no badges yet" โ an empty state belongs in the app, never on the thing you hand to somebody.
The stat picker
- Add, remove and reorder the stats for one card, from the Share page. The choice lives in the query string like every other control, so the preview and the download stay the same URL. Links rather than checkboxes because the list is ordered and a checkbox set cannot express order.
- ๐ด An empty grid needs a word:
?stats=none. A query parameter cannot distinguish absent from present and empty โ FastAPI hands both to the handler as""โ so without a sentinel, "clear all" and "use my defaults" are the same URL and the card silently ignores one of them. - Three settings, not one:
share.tokens,share.week_tokens,share.milestone_tokens, plusshare.theme.
Layout fixes found on the way
- ๐ด The stat grid's TYPE scaled off W while its RHYTHM scaled off H. On the one shape
where W > H, that gave the grid the largest type in the smallest vertical room and
drew every value straight through its own label.
wideshipped like that andsquarewas one descender from it. Type now keys offS = min(W, H)โ the rulestamp.py's docstring already states for exactly this reason โ and the three vertical steps key off the type they are spacing.min(W, H) == Wfor story and square, so the story card's headline block is unchanged to the byte; only the grid rhythm moves on the two that were wrong. - ๐ด The event ribbon had the mirror-image bug: a fixed 78 px row pitch with height-scaled type, so the same row carried 32 px type in a story card and 13 px in a wide share card's short box. Type now keys off the pitch. The multipliers are the story card's measured sizes, so the recap card this was tuned on is unchanged to the byte; its square variant gets the fix.
h1 smallhad a zero gap on every page in the app.h1{display:flex}makes the title an anonymous flex item, which strips its own trailing whitespace, and theh1 small {margin-left}meant to replace it is out-specified by.hintโ a class beats a two-element selector. Fixed with agapon the flex container, which no utility class can out-specify.fmt.pluralโ "1 active day", not "1 active days". Invisible in a template, glaring in a photograph. kit's private copy now defers to it.
Traps
- ๐ด A
badgesquery parameter shadows thebadgesMODULE for the whole handler, so the next line became0.recent_with_ts(...)and failed on every request, including ones that never passed it. Fixed withQuery(0, alias="badges")so the URL keeps its name and Python gets its own. - ๐ด Every numeric share parameter is declared
strand parsed by hand. Declared asint, FastAPI validates before the handler runs and answers?days=abcwith a 422 JSON body โ an error page where an image was asked for, which is the same failure the "never redirect on bad input" test exists to prevent, arriving through a door it was not watching. - โ ๏ธ And the tail of that change:
"0"is truthy, soif preview:silently made every card inline and the download button stopped downloading. Caught by the existing disposition assertion.