Skip to content

Three destinations, and settings reduced (ADR-0058 phases 2-3) - #169

Closed
jeffcrouse wants to merge 25 commits into
admin/dashboardfrom
admin/destinations
Closed

Three destinations, and settings reduced (ADR-0058 phases 2-3)#169
jeffcrouse wants to merge 25 commits into
admin/dashboardfrom
admin/destinations

Conversation

@jeffcrouse

Copy link
Copy Markdown
Member

Stacks on #168, which stacks on #167. Merge in that order.

ADR-0058 is now accepted, with an Implementation block recording what shipped where.

The change

The sidebar listed library browsers — a music player's shape. Settings was one scroll of nine sections, five of them listener preferences.

Destination Holds
Library (/) dashboard, scan & sync, analysis, artist cleanup
Tools (/tools) backup & restore, community cache, the fallback player
Server (/server) health, API keys, profiles, Last.fm, diagnostics

Settings keeps theme, playback and offline — the two waves point 5 describes, theme outliving the player because it styles this interface.

Deleted (point 5)

ShuffleWeightSettings, RadioSettings, AudioEffectsSettings, QueueSyncSettings. The capabilities are unaffected: native owns them per-device (ADR-0029), and in the browser they stay reachable from the player's own chrome — ShuffleWeightPopover, EffectsQuickAccess — which retires with the player. No store was orphaned; all three still have player consumers.

Queue sync needed care. Its flag is persisted, so deleting the only toggle would leave a device that had switched it on mirroring forever with nothing to switch it off. The store gains version: 1 and a migrate forcing it back off — where its own docstring says a rollout gate should start.

Phases 2 and 3 shipped together, against the plan

Routing /tools and /server before moving content into them would have created destinations with nothing mounted — precisely the defect ADR-0057 point 5 exists to prevent. Flagged rather than done quietly.

Three affordances that already led nowhere

Found while rewriting the sidebar:

  • /favorites and /downloads had been linked since the ADR-0057 strip with no route in App.tsx. Both fell through to the catch-all and silently redirected home.
  • The mixtape export modal's only setMixtapeSource call was its own onClose, so nothing could ever open it.

navigationIntegrity.test.ts already claimed to catch "a sidebar link pointing to a route that doesn't exist" — and did not, because it read the registry and these were hardcoded in the component. It now reads App.tsx source and fails on any <Link to> or navigate() targeting an unmounted path. Verified by pointing a link at /favorites and watching it fail with the intended message.

Deliberately not linked

Two things point 2 names have nothing to mount, so they get a record instead of a dead row — UNBUILT_DESTINATION_ITEMS in routes.ts:

  • pending reviewapi/pendingTracks.ts is another wrapper nothing calls; there is no component
  • duplicates / organiser — phase 4

Verification

  • tsc --noEmit — 14 errors (unchanged baseline)
  • pnpm test — 63 files / 956 tests passed (952 + 4 new navigation guards)
  • pnpm run build — clean
  • docs/WEB-PARITY.md updated in this change, as ADR-0050 point 6 requires

🤖 Generated with Claude Code

jeffcrouse and others added 25 commits August 13, 2026 19:03
ADR-0039 point 4 asked for marks that are theme-aware "so the site can have a dark
mode without a second set of files", which in practice meant `currentColor` and no
hardcoded colour anywhere.

**The premise was never true.** `site/assets/site.css` has no `prefers-color-scheme`
block and no theme toggle; neither does the page. Familiar is dark everywhere — Mac,
phone, web — and nothing planned would change that. The requirement was insuring
against a mode nobody intends to build.

The premium on that insurance was the whole visual register. A `currentColor` mark is
monochrome by construction, which is right for an icon and wrong for the "black cats,
crows, witchy-but-playful" the same point asks for. Point 4 was asking for two
incompatible things and getting the icon — the reference mark drawn under the
constraint proved it: competent, and not charming.

It also closed off the practical route to producing them, since generated illustration
is raster and raster cannot inherit a colour.

So: colour is allowed, no light-mode variants, no second set. What survives is the
part that was load-bearing — one named set, drawn once, used consistently — plus a new
point that the set must commit to *one* register, because colour illustrations beside
monochrome stroked icons is point 4's failure mode arriving by a different route.

Recorded rather than quietly changed, per the convention: this reverses a clause an
accepted ADR decided, so it supersedes that clause and says which. The tradeoff is
recorded honestly too — `currentColor` was never only about themes, and losing it
means a mark can no longer sit in a heading and a muted footnote from one file.

Status: proposed. The brief and the sprite's comment are updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
It was a spec — register options, palette table, naming conventions, checks. Useful to
me when files come back, useless to paste. Now it is fifteen prompts and nothing else.

Each is self-contained and repeats the style block, which reads as redundant and is
the point: a generator drifts within a few images once the description leaves the
context window, and drift across the set is the one failure ADR-0039 point 4 actually
cares about.

The register is decided rather than offered, because a document of choices is not a
document of prompts. Flat vector art in the site's own palette — violet outlines,
near-black fills, pale green for anything that reads as live — which is register B from
the old brief, produced by raster generation. It keeps the marks belonging to the page
rather than sitting on it.

Two instructions carry more weight than the wording and are in all fifteen:
transparent background, because the site's panels are #18181b on a #0a0a0a page and a
baked background shows its own rectangle on one of them; and legibility at 40 pixels,
which is the actual size on a feature card.

