Lossless Changelog
product

Refined Client Portals

The monogram fallback did its job and is now out of a job — the real icons arrived, recovered from a sibling repo and checked byte for byte.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 5 (1M context)

Changelog — 2026-09-15 (#1)

Why Care?

Two days ago we shipped a client portal surface whose logos were all broken, and wrote up [[2026-09-13_01]] explaining why: seven of the eleven icons were git-lfs pointer files, 130 bytes of ASCII that a web server hands you with a 200 and a Content-Type of image/png. We built a monogram fallback and left a prediction in the entry — "when those LFS objects are eventually fetched the real icons appear with no code change."

That turned out to be true, but not for the reason we expected. The objects were never going to be fetched, because they were never pushed. They exist on exactly one laptop, which we do not currently have access to. The fix came from somewhere else entirely, and the fallback stepped aside on its own.

Along the way the portals got the two refinements that make them feel finished: a CTA that actually goes somewhere, and tags that stop shouting.

What's New?

  • All seven missing client icons restored, recovered from lossless-site and verified byte-for-byte by SHA-256 against each LFS pointer's own oid.

  • "Access Portal" is a real link. Every card on /curious-clients/ now clicks through to that client's toolkit page.

  • Tag chips ported from spike/variant-a — outline instead of fill, squared instead of pill, about 25% smaller.

  • A Clients link in the header, so the surface is reachable without knowing the URL.

  • The /toolkit filter, scoped, now on every client portal and on /repositories/.

  • A vercel.json matching the house pattern the sibling sites already carry.

  • The filter box works again — every keystroke was being eaten by a binding race.

  • The Explorer's tag chips match the static ones, so one idea has one treatment.

  • The chip row gets its gutter back, caps at three rows, and scrolls past that.

  • Cards show every tag, not the first three.

  • This repo no longer depends on git-lfs for any of these assets.

The objects were never pushed

The first assumption was that a git lfs pull would end this. It did not, and the error is worth quoting because it rules out an entire category of fix:

$ git lfs fetch --all
8 objects found, done.
[cd2d6d98e8…] Object does not exist on the server: [404]
[36e4ece388…] Object does not exist on the server: [404]
… seven of these …
error: failed to fetch some objects

Seven 404s. The pointers were committed and pushed — they are plain text, they travel with any ordinary git push. The objects never left the authoring machine, because git lfs push is a separate operation that normally rides along on a pre-push hook, and that hook was not installed.

This is the quietest possible failure. The push succeeds. The clone succeeds. The working tree looks complete. Every file is present at the right path with the right name, and every one of them is a 130-byte stub.

Recovering from a sibling, not from a backup

The portal page carries a comment that turned out to be the whole answer:

The client list and its cards are replicated from the lossless.group index page.

If the list was replicated, the assets were too. All seven exist as real files in lossless-site, which has never used LFS for them. The question was whether they were the same files or merely files with the same names — and an LFS pointer is unusually good at answering that, because it carries the SHA-256 of the object it stands for:

version https://git-lfs.github.com/spec/v1
oid sha256:cd2d6d98e8aa472ab69faf27e8c5b26dae351d7cdac5a65cae936ed9533d145e
size 3749

So the pointer is a checksum with a filename attached. Hash the candidate, compare to the oid, and you get a yes or no with no judgement involved. Seven candidates, seven matches, zero mismatches — and the sizes agreed too, 3,749 bytes on both sides for the one above.

The copy then re-checked the hash at write time and refused to write anything that had drifted. It is a small thing, but the failure being recovered from was exactly "a file that is present, correctly named, and wrong", so a name-based copy would have been reproducing the original bug with better intentions.

The fallback stepped aside on its own

Nothing needed to be removed. ClientPortalQuickAccess.astro validates every icon on disk at build time:

TS
const LFS_POINTER_CEILING = 512;
// size > CEILING -> use the icon; otherwise fall back to a monogram tile

Every restored file is between 1,756 and 148,082 bytes. They cross the ceiling, so the real icons render and the monograms retire without a code change. The guard stays where it is — it costs one statSync per card and it is the reason a pointer can never again reach a browser as a broken-glyph.

Committed as ordinary git blobs, 369 KB across seven files. LFS exists for hundred-megabyte assets; the largest thing here is a 148 KB character illustration.

A CTA that was dead on purpose

ClientCard.astro had this at the bottom of every card:

ASTRO
<TextCTA text="Access Portal" />

No link. The comment above it explained why, and it was right at the time:

clientId … retained from the source so the portal link is a one-line change if a /client/<id> route ever exists here. It is NOT rendered — this site has no such route, and a dead link is worse than none.

The route landed two days ago, which quietly made that comment false. The wiring was smaller than expected: TextCTA already renders an <a> when handed an href and a <button> otherwise, and the scroller was already passing clientId. The prop was declared in Props and simply never destructured off Astro.props — so the value arrived at the component and fell on the floor.

ASTRO
href={clientId ? `/client/${clientId}/` : undefined}

The conditional keeps the original instinct intact. A client with no id still renders a <button>, so the card cannot produce a dead link — which was the entire reason this stayed unwired in the first place.

All eleven cards now link, and all eleven targets return 200. Two of them land on the "nothing tagged for this engagement yet" empty state, which is honest rather than broken, and is a content question rather than a code one.

Tags that stop shouting

The chips were filled pills at var(--step--1). At card density — three per card, dozens per page — a filled pill competes with the title it sits under. The other spike had already solved this. From spike/variant-a:

beforeafter
font-sizevar(--step--1) ≈ 0.78–0.86rem0.62rem
backgroundvar(--clr-chip)none
border1px transparent1px var(--clr-line)
radius999pxvar(--radius-sm)
tracking0.01em0.06em

The substantive move is losing the fill. An outline recedes; a fill insists. Wider tracking buys back the legibility the smaller size costs.

Variant A's tokens were remapped rather than copied — --hairline → --clr-line, --text-muted → --clr-ink-muted, --font__mono → --font-mono. None of the originals exist in this site's token set, so a verbatim copy would have produced chips with no border and inherited colour, and it would have looked plausible in dark mode. The replacements are defined in all three modes, so the chips still respond to light, dark, and vibrant.

One class, eight surfaces: tool cards, /tags/, the portal tag rows, /repositories/, /toolkit/<slug>/, the homepage, and 404.

The surface nobody could reach

/curious-clients/ shipped two days ago with no way to get to it. The only links in were the portal rail on the page itself and a card CTA that was not yet a link — which meant the only way to reach it was to already know the URL. The header carried Explore, Verticals and Tags and stopped there.

Explore · Verticals · Tags        ->  Explore · Verticals · Tags · Clients

One anchor in Base.astro. The nav is flex-wrap at full width and collapses into the hamburger panel below the breakpoint, so a fourth link needed no layout change at either size.

One filter, three scopes

The Explorer was built for one page. Putting it on the client portals and on /repositories/ meant answering two questions it had never been asked.

Which catalogue? Reusing /api/tools.json and narrowing in the browser would ship a megabyte to a portal that lists fourteen tools, and the facet counts would describe the corpus rather than the page — a chip reading 128 above a result set of 14. So each scope prerenders its own endpoint:

endpointtoolstagssize
/api/tools.json1,7657441,019 KB
/api/client/laerdal.json189322152 KB
/api/repositories.json15518495 KB

Which URL? This was the part that would have shipped as a bug. canonicalPath() hardcoded its destination — one tag became /tags/<slug>/, anything richer became /toolkit/?…. Both are correct for the global catalogue and wrong everywhere else: on a portal, picking a tag would rewrite the address bar to a different, unscoped page. The filter would look like it worked, and the link the user copied would open the whole catalogue.

scoped:    /client/laerdal/?tags=agentic-ai
unscoped:  /tags/agentic-ai/          <- a different result set

A scoped view has no prerendered single-tag equivalent, so query params on its own path is the honest answer. setScope() registers synchronously, before any effect runs — writeUrl uses replaceState, so a late registration emits one wrong URL first, and that is exactly the moment someone copies the address bar.

And /repositories/ keeps its point. The Explorer's card had no source link, so a filtered repo list would have been a worse /toolkit/. The projection gained r and rk behind an explicit withRepo opt-in — public URLs already printed on the page. All 155 repos carry their link; the client payloads carry none.

Both pages keep their server-rendered list under <noscript>.

Every keystroke was being eaten

The filter box looked dead. You could click it, the caret blinked, and nothing appeared. A browser drive found the input reading "" immediately after typing kubernetes — not a rejected keystroke, an erased one.

The cause was two writers racing on one value:

SVELTE
<input bind:value={query} oninput={() => { limit = 48; push(); }} />

Both Svelte's binding and the explicit handler fire on the same input event, and the handler can run first. push() then copied a stale query into the shared atom; the binding updated query a moment later; and the bridge effect — which exists to let the header bar drive this component — saw toolkit.query ('') disagree with query ('k') and did what it is built to do: reset the local value.

keystroke -> oninput (stale push) -> binding sets query -> effect resets query -> ''

The file already carries a comment about a two-way mirror that raced and "ate every keystroke". That bug was fixed by giving the atom a single owner. This is the same failure re-entering through the event ordering rather than through the effect graph.

The fix is to stop having two writers. Reading the value off the event makes DOM → query → toolkit.query one synchronous sequence, so the effect never observes a disagreement:

SVELTE
<input value={query} oninput={(e) => { limit = 48; query = e.currentTarget.value; push(); }} />

The category select had the identical pattern and the identical fix.

This predates today. Removing the new setScope() call and re-running the drive reproduced it unchanged on /toolkit/, so it was live for anyone who tried to filter. It surfaced now only because putting the Explorer on two more pages meant actually driving it.

Verified rather than asserted — kubernetes narrows to 7 on /toolkit/, 1 on /repositories/, 2 on the Laerdal portal, and zzzznomatch reaches the empty state on all three.

One treatment per idea

The Explorer's chips were still the old filled pill while the static ones beside them had already gone to the variant-a outline — two treatments for one idea on a single page. TagChip now matches: same 0.62rem mono, same 6px radius, same hairline, same tracking, confirmed by computed style rather than by eye.

What deliberately did not shrink is the hit area. These chips are controls, not labels, so padding stays tappable and .selected keeps its solid accent fill — losing that would remove the only cue for which facets are active.

The chip row, and the tags it was hiding

Two problems, one of which was hiding the other.

The chip row on the Explorer's cards had no inline padding, so chips sat flush against the card edge while every other element respected .card__body's 0.95rem gutter. The static ToolCard never showed this — it carries its own scoped .card__tags--links rule with the padding already in it. Only the Explorer's cards were affected, which is why it surfaced the moment the Explorer landed on two more pages.

The cap is arithmetic rather than a guessed pixel value, because border-box counts padding toward max-height:

CSS
--chip-row: 1.4rem;  --chip-gap: 0.3rem;  --chip-pad: 0.55rem;
max-height: calc(3 * var(--chip-row) + 2 * var(--chip-gap) + 2 * var(--chip-pad));

ToolCard's scoped rule now sets only padding and pulls the same custom property, so the two cannot desync — a repeated literal there would have left the cap computed from one number and the padding rendered from another.

And then the cap revealed the real omission. Both components truncated at slice(0, 3), so a three-row container could never fill. The corpus does not look like that:

tags per tool
median3
p755
p906
max13

729 of 1,765 tools — 41% — carry more than three tags, and every one past the third was simply not rendered. With a container that caps height and scrolls, the truncation has nothing left to protect, so it is gone. Measured after the change: a seven-chip card clamps to 94px of a 119px scroll height and scrolls; a five-chip card sizes naturally and does not.

Still open

  • The LFS objects remain unpushed. Anyone cloning fresh still gets stubs for any other LFS-tracked file in the history. git lfs push --all origin from the authoring machine closes it.

  • There is no .gitattributes in this repo — and there never was. Files were committed as LFS pointers anyway, which means the tracking came from machine-local config rather than anything versioned. Until that is either committed or removed, the same silent failure can recur on the next binary. For assets this size, removing LFS is the better answer.

  • astro.config.mjs names the wrong origin. SITE is lossless-toolkit.vercel.app, which 404s; the deployment lives at lossless-toolkit-site.vercel.app. Every canonical, sitemap entry and absolute og:image is built from a dead host. One line, but it changes every emitted URL.

  • Two tools name a client in their public summary. microsoft-loop and adaptcms both read "part of the … Stack" with a client name in it. Not a for_clients leak — the DENY set holds — but body prose that the summary precedence chain picks up as a first-paragraph fallback. Both pages are public, not noindex, and in the sitemap. The guard protects the field; prose walks past it. A content fix, not a code one.

  • ToolExplorer's tag chips are untouched. Done — see above. Those are a separate component (TagChip.svelte, .tool-tag) and they are interactive — selectable, removable, carrying live facet counts. Shrinking an interactive control to 0.62rem trades away tap-target size and the selected/unselected contrast that filtering depends on. They will look heavier than the static chips beside them until that is reconciled deliberately.

References