Ends with the four ways a result drifts and what to add when it does, plus the one
tactic that matters: get `cat` right first, then attach it to every later prompt.
Matching a real image holds a set together far better than matching a description.

Verified by parsing the file rather than reading it: fifteen prompts, every one
carrying the transparency instruction, the flat-colour instruction, the 40-pixel
legibility floor and the palette.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Fourteen of the first fifteen illustrations came back sitting on a grey Photoshop
checkerboard. The prompts asked for a transparent background, and that request cannot
be honoured: Gemini returns JPEG, JPEG has no alpha channel. Rather than refuse, the
model drew the checkerboard — it is the visual signifier of transparency, so it renders
it as pixels. Asking more firmly cannot fix a format that has nowhere to put the answer.

So the prompts now ask for flat cyan. No mark uses cyan, and its nearest neighbour in
the palette is the green accent at ~174 in RGB, which leaves ample room to key against.

`extract-marks.py` does the cutting, reading the background off the image so the
fourteen already generated are rescued rather than regenerated — they are a good set,
and a background is no reason to throw them away.

Two things it does that a corner flood-fill cannot. It keys on colour, so fully
enclosed background survives nothing: the gap between the crow's legs is background,
and no fill starting at a corner ever reaches it. And for checkerboards it leans on the
fact that only ONE of the two tones is ambiguous — the dark square is a mid-grey and no
mark uses mid-grey, so every dark pixel is certainly background, while a light pixel is
background only when connected to a dark one. That is what keeps the key's off-white
body and the moons, whose own fill is 250 against a checkerboard white of 253; JPEG
will not hold a 5-value gap, and colour matching alone ate them.

Two measured details drove the rest. Tones are clustered rather than quantised, because
quantising splits one tone across two buckets whenever it straddles an edge (239 and
240 are the same grey), which then forces a merge threshold wide enough to swallow the
real second tone — the first version read every checkerboard as solid. And the output is
quantised to 16 colours: the art is flat, but JPEG mottling inside the fills is what
stopped PNG compressing it, at 167 KB for a mark that is really five colours. All
fifteen now come to 208 KB rather than 2.4 MB.

The source JPEGs move out of site/ and out of git. `cp -R site/. _site/` ships whatever
is in that directory, so 28 MB of intermediates would have gone to Cloudflare; the same
copy was also publishing the prompts document and now the script, so the assemble step
removes them.

Verified by compositing all fifteen on the page's own #0a0a0a and at 40px, which is the
size a feature card actually uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Two problems with the raster cut, both raised looking at the results.

The first: bits of outline were missing from the marks worth keeping. That was the
anti-halo erosion — two pixels off every edge, which a 30px outline never notices and a
10px one does not survive. Measured across the set it removed 17% of the constellation's
artwork, 6% of the key's and 5% of the tuning fork's. A tracer follows colour regions
instead, so a thin stroke becomes a path rather than a casualty.

The second: outline weight ranged from 10px to 54px against a median of 30, because the
set was generated a prompt at a time. In vector that becomes adjustable — the violet band
is grown or shrunk to a single target before tracing, and the set now reads as a set.

Getting there took four wrong diagnoses, and the notes in the file are mostly there so
the next person skips them. The ragged edges were not JPEG noise (more smoothing changed
nothing), not Lanczos ringing, not Floyd-Steinberg (FASTOCTREE ignores the flag), and not
path precision — every trace was clean until the background paths came out. Looking at
the actual pre-trace image rather than reasoning about it showed the cause in one glance:
the outline narrowing hands each vacated pixel the colour of its nearest non-violet
neighbour, and quantising had left stray sentinel pixels along interior boundaries. Each
one became nearest-neighbour for a wedge of the band and got smeared down it, combing
every outline with magenta teeth a dozen pixels long. Excluding the sentinel as a refill
source removes them, and the eye went from 105 KB to 28 KB because the teeth *were* most
of the path data.

Three smaller findings kept as constants rather than comments. The fill black is #0f0e14,
not the page's #0a0a0a — unifying to the page colour made the cat, the crow and the two
cats read as hollow outlines. The snap radii differ, 70 for violet and 30 for black,
because 60 reached far enough to swallow the shaded end of the cat-ear's rose gradient
and scatter black speckles through it. And accents are left exactly as drawn: snapping
every pixel to the site palette turned the cat's pale green eyes rose, pale green being
nearer #c07f8a than #22c55e in RGB.

Fifteen scalable marks, 668 KB, outlines within 26-32px of the 30px target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
The first set is discarded. The problem was not extraction, it was the prompts: every
one of the fifteen asked for "thick confident outlines in warm violet", so every image
came back as a vector sticker. No amount of post-processing undoes a style instruction.

The new register is the Welcome to Night Vale tour poster — hand-inked line of varying
weight, shading built from crosshatch and stipple, three inks on a black ground. Chosen
over that show's other look, the flat vector novel cover, in the knowledge that hatching
turns to mud below about 80px: the page will want a few large illustrations rather than a
grid of small icons, and the brief now says so rather than leaving it to be discovered.

Twelve prompts instead of fifteen, leading with characters — a cat and a crow that recur
— because a small recurring cast carries more identity than a scatter of objects. One of
them is a model sheet: the same cat four times in a single image, so consistency across
poses is structural instead of hoped for across separate generations.

**Black background, and it is not a compromise.** Over a black ground a pixel is its own
colour multiplied by its coverage, so coverage is just how bright it got, and dividing
that back out recovers the ink exactly. `extract-marks.py` gains that mode and picks it
automatically. Verified against a synthetic target: one-pixel strokes survive with correct
partial alpha, colours are preserved, and there is no halo on a lighter panel.

That also answers the concern that keying would suck the black out of the middle of a
drawing. In this register there is no black in the middle to lose — the darkness is the
ground, and letting the page show through it is what the style wants.

Two smaller things. The colour-key path now mattes its edges instead of cutting hard and
eroding two pixels to hide the fringe, which is what destroyed 17% of the constellation.
And there is no eye prompt: the eye-and-crescent is Night Vale's actual logo, so the
register is borrowed and the mark is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
The previous commit argued that a model sheet is the only reliable way to keep a
recurring character consistent, and then, two sections later, gave the cat three separate
pose prompts and the crow two. Jeff caught it. The prompts now do what the reasoning said.

Six character prompts become two: one cat sheet and one crow sheet, each four poses in a
single generation, because poses drawn together agree by construction and poses drawn
separately only agree by luck. `cat-and-crow` stays a single image — it needs both
characters in one composition, which no single-character sheet can produce — and it takes
an approved pose from each sheet as its reference.

The workflow was also unsupported by the tooling, which is the more useful half of the
catch: `extract-marks.py` wrote one file per input, so a sheet of four poses came out as
one image with four cats in it. It now takes `--split RxC` and carves the sheet at its
emptiest rows and columns, ranking runs of empty by length so a real gutter is picked over
the incidental gap between a cat's ear and its tail, and ignoring the frame's own margin,
which is longer than any gutter. Verified on a synthetic 2x2: four poses out, each cropped
to its own content, colours and partial alpha intact.

Eight prompts producing eleven marks, against twelve prompts producing twelve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Fourteen marks: two model sheets of four poses each, the cat-and-crow hero, and five
objects. The register landed — hand-inked line, crosshatch shading, magenta and violet on
black, and no outline anywhere.

Two things broke on contact with real sheets.

The splitter cut at empty rows and columns, and the cat sheet has no empty row: the
walking cat's tail and the stretching cat's tail both cross the horizontal middle, so it
found one column gutter and wrote two images with two cats each. Nothing is wrong with
that drawing — poses simply are not boxed, and the brief asking for "a wide band of clear
black space" was asking the artwork to accommodate the tool. It now groups the drawing's
connected parts by where their centres of mass fall, which separates poses whose extents
overlap, keeps a detached whisker with the pose it belongs to, and masks anything reaching
in from a neighbour before cropping. Eight poses out, each tight to its own content.

And the output is WebP lossless rather than PNG. The hatching lives in the alpha channel,
and 8-bit alpha full of cross-hatch barely compresses in PNG — half a megabyte for one
mark. Lossy WebP does not help, because it stores alpha losslessly whatever the quality;
only WebP's lossless mode, which has a real alpha codec, does, at about half the size.

Flattening the ink to one colour was measured and rejected: hue is stable across the set
(sd ~10) but saturation is not (sd ~60), and that variance is the pale-highlight-against
-body reading. It would have saved 25% and cost the thing the style is made of.

The matte needed no adjustment. Checked against #18181b and #27272a panels: no halo, and
a cat's dark interior shows the panel through it, which is what a light-on-dark register
should do.

Still 2.8 MB for fourteen, so the brief now says to pick the few the page uses rather than
ship the set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
The hero takes cat-and-crow, and the cauldron, lantern and curled cat sit beside the
headings of Ask, Install and Get started. Decorative, so `aria-hidden` and no alt text —
the heading beside each one is the accessible name, and describing it twice only makes a
screen reader slower. Hidden below 760px rather than shrunk, since a hatched drawing at
thumbnail size is texture, not a picture.

The inline symbol sheet goes with them. `mark-cat` was the reference drawn under the
constraint ADR-0054 removed, it was used exactly once, and a stroked monochrome icon beside
an inked illustration is the mixed set that ADR-0054 point 4 exists to prevent. `marks.svg`
and the `.mark` rule follow it out.

`#intro` moved under the hero. The three sentences that say what Familiar is were sitting
between the FAQ and Install, about 1,400 words down, and in no navigation.

**ADR-0055 proposes the restructure Jeff asked for**, because reorganising the site is what
ADR-0039 decided and not something to quietly redo. Its useful finding is that 0039 point 2
described its own plan as "taking Fork's architecture: ... a comparison table, FAQ" — and
Fork has neither. What Fork does have, and 0039 skipped, is a feature section carrying its
own screenshot; Familiar pooled sixteen screenshots into one grid and listed fourteen
features as text with no pictures. It took the parts of Fork that are easy to write and
left the part that does the work.

The claims were audited against the repo rather than trusted, and they mostly hold —
listening sessions, music videos, community cache, Mood Grid, 3D Explorer, smart playlists
and MCP all exist. I briefly had music videos as removed; that was a bad grep, and the
correction is recorded in the ADR so it is not re-derived. What has drifted is the
pictures: fifteen of the twenty screenshots predate May, so 0039 point 5's "just re-run the
spec" held as policy and failed as practice for four months.

The density point is written as a budget — six claims, under 700 words, against fourteen
and 1,778 — because "simplify" is not a decision. Every sentence on that page is
individually defensible, which is how it reached eleven sections.

Also recorded as a follow-up, found while screenshotting and unrelated to any of this: the
page overflows horizontally at 420px, cutting off both the nav's Install button and the
body copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Jeff's call, and it splits what ADR-0055 had treated as one problem. The comparison table
and the FAQ are not the same kind of thing: the table is an argument, answering "why not
Jellyfin, why not Plex" for a reader who is holding that question right now, so it earns a
place in front of them. The eight FAQ entries answer someone who has already decided to
care and is checking specifics — that reader follows a link, and the entries were costing
a third of the page to serve them.

So `site/faq.html`, linked from the nav and both footers. The landing page drops from
1,778 words to 1,314 and from eleven sections to ten. ADR-0055's budget is 700, so this is
a quarter of the way, not the job.

The cross-page links were the part with teeth. Two answers link to `#install` and
`#remote-access`, which are now on a different document, and `privacy.html`'s nav pointed
at `./#faq`, an anchor that no longer exists anywhere. All three were dead the moment the
section moved, and none of them would have looked broken — a dangling fragment just lands
you at the top of the page. Every internal anchor across the three pages is now checked
against the ids that actually exist, and they all resolve.

The FAQ page's illustration sits beside its title rather than above it. Stacked, the way
the landing hero does it, the first question started most of a screen down — fine when the
picture is the point, wrong when the reader came with a question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Green was the title, every link, the version chip, the requirements rule, the step badges,
the disclosure chevrons, the comparison table's affirmatives *and* the install button. When
everything is the accent, nothing is, and the one thing worth clicking looked like the rest
of the page.

So purple takes the voice and green keeps the job. The split is a rule rather than taste:
green means "act on this" or "yes" — the install button, the primary buttons, the .yes
cells, the live dot — and everything else follows the illustrations.

The measurement is what settled it. The marks sit at hue 288deg; `--accent-purple`, already
defined in the stylesheet and used for nothing but the arrows in the prompt cards, is at
271deg. The green is at 142deg, which is 146deg from the artwork — close enough to
complementary to read as a clash and not close enough to be one.

All-purple was mocked up and rejected: the app icon is a green waveform, it ships on the App
Store, and with no other green on the page it reads as a foreign object rather than the
product's mark.

Two things the change turned up. The hero title is a gradient, not a flat colour, so it
needed a real edit rather than a variable swap. And the closing call to action had no button
styling at all — `.nav-links a.primary` was the only rule, so the repeated "Install the
server" that ADR-0039 point 2 added on purpose was rendering as a bare text link. It now
shares the primary style, which is the whole point of repeating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
ADR-0055 points 3 and 4. Fourteen features listed as text and sixteen thumbnails in a wall
become six claims, each with a picture of itself, plus a compact list of the other nine.

The grid was a gallery rather than an argument: a reader met sixteen pictures at once, in no
order, before being told what any of them were for. Now each screenshot appears beside the
sentence it supports, which also forces the question of whether a claim deserves a picture
at all.

**Only screenshots taken this week are on the page.** Fifteen of the twenty in `screenshots/`
predate May, and the grid was showing all of them; the four that survive are the Mac ones
from today. Where there is no current screenshot the illustration carries the section, which
is honest about showing a feeling rather than a feature — and the illustrations are
deliberately smaller than the screenshots, because a drawing is atmosphere and giving it the
weight of evidence overstates it.

Two things found while rewriting. The iOS entry described the app as a "native Capacitor
wrapper", which has been wrong since `packages/ios` was deleted on 2026-08-11 — it is native
Swift now, and the site has been claiming otherwise for two days. And removing `#screenshots`
orphaned the nav links to it on both the landing page and the FAQ page; the anchor check
caught the second one, which is exactly the silent-fragment failure that made the check worth
writing.

Word count is honest: 1,356 against a 700 budget, and points 3 and 4 barely moved it — six
paragraphs that say something cost about what fourteen blurbs cost. What they bought is
structure: eleven sections to nine, thirty-six screenshot references to eight. Getting to 700
means a section leaving, which is point 2 and a separate call.

`site/screenshots` is gitignored. The deploy copies `screenshots/` into `_site` itself, but a
local `http.server` in `site/` cannot resolve them without it — which is why the feature
screenshots rendered as broken-image icons the first time this was reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Jeff asked whether the site's purpose was ever decided. It was not: ADR-0039 decided its
layout, ADR-0055 decided its structure, and neither said who the site is for or what it may
claim. That is the mechanism behind the drift — with no rule for what belongs, every true
thing gets added and nothing is removed when it stops being true. So ADR-0055 gains two
points, purpose and accuracy, and they go first because everything else is downstream.

The audit checked claims against the repository rather than trusting them. Two were wrong.

The version chip read v0.1.0-alpha2 · 2026-04-17; CHANGELOG.md says 0.2.0-alpha1, released
2026-08-10. Four months and a minor version.

The serious one is the privacy policy, which told readers that "when you use the AI chat
feature, your prompt and conversation context are sent to Anthropic". There is no chat
feature — `components/Chat/` and `api/chat.ts` are both gone — and under MCP the claim is
false in a specific way: Familiar holds no key and calls no model, so nothing goes from
Familiar to a provider at all. The host does the calling, on the reader's own account. It
now says that, and says what does cross the connection: tool-call answers, never audio. A
privacy page is where a stale claim costs the most, because it is read precisely by the
person deciding whether to trust the thing.

Not everything was wrong, and the ADR records that too so it is not "fixed" later. "36
tools" is right — 34 in MUSIC_TOOLS plus list_players and now_playing. CLAP is
512-dimensional. The demo and App Store links both resolve.

And the PWA is still real, against Jeff's recollection: vite-plugin-pwa is configured,
public/manifest.json exists, main.tsx registers /sw.js. Recorded as a fact rather than acted
on, because if it leaves the page it should leave as a positioning decision now that there
are native apps — not as a correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
The site still sold the browser as a way to listen: an "Offline PWA" feature promising
home-screen install and offline caching, a comparison row claiming "Mobile PWA", and a
remote-access section justified by the PWA install prompt. ADR-0050 was accepted on
2026-08-11 and says the opposite — the web app is a management surface, the listening path
belongs to the native clients, and new listening features do not come back to the browser.

This is not a new decision, which is the point. The direction was decided, accepted and
shipped two days ago, and the site never caught up. Nothing here needed an ADR; it needed
someone to read the one that exists.

So the sixth feature block becomes the apps rather than "your library, properly browsable" —
listening happens in real apps, and the browser is where you set Familiar up and look after
the library. Every PWA reference is gone: zero left across all three pages.

One row was removed rather than rewritten. I had replaced "Mobile PWA" with "Native Mac &
iOS apps" and marked Jellyfin as third-party, which is wrong — Jellyfin ships official
mobile apps — and I cannot verify competitor app support from this repository, which
ADR-0055 point 2 now forbids me from pretending otherwise about. A row where every column
answers Yes also differentiates nothing, so it earns no space.

Deliberately not claimed: that the web app is settings-only today. ADR-0050 point 6 is
explicit that "settings only" is not reachable until the Apple clients can edit playlists,
edit metadata, trigger a scan and create a profile, and `docs/WEB-PARITY.md` still shows
playlist reorder as browser-only. The site now describes the browser's job as setup and
library care, which is true today and stays true when the last routes retire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
The first audit failed and Jeff caught it. It passed "Music videos — attach video files to
tracks and play them in the full player" on the strength of a grep that returned
routes/videos.py. A filename is not a feature.

Five failures, all method rather than oversight. It checked that code exists rather than
that a capability does. It never opened docs/WEB-PARITY.md, which is a maintained
per-surface matrix answering exactly the question being asked, and which ADR-0050 point 6
names as the reference. It audited the wrong surface — the site promises native apps, and
`videos` is one of 22 API tags absent from the Swift client, so it is browser-only by
construction. It matched the topic rather than the assertion: the real feature is a
visualizer that searches YouTube. And it left no artefact, so "broadly sound" was
generalised from a handful of positives and nothing could be re-run.

The sweep found four more.

**Mood Grid and 3D Explorer do not exist anywhere.** No component in the repository; the
routes survive only as icon entries in `Sidebar.tsx:42,44`, absent from both `LIBRARY_ITEMS`
and `BROWSER_ROUTES`, with no native equivalent. This is the affordance-with-no-destination
shape the codebase already has a name for, and the site was selling both.

**Semantic search has no UI on any client.** `semantic_search` is an MCP tool with a handler
in `llm/handlers/search.py`; there is no REST endpoint. The headline feature block read as
an in-app search box. It now names the host, and merges with the MCP block that was making
the same point — six feature blocks become five.

**Listening sessions is browser-only by decision** (ADR-0037 rejected), and music videos are
browser-only by construction. Both removed, Jeff's call: they sat two sections below a block
saying the browser is not where you listen.

Everything else checked out and is recorded so it does not get "fixed" later — 36 tools,
the feature columns, the community cache, Music Map on the Mac, offline downloads.

`docs/SITE-CLAIMS.md` is the ledger: every claim, its verdict, its evidence, the date.
`site/scripts/check-claims.py` re-runs the part that rots on its own — version against
CHANGELOG, links, screenshot age, retired terms, dangling anchors. It caught one thing
immediately, and it was its own bug: the demo answers HEAD with 405 while serving fine, so
it now falls back to GET rather than crying wolf about its method.

28 competitor assertions in the comparison table are marked unverifiable and left on the
page pending Jeff's review, as agreed. The ledger is the worklist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Jeff caught the demo link and named the real problem: I had been correcting sentences one
at a time instead of asking what the site should say. The page still described a product
where the browser is the client and an AI chat is the hook, and both stopped being true
months ago.

**The demo link is the clearest case and it is gone.** ADR-0038's opening line is "Apple
App Store review requires a working backend server with test data" — the instance exists so
a reviewer has something to type into the setup screen. ADR-0039 point 8 put it on the site
as "try the live demo", turning a compliance fixture into a product tour. There is no way to
try Familiar without your own server: `SetupView.swift` asks for an address on first launch
and the string `familiar-demo` appears nowhere in the Swift. That also makes `faq.html`'s
"first launch connects to a public demo backend" false, and it is corrected.

**The best thing Familiar does was not on the page at all.** ADR-0048's seeded playlists —
build a playlist from any track, album, artist or selection, scored from the library's own
analysis, with no English sentence constructed in any client — is a better headline than
"connect an MCP host", because it works whether or not a language model exists. It now
leads. It is web-only today: `playlists_generate` is tagged `playlists` so it IS in the
generated Swift client, and nothing calls it. Jeff's call to state the capability without
naming a surface; the ledger records the gap and the native work is the agreed next task.

The page is rebuilt around the actual journey — install the server, point it at your files
in a browser, listen in the apps, connect an assistant if you want one. Nine sections become
four plus the hero; 1,273 words become 758. `#use-case` restated the hero, `#remote-access`
was install documentation that had grown into a peer section and is now step 4 of Install,
`#feedback` was a GitHub link, and `#ask` survives as two prompt->filter examples inline in
the MCP block — which is the proof it was providing, at a tenth of the words.

A fourth claim is new and overdue: Familiar resolves canonical artists and albums, fetches
artwork, and keeps your fixes across a re-scan (ADR-0051, ADR-0052). That is real work the
site had never mentioned.

`check-claims.py` earned itself immediately — the rewrite orphaned `./#ask` and
`./#remote-access` on two other pages, which a dangling fragment never reports at runtime.
36 orphaned CSS rules went with the sections; no selector is now unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
The demo link was removed from the site with two pieces of evidence, and one of them stopped
being true the same afternoon. "`familiar-demo` appears nowhere in the Swift" was accurate
when checked; `familiar-apple` e78a44b now sets the macOS setup field to
`https://familiar-demo.fly.dev`. iOS still starts empty.

The removal stands, and on firmer ground than before: that prefill exists so an App Store
reviewer has something to connect to, which is exactly what ADR-0038 says the instance is
for. It is a filled-in text field, not a connection, and not a product tour. So the ledger
now rests the decision on ADR-0038's reasoning rather than on the absence of a string, which
is the part that could rot.

The FAQ said the app "asks for your server's address the first time you open it". True of
the phone, no longer true of the Mac, and a reader who opens the Mac app and finds someone
else's server already typed in deserves to have been told why. It now says.

Recorded rather than quietly fixed, because a one-day-old citation going stale is precisely
the failure this ledger exists to catch — and it caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
ADR-0031 point 7 deferred casting on the phone and named the bar for revisiting it: "the ADR
that revisits it should measure the drain rather than assume it". Jeff asked for it. This is
that ADR, and its first job is to say that **the cost point 7 names is probably not the cost
that matters.**

Point 7 frames it as a full decode running silently and an audio session held for output
nobody hears. Reading the implementation, the decode is the cheap half. `Casting.swift:322`
mutes the local engine and leaves it running; `observe(_ player:)` derives every speaker
command from the local timeline and nothing polls the device; and the speaker separately
fetches its own audio through `device_stream_base_url`. So a casting phone would pull a
second complete copy of the audio over the network, decode it, and throw it away — purely to
learn when the track ends. On a phone the radio is the expensive part, and it scales with
session length. Point 7 does not mention it.

ADR-0031's alternatives rejected zones, AirPlay-only, fixing the subsystem first, deferring
again, and both platforms at once. **Decoupling the timeline from the decoder is not among
them** — it is unexamined rather than rejected. The client needs one fact from that stream:
when to advance. Crossfade is already suppressed while casting (point 6), so sample-exact
end-of-track is not required, which removes most of the reason to want a decoder at all.

Two things the ADR is careful about, because both are places this could quietly overclaim.

Nothing has been measured, and point 7 requires it. So the measurement is a decision point
with a protocol rather than a footnote, the ADR says it should not be accepted until that has
been run, and it names bytes transferred — not CPU — as the number that decides.

And `PlayableTrack` carries no duration. I said in conversation that the client already knows
how long a track is; that was wrong, and the ADR records it. Duration reaches the row menus
and not the player's queue item, so a timer-driven advance needs the type to grow a field —
optional, falling back to today's behaviour, so it is not a flag day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Point 5 asked for the drain to be measured on a casting phone. A phone cannot cast — that is
what the ADR proposes — so the evidence required to accept it could only be gathered after
the decision it was meant to inform. Circular, and it would have sat there unactionable.

It measures on the Mac instead, which is not a compromise: the Mac casts today, pulls the
same discarded stream from the same server, and the quantity in question is bytes over the
wire, which does not care which machine asked for them. `nettop` against the app's pid, three
readings on one track — playing locally, casting, idle.

Stating the falsifier rather than only the expectation: the ADR claims casting costs about
what playing locally costs, because it is a second full stream. If it measures near idle,
the discarded fetch is not happening the way the ADR reads the code, and it should be
rejected rather than reworded.

The phone battery figure is still worth having, but it is downstream of the bytes rather than
independent evidence, and it cannot be collected until there is something to collect it from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Jeff asked why I could not verify them online. There was no good answer. ADR-0055 point 2
said claims must be "checkable against this repository", and I let that framing turn "not in
the repo" into "unverifiable" — which does not follow, and I had used web search twice
earlier in the same session.

Checked against each product's own documentation. Two cells were wrong, both in Familiar's
favour, which is the direction that matters:

**Plex was written off as "Basic" audio analysis.** It runs a neural network that places
every track in an N-dimensional space and uses it for sonic similarity, track radio and
generated mixes. That is the nearest competitor on the exact axis Familiar claims as its
own, and the table understated it. Now "Sonic analysis (Plex Pass)".

**Spotify was shown as unable to play your own files.** Local Files plays MP3/MP4/M4P and
syncs to a phone over the same Wi-Fi. No FLAC and not remotely — which is a real distinction
and a better argument than a dash. The site's own FAQ already described Local Files while
the table contradicted it.

Two smaller ones. Jellyfin smart playlists are a maintained community plugin rather than
nothing. And "Semantic audio search" became "Search by describing the sound", because Plex's
similarity is track-to-track and real; what Familiar does differently is text-to-audio, so
the row now says that instead of implying nobody else analyses audio.

The table carries the date it was checked and asks to be corrected. That is a promise that
can be kept — not "this is current", but "this was true on a stated day".

ADR-0055 point 2 now says `unverifiable` means nobody can check it, not that it sits outside
this repository, and records why: a comparison table audited only against its own author's
repo will drift towards flattering its author, which is worse than being out of date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
`familiar-apple` #123. The row said `Web ✅ / Mac ✅ / iPhone ❌`, which was true of the whole
capability and is now only true of half of it.

Browsing and playing is on all three. Editing stays macOS-only — the "Create a smart
playlist" and "Delete a smart playlist" rows below keep their ❌ for the phone, because
ADR-0013 point 3 assigns that CRUD to the Mac and nothing here changes it. The note says
which half moved rather than leaving a ✅ that overstates it.

ADR-0050 point 6 makes this file the reference and says it is maintained, so it moves with
the change rather than after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Jeff asked for this after the file produced a false statement in a source comment two
repositories away — I wrote that `playlistsReorderPlaylistTracks` was generated and uncalled,
citing this matrix, while `BrowseStores.reorder` has been calling it since #103.

Eight rows were wrong, all in the same direction: native had more than this said. Playlist
reorder, queue reorder and removal, track metadata editing, library scan, profile creation,
playlist creation on the phone, removing a track from a playlist, and smart playlists. Every
one shipped in a numbered PR and none of them updated the file. ADR-0050 point 6 calls this
document the reference and says it is maintained; it was the reference and it was not
maintained.

**The consequence is larger than the rows.** Point 6 said "settings only" is not reachable
until the Apple clients can edit playlists, edit track metadata, trigger a scan and create a
profile. All four are done. The milestone the ADR was waiting for has arrived, and the file
that was supposed to announce it said the opposite.

Two structural corrections as well. `outputs` sat in the list of tags "not generated, so
browser-only by construction" — nine of its operations are generated by name (ADR-0031), and
the filter names operations rather than the tag precisely so the nine zone operations stay
out. And the file implied the vendored `openapi.json` is filtered; it is not, it is the whole
artifact copied verbatim, so counting its tags proves nothing about reach. I ran exactly that
check during this pass and it briefly looked like the entire list was wrong.

The method is written down now, because three false positives came from grepping for words:
`WindowCapture.swift` matches "restore", `BeatGrid.swift` matches "diagnostics",
`SmartPlaylistEditor.swift` matches "recentlyPlayed". Grep for the generated operation, then
confirm a reachable caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
Jeff asked to start stripping the web app and, offered a scope, chose to settle the boundary
first. That was the right call: the question turned out not to be which routes to delete.

ADR-0050 point 3 recorded the browser's scope as a list of five keeps, and that list has now
failed in both directions. `/playlists/:id` was kept "until the Apple clients can edit a
playlist" — they can, on both platforms, and nothing happened when it became true, because a
condition written into prose has nobody watching it. Meanwhile `/library/artists/:name` and
`/library/albums/:artist/:album` are still mounted and appear in point 3 at all. They were not
decided against; they were not decided, and a list says nothing about what it omits.

The document that would have caught both was itself wrong. The re-verification found nine
stale rows, every one understating native, each from a numbered PR that never updated the
file — and ADR-0050 point 6's own "settings only" milestone had arrived unnoticed.

So this replaces the inventory with the rule it was an instance of: the browser keeps a
capability only while it has no native answer, with two stated exceptions — infrastructural
work aimed at the server rather than at listening, and listen-together, which is web-only by
decision since ADR-0037 was rejected.

The contested part is point 3, and it is written as a decision rather than a discovery: the
fallback player is a flat searchable track list, and browsing is not part of it. Artist and
album drill-down go. The alternative is recorded honestly — from 26,000 tracks, clicking
through to an album is a natural way to find something, and this makes the guest-machine case
worse.

Point 5 is the one that will earn its keep: a capability and its affordances leave in the same
commit. Removing `/playlists/:id` alone would strand five "Make a playlist" buttons, a sidebar
that lists playlists it cannot open, and mobile rows that do nothing — the defect this
codebase has shipped four times.

Checked rather than assumed: `embedBridge.ts` posts its `navigate` intent to the native side
and names API paths, not web routes, so removing the drill-down does not touch the embedded
Discover that both Apple clients render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
3,702 lines out, 34 in. The first strip under the rule ADR-0057 replaced ADR-0050's list
with: the browser keeps a capability only while it has no native answer.

Three routes go — `/playlists/:id`, `/library/artists/:name`,
`/library/albums/:artist/:album` — with the components behind them and, per point 5,
everything that reached them in the same commit. That last part is most of the work and the
reason the diff is not three files: five "Make a playlist" affordances, `useGeneratePlaylist`,
the sidebar's playlist and smart-playlist sections, the mobile nav's playlist rows, two
context menus, an edit modal, a row component, and the queries that fed them.

**"Go to artist" survives as a filter rather than dying.** ADR-0057 point 3 makes the
fallback player a flat searchable list, and a filtered flat list is still a flat list —
`navigateToAlbum` already worked that way, so `navigateToArtist` now does too. Removing the
menu item would have been the easy reading of the ADR and a worse one: finding an artist's
tracks in a list of 26,000 is exactly what that player is for.

Two things fell out that predate this ADR.

`LegacyRedirect` — 82 lines migrating a hash and query-param URL scheme — pointed at
`/home`, `/favorites`, `/downloads`, `/library/music-map`, `/library/discover` and
`/library/proposed-changes`. **Not one of them has been a mounted route.** It was migrating
old URLs to newer URLs that no longer exist, and its default landing was `/home`, which falls
through to the catch-all.

And `SmartPlaylistDetail`, 531 lines, whose route `/smart-playlists/:id` was never mounted
either. The sidebar linked to it; nothing served it. Deleted here rather than left, though it
was dead before this ADR and is not strictly in its scope.

Checked rather than assumed, because ADR-0050 point 4's trap is exactly this: `EmbedDiscover`
always supplies `onGoToArtist`, `DiscoverBrowser` is mounted only in the embed, and
`embedBridge` posts to the native side naming API paths rather than web routes. Removing
these routes cannot reach the embedded Discover both Apple clients render.

Typecheck holds at the 14-error baseline, all 63 test files and 952 tests pass, and the web
app builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
ADR-0058, proposed: the web app is an administration tool, and the first thing an
administrator wants is the state of the thing they administer — not a settings form
with playback controls above library health.

The dashboard itself is an unbuilt front end rather than a new feature. Both endpoints
already existed and both already had a client wrapper nothing called: libraryApi.getStats
over /library/stats, playTrackingApi.getStats over /tracks/stats/plays. The four
pending-analysis queues are shown separately (point 7) because they are distinct backlogs
with distinct version constants, and one number hides which is stuck.

Pointing it at the real 26k library is what turned this into a backend change. Every total
on /library/stats disagreed with the screen it links to:

  total_tracks   26,488 vs 26,422 — no status == ACTIVE filter, so 66 missing/deleted
                 files counted as library size
  total_albums   3,873 vs 3,927 — count(distinct Track.album) is a *string* distinct and
                 merges same-titled albums by different artists; the album list groups by
                 (album_artist, album), case-insensitively
  total_artists  3,664 vs 3,477 — raw tag strings, so "The Beatles" and "Beatles, The"
                 count twice; the artist list reads canonical Artist (ADR-0052)

Each now counts the way the list endpoint counts. Scoping the totals to active tracks
without also scoping the analysis counts would have rendered a *negative* backlog — -41
pending on the real library — so those join Track too, and pending_analysis is clamped.

albums/compilations/soundtracks cannot be fixed: nothing writes Track.album_type, so every
row keeps the column default. That breakdown reads "26,488 albums · 0 compilations" on a
library ADR-0052 found 297 compilations in. A consumer with no producer — the mirror of the
generated-and-uncalled shape this codebase keeps finding. They stay on the wire because
`library` is a generated tag (ADR-0007) and dropping required fields breaks the Swift
client; they are deprecated in the model and the type, and displayed nowhere. REST-API.md
had been documenting 150 compilations and 45 soundtracks, which no instance ever returned.

tests/test_library_stats.py asserts stats against the list endpoints rather than against
constants — "expect 3 albums" passes under either counting method, which is how this drifted
for as long as it did. All five fail against the previous implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…058 phases 2-3)

ADR-0058 accepted. The sidebar listed library browsers — a music player's shape — and
Settings was one scroll of nine sections, five of them listener preferences.

Now three destinations (point 2): Library (dashboard, scan, analysis, artist cleanup),
Tools (backup, community cache, the fallback player), Server (health, keys, profiles,
Last.fm, diagnostics). Settings keeps theme, playback and offline — the two waves point 5
describes, with theme outliving the player because it styles this interface.

Deleted per point 5: ShuffleWeightSettings, RadioSettings, AudioEffectsSettings,
QueueSyncSettings. The capabilities are unaffected — native owns them per-device
(ADR-0029), and in the browser they stay reachable from the player's own chrome
(ShuffleWeightPopover, EffectsQuickAccess), which retires with the player. No store was
orphaned; all three still have player consumers.

Queue sync needed care: its flag is persisted, so deleting the only toggle would leave a
device that had switched it on mirroring forever with nothing to switch it off. The store
gains version 1 and a migrate that forces it back off — where its own docstring says a
rollout gate should start.

**Phases 2 and 3 shipped together, against the plan.** Routing /tools and /server before
moving content into them would have created destinations with nothing mounted, which is
precisely the defect ADR-0057 point 5 exists to prevent.

Rewriting the sidebar found three affordances that already led nowhere:

  - /favorites and /downloads had been linked since the ADR-0057 strip with no route in
    App.tsx — both fell through to the catch-all and silently redirected home
  - the mixtape export modal's only setMixtapeSource call was its own onClose, so nothing
    could ever open it

navigationIntegrity.test.ts already claimed to catch "a sidebar link pointing to a route
that doesn't exist" and did not, because it read the registry and those two were hardcoded
in the component. It now reads App.tsx source and fails on any link or navigate() to an
unmounted path — verified by pointing a link at /favorites and watching it fail.

Two things point 2 names are deliberately not linked, for the same reason: pending review
has no web component (api/pendingTracks.ts is another uncalled wrapper), and duplicates and
the organiser are phase 4. Recorded in UNBUILT_DESTINATION_ITEMS rather than omitted
silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant