Skip to content

Say what codecity is on the page that covers everything else - #155

Merged
thalida merged 3 commits into
mainfrom
feat/switcher-meta
Aug 9, 2026
Merged

Say what codecity is on the page that covers everything else#155
thalida merged 3 commits into
mainfrom
feat/switcher-meta

Conversation

@thalida

@thalida thalida commented Aug 9, 2026

Copy link
Copy Markdown
Owner

The project switcher is position: fixed; inset: 0; z-index: 1000, so it covers the app header and footer. On a cold boot nobody saw the version, the repo link or the credit until they had already loaded a repo. All three now sit under the wordmark, in both landing modes since the switcher covers the chrome either way.

One definition each

That made a third surface showing the same three things, so they moved to components/AppMeta: the version, the about link, and the credit, once each. The wording, the URLs and the link targets can no longer drift between the header, the footer and the landing. FooterMeta.tsx is gone.

Link treatment is the surface's call via a linkClass prop, using the classes styles/text.css already defines rather than a fourth bespoke one:

  • .link on the landing: prose, so accent and always underlined
  • .link--chrome in the header and footer: accent, underline on hover

That distinction is the point. An icon button reads as clickable by being an icon; a bare word doesn't, so the text links stopped borrowing the icons' resting muted colour. The project chip goes to --cc-text-strong for the same reason, and it's the header's main control besides — its hover colour was then a no-op, so the background lift is the whole hover signal now.

Fixes found while looking at it

  • The landing's meta line had the footer's 10px. That suits a 24px status bar, not a full-screen hero under a 28px wordmark. It's --cc-font-sm now. The size had been copied without being re-decided for the context.
  • The hero had a ragged left edge. The meta line sat inside the brand row, so it inherited the wordmark's indent past the gem while the tagline and cues started at the hero's left. One offset line reads as the gem's partner; two read as a mistake. Brand and meta now share a block at a tighter gap than the hero's own rhythm, so the line belongs to the wordmark rather than floating between it and the tagline.
  • The credit vanished on small screens, everywhere. Renaming .app-footer-credit to the shared .meta-credit left the footer's narrow-viewport rule matching the landing too, ending its line on a dangling separator. Scoped, then deleted: that rule dated from the credit being centred in a three-track grid, and direction C gave it its own track and dropped the footer to two. It outlived its reason.
  • The status timestamp went the same way. The bar holds two short items per side and each section clips with an ellipsis, so hiding either traded real information for space that wasn't needed.

Verification

just test-app (2903) and just lint green; the pre-push gate passed. Four new tests cover the landing's identity line in both modes, including both link targets and their rel. Breaking the credit URL fails three tests across the landing and the footer, so they bite.

Verified in a browser at desktop and narrow widths, since jsdom computes neither layout nor colour.

🤖 Generated with Claude Code

thalida and others added 3 commits August 8, 2026 22:35
The landing is fixed over the whole viewport, so it hides the header and
footer: on a cold boot nobody saw the version, the repo link or the
credit until they had already loaded a repo. The three now sit under the
wordmark, where attribution belongs on a landing page, in both landing
modes since the switcher covers the chrome either way.

That made a third surface showing the same three things, so they move to
components/AppMeta as one definition each and the wording, URLs and link
targets can no longer drift. Colour stays with each surface: the header
holds its link at icon weight, the footer lifts it above the prose, the
landing sits it between the two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A word doesn't read as clickable the way an icon button does, so the
three text links stopped borrowing the icons' resting colour. The house
classes already drew this line: .link for prose (accent, always
underlined) on the landing, .link--chrome for chrome (accent, underline
on hover) in the header and footer. AppMeta takes which one as a prop
rather than inventing a fourth treatment.

The project chip goes accent for the same reason, and it's the main
control in the header besides.

The landing's line also grows two steps. It had the footer's 10px, which
suits a 24px status bar and not a full-screen hero under a 28px
wordmark: the size was copied without being re-decided for the context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extracting the meta atoms renamed .app-footer-credit to the shared
.meta-credit but left its rules where they were, so a narrow-viewport
rule meant for the footer hid the credit on the landing too, leaving its
line ending on a dangling separator.

The footer rule itself was also stale. It dates from the credit being
centred in a three-track grid, where a narrow viewport genuinely squeezed
it; direction C gave the credit its own track and dropped the footer to
two, which removed the crowding, and the rule outlived the reason for it.
The status timestamp goes the same way: the bar holds two short items per
side and each section already clips with an ellipsis, so hiding either
traded real information for space that wasn't needed.

The hero's identity block also regroups. The meta line sat inside the
brand row, so it inherited the wordmark's indent past the gem while the
tagline and cues started at the hero's left edge: one offset line reads
as the gem's partner, two read as a mistake. Brand and meta now share a
block at a tighter gap than the hero's own, and the gem gets more room
before the wordmark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thalida
thalida merged commit 3298608 into main Aug 9, 2026
1 check passed
@thalida
thalida deleted the feat/switcher-meta branch August 9, 2026 02:55
thalida added a commit that referenced this pull request Aug 10, 2026
Both bars had accreted, each element sitting in whichever one it was
added to. Sorted by one rule, three things move.

The freshness readout goes up. It's a fact about the project, and it's
what refresh acts on, so leaving it bottom-left while its new button sat
top-right would have repeated the exact mistake the split button exists
to fix. It's now components/FreshnessStatus, still self-reading, sharing
a cluster with the refresh control.

About and the keyboard shortcuts come down, because neither is about the
repo you have open. The footer's right-hand run becomes <MetaLine> whole
rather than a hand-assembled version + credit with `about` upstairs. That
puts `about` back in the footer, which is closer to where #152 had it;
#155 argued for the landing carrying all three and for link colour per
surface, not for `about` living in the header, so this applies its
principle to an incidental split.

Refresh is one control with two ways to press it, and the same words for
either kind of source: "check for changes and re-scan if anything moved",
"ignore the cache and re-read the whole repo". Whether checking means
stat-walking a working tree or fetching a remote is mechanism, and the
menu has no business narrating it. Glyph only in the bar, where the word
would cost more room than it earns.

The auto-refresh toggle sits in that menu's footer: same subject, not one
of its actions. On a remote it keeps its shape and goes inert, switch
visible but off and not focusable, with "only for local folders" where
the cadence would be. Dropping the row would change the menu's height per
source; a live-looking switch would imply it could be turned on. The
cadence is a link to the setting that owns it, never a second number
input for a value the schema already bounds.

"?" opens the shortcuts panel. Demoting that button to a 24px glyph is
only acceptable if the panel is reachable without hunting for it. It does
nothing while you're typing, in an input, a textarea, or inside a
contenteditable, matched with closest() because the caret's event lands
on a child node. All four of those go red when the guard is removed.

Clusters outline the group, not each control, with hairline dividers
inside. The border says these belong together, the divider says these are
separate presses, and six pills in a 32px bar would say neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thalida added a commit that referenced this pull request Aug 10, 2026
…ive featured city (#157)

* Tell the client which kind of instance it's talking to (#156)

`allowLocalRepos` collapses two situations that need opposite advice. A
hosted instance can never resolve a local path; a local instance that
hasn't mounted anything is ten seconds from working. Both report false,
so the "how to enable local paths" notice is wrong on exactly the
instance most people meet first.

/api/config gains `hosted`, set by CODECITY_HOSTED=1 in the deploy env.
It fails closed to false the way allowLocalRepos already does: a
local-flavoured message on a hosted instance is the smaller error.

CODECITY_DISCOVER and CODECITY_DISCOVER_FILE land here too, ahead of the
endpoint that reads them. Discover ships on, so its flag needs the
mirror of the truthy set rather than the truthy set itself: with
env_bool, CODECITY_DISCOVER=enabled would read as off and silently empty
the tab.

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

* Let `just dev` set the flags it exists to exercise (#156)

Testing a CODECITY_* flag locally meant editing docker-compose.dev.yml or
abandoning `just dev` for a raw `docker run`. The recipes are wrappers
over docker, and a mount and an env var are both docker concepts, so they
now take docker's own flags:

    just dev -v ~/Documents/Repos/myproj -e CODECITY_HOSTED=1

The mount stops being a bare positional. One flag per concept means
nothing has to be guessed from an argument's shape, and there is no
ordering rule to remember. `-v` takes a plain path rather than
SRC:DST:MODE because codecity supports exactly one mount shape, read-only
at the same absolute path, so the other two fields have one legal value
each.

bin/docker-args.py holds the parsing and emits either a compose override
or `docker run` flags, which keeps twenty lines of sh out of the recipes
and lets `dev` and `run` share one definition of what the arguments mean.
A mount still implies CODECITY_ALLOW_LOCAL_REPOS=1, now as a default an
explicit -e replaces rather than a line that fights it.

bin/ joins the ruff gate. It was the only Python outside it, which is how
pick-port.py drifted a blank line from formatted.

The README gains the flag table this makes reachable. It was documenting
CODECITY_ALLOW_LOCAL_REPOS inline in a docker run and nothing else, so
there was no one place that answered what codecity reads.

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

* Say why a repo couldn't be reached, in a field (#156)

The stream's `error` event carried a message and nothing else, so the
only way for the client to tell "that repo isn't reachable" from any
other failure was to match on the text. Nobody does that twice without
regretting it, and the remedy this unlocks is worth being sure about.

ErrorEvent gains a `code`, an enum whose only member so far is
repo-not-found. It is absent rather than null when there is nothing to
say, matching how the other optional wire fields behave, so the client
never has to tell those two apart.

The code is set for a remote not-found only. GitHub returns 404 to an
unauthenticated caller whether the repo is private or the URL is a typo,
so the message this unlocks can never claim the repo is private. A local
path that isn't there is a different thing entirely: the user can see the
path they typed, and private-repo guidance would be nonsense.

ResolveError carries the code rather than the manifest route
special-casing its own catch. The timeline route resolves through
resolve_source, which flattens every clone failure into a ResolveError,
so a code that lived only in the route would be dropped on the way
through. One rule now: a ResolveError's code goes on the wire.

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

* Serve a hand-picked list of repos worth rendering (#156)

Nothing on the landing page suggested what to open. Pasting a URL
presumes you already have one in mind, and the obvious way to find one,
GitHub's most-starred repos, is a bad source: of the top thirty, twelve
are markdown awesome-lists with no city to speak of, and most of the rest
are gigabyte clones. The API's `size` is checkout size, so it can't even
filter on commit count, which is what actually drives scan cost.

So the list is hand-picked and served from a file: five repos chosen for
rendering well and scanning fast. No stars, no timings, no third-party
call at request time. CODECITY_DISCOVER=off hides the tab and
CODECITY_DISCOVER_FILE swaps the file, so an operator can curate their
own without a rebuild.

Every failure mode answers 200 with an empty list, and says so in the
log: disabled, file missing, unparseable, right JSON but wrong shape.
This is one tab on the landing page, and a typo in a hand-edited file
should not be able to take the landing down. Breaking the catch turns six
of the thirteen tests red, so the guard is doing work.

One entry is on codeberg rather than GitHub, which keeps the row icon's
generic fallback on a path someone actually walks.

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

* Fetch the curated list at boot, next to the config (#156)

api/discover.ts mirrors api/config.ts: one memoized fetch, failures
resolving to an empty list rather than rejecting. Discover's tab hides
itself when the list is empty, so "the server switched it off" and "the
request fell over" want the same handling and get it.

Rows missing a url or a label are dropped here. A row with no url has
nothing to open and a row with no label has nothing to click, and the
alternative is rendering a blank line that looks like a bug.

Both boot reads go out together. They have no dependency on each other,
and awaiting them in series would make the landing wait two round trips
to draw a tab.

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

* Stop hand-rolling env handling, and split the files by who owns them (#156)

Two problems that turned out to be one.

api/config.py parsed env vars by hand: a truthy set, then a second
falsey set once a flag needed to default on, and a bool parser that read
anything unrecognized as false. CODECITY_DISCOVER=enabled would have
silently emptied the Discover tab. pydantic-settings does this properly,
so Settings is now typed fields with a CODECITY_ prefix and an unknown
value raises instead of guessing. env_bool and both string sets are gone.

The api still reads the environment and nothing else, deliberately. A
.env file loaded in-process would mean a test run picking up whatever is
in the developer's file, and .env.local is about how you *launch*
codecity, not what codecity is.

The files themselves had grown three inconsistent homes: a tracked .env
for version pins, .local/deploy.env for credentials, and CODECITY_QUIET
hardcoded into the dev compose. The axis that matters is whether everyone
shares the value, so there are two files: .env tracked for what's shared,
.env.local gitignored for everything that's yours, credentials included.
.local/ goes back to being generated state that's always safe to delete,
which it was not while it held the only copy of a deploy token.

`just dev` reads .env.local, so a flag flips in a file and the next run
picks it up, with -v and -e still available for a single run. Only
CODECITY_* keys are forwarded into the container: the Forgejo token lives
in the same file now and has no business in the api's environment.

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

* Make the generated files say that they are generated (#156)

Two files in .local/ look like config until you know better. Both are
compiled output, and neither said so.

.local/dev.override.yml gets a header naming what wrote it, when, and the
two places worth editing instead: .env.local for a standing setting, a -v
/ -e flag for one run. It is rewritten from scratch every `just dev` and
deleted when there is nothing to pass, so an edit made there is lost
without warning, which is worth saying on the file itself.

.local/openapi.json can't carry a comment, being JSON, so the name does
the work: openapi.generated.json. It is the intermediate between the two
halves of `just gen-types` and nothing else reads it. The recipe now says
why it's kept on disk rather than piped, and distinguishes it from the
live /api/openapi.json route, which is a different thing with nearly the
same name.

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

* Add a split button, for an action with a rarer sibling (#156)

Two places need the same shape: Open / Open with a fresh scan, and
Refresh / Fresh scan. Both are one action with a variant that differs
only in whether it trusts the cache, and both currently express that
badly: an Advanced disclosure hiding a single checkbox, and nothing at
all.

The default stays one press and the variant costs two, which is the
point. It reads as a modifier on the action rather than a second button
competing with the first.

Keyboard behaviour is the part worth building once: Down opens from the
caret, the first item takes focus so the eye and the focus ring stay
together, Up/Down wrap, Home/End jump, and Escape closes and puts focus
back on the caret. That last one is not decoration: the item that had
focus has just been removed, and a detached activeElement drops the user
at the top of the page. Deleting the refocus turns that test red.

Selecting an item closes without restoring focus, because what follows
usually moves focus itself (a form submit, a reload) and pulling it back
to the caret first would fight that.

The footer slot exists for the auto-refresh row, which belongs with the
refresh menu without being one of its choices, so it sits below a rule
and outside the menuitem set.

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

* Say what to do about a repo codecity can't open (#156)

The old notice read "Local paths aren't enabled. How to enable" on every
instance, including the hosted one, where following that link leads to
instructions you cannot act on. It also only ever appeared as standing
guidance: a repo that failed to load got the raw server message and no
suggestion at all, which is issue #120.

One component now answers both. The insight is that the remedy depends on
the SERVER, not on what went wrong: what you can do about a repo you
can't open is a question of whether this instance reads local paths, and
whether that's fixable. Three columns, and the trigger only decides
whether "Couldn't reach that repo." sits in front.

  allowLocal              -> clone it and open the folder
  !allowLocal && hosted   -> run codecity on your machine
  !allowLocal && !hosted  -> mount a folder and set the flag

The `git clone` line appears only in the first column. On hosted it would
be telling you to do something the app then can't help with, and on an
unmounted local instance it's half a fix.

Two things the copy must never say, both tested. It never asserts the
repo is private: GitHub 404s a private repo to an anonymous caller
exactly as it 404s a typo, so the two are indistinguishable from here.
And it never says "you don't have access", which blames the user for a
property of this server.

The error variant is keyed on the code from the last commit, threaded
through as a ScanError so the throw doesn't flatten it to a string.
Nothing matches on message text, which is the server's to reword.

Both invariants are guarded rather than merely asserted: branching the
remedy on the variant turns two tests red, and leaking the clone line
into the hosted column turns another red.

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

* Put the fresh scan on the open button, not behind Advanced (#156)

"Advanced" was a disclosure hiding one checkbox. It cost a click to
discover, read as a section of settings when it held a single option, and
put a checkbox next to the button rather than on it, so choosing the
variant meant tick-then-press instead of one gesture.

Skipping the cache is a way of opening, not a setting, so it belongs on
the open control. Open project becomes a split button whose menu carries
"Open with a fresh scan". The default is still one press and the variant
is two, which is the right ratio for how often each is wanted.

The primary half stays the form's real submit, so Enter in the field and
a click on the button remain the identical path rather than two branches
that have to be kept in agreement. The menu item is type="button" for the
same reason in reverse: it sits inside the <form>, and a stray submit
would fire a plain open alongside the fresh one. Tested, since that is
invisible until it happens.

The old aria-label duplicated the visible label; the split button takes
its accessible name from the text, so there is one name, not two.

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

* Say it in fewer, plainer words (#156)

The unmounted-local remedy shipped an env var name and a mount
instruction at someone who was only trying to open a repo. It's back to
the one line it needs, with the setup detail behind the link, which is
where a person actually self-hosting will look for it.

Not "access to private or local repos is disabled", which was the other
candidate: enabling local paths does nothing for a private remote repo,
so that would promise a fix it can't deliver.

The hosted remedy loses "so it can only open repos it can reach
anonymously", which was explaining the mechanism rather than the way out.

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

* Split the row out of Recents, and drop the last of the jargon (#156)

Discover needs the same row Recents already draws, because a Discover
entry is a recent you haven't opened yet and making them look different
would imply a difference that isn't there. So the presentation moves to
components/SourceRow: glyph, label, branch pill, src. What's left in
RecentRow is what only a recent has, an Active badge and a remove
control, both passed in rather than assumed.

The classes rename with it. .recent-row on a Discover row would have been
a lie about which list you were looking at, and the CSS is shared now.

Copy: the hosted remedy was explaining the deployment ("this is the
hosted instance, so it can only open repos it can reach anonymously")
when the reader wants the way out. It now matches the shape of its
sibling: one plain sentence, then a link.

The recents hover title had the same fault, telling someone who clicked
a greyed-out row to restart the server with an env var set. There is no
env var left anywhere in the UI; that detail lives in the README, behind
the links, where a self-hoster will look for it.

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

* Give the how-to link its own line (#156)

The card is narrow enough that a trailing inline link breaks mid-phrase,
so "How to run it" rendered as "How to run" then "it" on the next line,
which reads as two things. Both remedies put the link on its own row
under the sentence, and align-self keeps the underline the width of the
words rather than the whole row.

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

* Shorten the link to "See docs", inline and unbreakable (#156)

"How to run it" was long enough to wrap mid-phrase in a narrow card, and
the previous fix (its own line) made a two-word link look stranded under
its sentence. Both remedies now use the same short label, back inline
where it belongs, with a non-breaking space inside the link so it can
never split across lines. The sentences around it still wrap normally.

Same label for both destinations: in context, directly after the
sentence that says what you'd be reading about, the target is never in
doubt.

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

* Give the landing somewhere to look besides the URL field (#156)

Pasting a URL presumes you already have one in mind. Recent and Discover
now share one card behind a PaneTabs strip, so the action column gains a
way to browse without gaining a second panel, which is what would have
happened if Discover had arrived as its own card.

Recent is always offered, empty state and all. Hiding it until you had
one meant a first visit never learned that codecity remembers what you
open. Discover appears only when the server actually sent a list, so
CODECITY_DISCOVER=off removes the tab rather than showing an empty one.
The default tab is whichever has something in it: Discover on a first
visit, Recent once you have any.

The active tab falls back during render rather than being corrected by an
effect. The curated list arrives after first paint and can take its tab
with it, and a stored id would point at a tab that no longer exists until
the effect caught up.

Reusing PaneTabs inherits its roving tabindex, arrow keys with wrap, and
Home/End, all of which a lookalike strip would have quietly dropped. The
strip is the card's heading now, so the old "Recent projects" title goes:
a heading above tabs names the card twice.

The unreachable-source notice moves to the top of the open card, under
its heading. It says what this instance can open, which is context for
filling the field in rather than a footnote on the result, and it leaves
Open project as the last thing in the card.

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

* Play the demo full-bleed on cold boot, under the swirl (#156)

The design had the clip as a 21:9 panel inside the hero column. Built, it
was obviously the weaker idea: the switcher over a loaded city is the
best-looking thing in the app, and a small framed video beside some text
is not that.

So cold boot borrows the same staging. The clip plays full-bleed and
covered, the swirl backdrop sits over it, and the hero scrim that already
kept the modal's text legible over a live city now covers both modes,
since both have something moving behind the words.

Two changes make the layering work. The backdrop shader carried alpha 1
and mixed its calm areas into the background colour, which would have
painted over the clip entirely; it now writes the energy mask as
premultiplied alpha, so the ribbons float and the city plays through the
quiet parts. And .landing's opaque fill is scoped to cold boot, because
in modal mode that fill would hide the very city being revealed.

The asset changes with the placement: no 21:9 crop, since covering a
viewport wants the source's own framing, and CRF 36 at 1280 because it
sits under a scrim and a swirl where finer detail is spent on something
nobody sees. 999K, and it autoplays on every cold boot.

Reduced motion keeps the poster and stops the orbit. That has to be a
JS decision: CSS cannot stop a <video>, so the earlier @media block was
doing nothing. Breaking the check turns its test red.

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

* Stop a bare `canvas` rule painting over every canvas in the app (#156)

CenterPane.css styled `canvas` unqualified, including an opaque
background meant to stop the city flashing through during a resize. That
reached the landing backdrop too, so the swirl sat on a black plate and
hid the clip behind it no matter what alpha the shader wrote. Scoped to
`#center-pane canvas`, which is the canvas it was written for.

Alongside: the backdrop's ribbons cap at 55% opacity, since over a moving
city "fully opaque where the swirl is prettiest" is the wrong tradeoff;
and the two backdrop layers move into one .landing-stage so their order
is plain document order rather than three negative z-index values that
have to agree.

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

* Drop the opacity cap the black plate made look necessary (#156)

The ribbons were capped at 55% because they appeared to hide the clip.
They weren't: a bare `canvas` rule was painting an opaque background
under them, and the cap was dimming the swirl to compensate for a bug one
line away. With that fixed the mask stands on its own again.

The premultiplied alpha stays. That is what lets the swirl sit over
anything at all, rather than mixing its calm areas into a background
colour and covering what's behind.

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

* Let the server name a featured repo (#156)

One env var, CODECITY_FEATURED_REPO, for the repo the landing puts front
and centre. It shows up in two places and both read the same value, so
they cannot disagree: /api/config carries it for the landing to render
behind itself, and /api/discover flags it so the tab says which one
you're looking at.

Named "featured" because the two obvious alternatives are taken:
"showcase" is the switcher's camera treatment and "demo" is the README
video, and reusing either would make grep lie.

The featured entry leads the Discover list. Someone wondering what's
behind the page should find it as the first row rather than hunting for
a badge. Curating it into discover.json by hand marks that entry rather
than adding a near-duplicate, matched on URL.

Off by default. A fresh install must not clone a repo to decorate a page
nobody asked to decorate; the public deployment sets it, where one warm
cache serves every visitor. Switching Discover off hides the tab and the
featured row with it, but leaves the backdrop alone: rendering a city is
not a Discover feature, which is why the landing reads the URL from
/api/config rather than from the list.

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

* Render the featured repo behind the landing, for real (#156)

The clip is gone. A video of a city, at a fixed resolution, soft wherever
the display is bigger than the encode, when the app renders cities. Cold
boot now draws the server's featured repo with the same renderer and the
same turntable the switcher uses over a loaded project, so the landing
and the switcher are one staging rather than two impressions of it.

useFeaturedCity applies the manifest STRAIGHT TO THE SCENE and never
writes MANIFEST. That signal means "the project you opened", and half the
app reads it: the sidebar unhides its panes, the title changes, App
decides whether a failed load may be dismissed. A backdrop is not an
opened project. Every failure is silent, because a featured repo that
won't clone should leave the landing exactly as it was without the
feature.

The notice on a row went through three wrong shapes before this one. It
was the server's `featured` flag, so it announced "on screen" while you
were looking at something else. Then it was two words, Active and
Featured, which made a reader work out which list they were in. It is one
word now: Active, meaning this repo's city is the one you can see, from
one ACTIVE_SOURCE that both lists read. Calling a repo featured while
you're looking at a different one was describing configuration, not
anything on screen.

Getting that right needed source identity handled honestly at both ends.
Identity includes the branch, so the featured city records the branch it
loaded or it fails to match its own recents row (which stores @main); and
a Discover row names a repo rather than a branch, so it compares on src
with the branch dropped from both sides. Comparing both lists the same
way marked one and missed the other, in whichever direction the shapes
leaned. Both directions are now tested, and both tests go red when their
half is reverted.

The landing names what it's showing, and only once the city has painted,
so it cannot name a repo you can't see.

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

* Fix the switcher opening on a tab you picked last time (#156)

Opening the switcher from a loaded project sometimes landed on Discover,
however many recents you had. The view returns null while hidden but
stays mounted, so its useState survived the close and a tab picked once
became the tab you got every time after. It resets on close, not on open,
so the next open renders the default straight away with no visible flip.
The file's header comment claimed state reset itself on close; it doesn't,
and now says what actually has to opt in.

The chrome hides whenever the landing is up, not only in the dismissible
mode. The landing has no background of its own now that a city renders
behind it, so the header and footer strips would have shown through at
the top and bottom instead of the city. One writer for the class, keyed
on PROJECTS_VIEW; useSwitcherShowcase goes back to being about the camera.

Also here, ahead of the chrome resort:

The drag-region code is gone. `-webkit-app-region: drag` was carried for
a native frame that does not exist: pywebview appears nowhere in this
repo but the comment claiming it. Eight call sites had cargo-culted the
no-drag marker, and the two comments describing it disagreed about which
framework it was for. Grep for app-region or no-drag now returns nothing.

The status indicator moves to components/FreshnessStatus, self-reading as
it already was, so the header can hold it next to the refresh control
that acts on it.

The sidebar's active tab and collapsed state lift into the store, so the
header's auto-refresh row can point at the Updates settings instead of
growing a second control for a bounded value.

And LIVE_UPDATES_ACTIVE's comment stops claiming a remote's signature
cannot move. ensure_clone fetches and resets on every open, so it can.
The conclusion holds for a different reason: polling one means a git
fetch every few seconds for a repo that changes on someone else's clock.

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

* Sort the chrome: header is the project, footer is the app (#156)

Both bars had accreted, each element sitting in whichever one it was
added to. Sorted by one rule, three things move.

The freshness readout goes up. It's a fact about the project, and it's
what refresh acts on, so leaving it bottom-left while its new button sat
top-right would have repeated the exact mistake the split button exists
to fix. It's now components/FreshnessStatus, still self-reading, sharing
a cluster with the refresh control.

About and the keyboard shortcuts come down, because neither is about the
repo you have open. The footer's right-hand run becomes <MetaLine> whole
rather than a hand-assembled version + credit with `about` upstairs. That
puts `about` back in the footer, which is closer to where #152 had it;
#155 argued for the landing carrying all three and for link colour per
surface, not for `about` living in the header, so this applies its
principle to an incidental split.

Refresh is one control with two ways to press it, and the same words for
either kind of source: "check for changes and re-scan if anything moved",
"ignore the cache and re-read the whole repo". Whether checking means
stat-walking a working tree or fetching a remote is mechanism, and the
menu has no business narrating it. Glyph only in the bar, where the word
would cost more room than it earns.

The auto-refresh toggle sits in that menu's footer: same subject, not one
of its actions. On a remote it keeps its shape and goes inert, switch
visible but off and not focusable, with "only for local folders" where
the cadence would be. Dropping the row would change the menu's height per
source; a live-looking switch would imply it could be turned on. The
cadence is a link to the setting that owns it, never a second number
input for a value the schema already bounds.

"?" opens the shortcuts panel. Demoting that button to a 24px glyph is
only acceptable if the panel is reachable without hunting for it. It does
nothing while you're typing, in an input, a textarea, or inside a
contenteditable, matched with closest() because the caret's event lands
on a child node. All four of those go red when the guard is removed.

Clusters outline the group, not each control, with hairline dividers
inside. The border says these belong together, the divider says these are
separate presses, and six pills in a 32px bar would say neither.

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

* Make the chrome bars one size, and stop the cluster clipping its menu (#156)

Four faults in what I put in, all of them things arithmetic or a look at
the mock would have caught before a screenshot did.

The bars were 32px and 24px. That was survivable while one held a credit
and the other held controls; now that both hold a cluster of the same
controls it reads as a mistake. One --cc-chrome-h token, used by both, so
they cannot drift again, and sized so a cluster clears the edges.

The cluster clipped its own dropdown. overflow: hidden was rounding the
children to the pill, which also cut the refresh menu off at the bar. The
end children round themselves instead, in logical corners, and the split
button's outer half takes the radius since it is one item made of two
presses.

Neither bar had a stacking order, so a menu opened from either painted
UNDER #app-body, which comes later in the DOM. Both are positioned with a
z-index now.

The cluster's items carried 4px of vertical padding on top of their own
24px height, making a 34px cluster inside a 32px bar. Padding is
horizontal only; the items already carry the height. And the cluster's
height is explicit rather than inherited from its tallest child: the
freshness cluster holds only text and a split button, so "as tall as its
children" and "children fill it" defined each other and collapsed.

Borders moved from border-subtle to border-input. Subtle sits ~0.02
lightness above the chrome bar, which is not an edge anyone can see.

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

* Standardize the cluster items, and cut the comments back (#156)

The two header clusters disagreed because only one of them was styled by
the shared rules: the refresh button had a one-off in AppHeader.css.
chrome.css now defines what counts as a cluster item once, a split button
contributing its two halves rather than itself, and padding, dividers and
corners all key off that. The one-off is gone.

Three visual bugs, each from a rule fighting a fixed value rather than
anything subtle:

  .btn-icon--sm sets height: 22px, and an explicit height beats
  align-self: stretch, so the glyph pinned to the top of a 24px cluster
  and every bit of slack fell below it. height: auto lets it stretch.

  The end items sat 10px in, inside the pill's own 13px curve, which cut
  into the status dot. They clear it now.

  #app-header .btn-icon svg { display: block } was header-scoped, so the
  footer's glyphs never got it. It lives with .btn-icon.

Comments trimmed throughout to the non-obvious why. What I wrote narrated
history ("before this", "used to", "this lived in X"), which is exactly
what the house style says not to do, and a twenty-line block header on a
seventy-line stylesheet is its own kind of noise.

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

* Give the chrome bars one shared item, and delete the overrides (#156)

The clusters were built by reaching in from outside: each control brought
its own box (.btn-chip, a 24x24 .btn-icon, a bare span, a split button's
wrapper) and the cluster CSS tried to normalize five different ones. Every
override to line up a divider deformed one of them, and .btn-icon's
width:24px has the same specificity as the rule fighting it, so bundle
order decided the result.

Inverted: components/ChromeCluster owns the box. ChromeCluster draws the
outlined pill; ClusterButton / ClusterLink / ClusterText render the item,
one height, one padding, one divider rule. Anything that appears in a bar
takes that box instead of its own, so there is nothing left to override.
ProjectSwitcher, CopyButton (a variant, since it also appears in panes),
FreshnessStatus and SplitButton's chrome halves all go through it, and
.btn-chip is deleted with its last caller.

SplitButton renders NO wrapper in a chrome bar. The wrapper existed only
to position the menu, and its two halves being nested inside it is what
forced selectors like `> * + * > .cluster-item:first-child` — which is
where the double dividers, the wrong-side rounding and the vanishing seam
all came from. The halves are direct children now and the cluster is the
positioning ancestor, so the rules are plain sibling selectors.
display: contents was the other candidate and would not have worked: it
changes layout, not selector matching, so the halves would still not have
matched as children.

Accent now means pressable in these bars, and nothing else does: the gem
chip, copy, origin, refresh and the caret carry it, while the repo name,
the branch pill and the freshness readout stay neutral. The seam inside
the accent-filled Open project button goes darker rather than lighter,
where a white line read as a crack.

Both bars take their inline padding from the same arithmetic as the gap
above and below a cluster, so the inset is even on all four sides.

A card holding an open menu outranks its siblings; without it the next
landing card painted over the Open project dropdown.

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

* Give the menus the glass surface instead of a hand-mixed fill (#156)

The dropdown had its own colour, oklch(from --cc-bg-sidebar l + 0.027),
which is none of the four surface roles and nothing else in the app uses.
Both menus float over the page: the header's over the city, the landing's
over the backdrop and the card below it, which is why it needed the
z-index fix to stop the next card painting on top. Floating over the scene
is what .surface-glass is for, so both take it.

It was the last derived colour literal in what this branch added; the two
remaining color-mix calls build on --cc-black and --cc-error, matching how
--cc-bg-backdrop is already defined.

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

* Put icons on the menu rows, in a flex column not a padding guess (#156)

The sublabel lined up under the label via padding: calc(1em + gap), which
guesses the glyph's width and drifts the moment the icon size or the gap
changes. Glyph column, text column: the sublabel is under the label
because it is in the same box.

Chrome links and chrome buttons were two different accent steps in one
bar. .link--chrome already used accent-light, so the cluster items follow
it rather than the other way round.

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

* Audit the CSS this branch added, and fix what the audit found (#156)

Swept every rule added here for the class of mistake that produced the
menu row's padding hack. Four findings.

Discover duplicated the whole of Recents' scrolling shell, verbatim, with
a comment saying it had to stay identical. It is one .source-list now,
shared, so it cannot drift; the lists identify themselves with data-list
for tests rather than a class that carries no style.

.landing-tabs pulled itself up with a negative margin to undo the card's
gap. The card sets its own gap instead: a tab strip already rules itself
off from the panel below, and saying that on the card says what is meant.

The menu row and the unreachable notice both nudged their glyph with a
1-2px top margin to sit on the first line of text. Both are grids now, so
the glyph is centred on the label's row and the sublabel is under the
label because the grid puts it there.

Two pre-existing negative margins go with them, since they are the same
smell: CommitPane cancelled the body's flex gap between author rows, and
now the authors are a box with their own gap; TimeTravelBar's playhead
centred a 3px line with -1.5px, and now uses translateX(-50%).

Two others stay: OverviewPane's row and the ControlsPane header use
negative margin with equal padding to bleed a hover highlight or a hit
area into the gutter while keeping text aligned. That is the idiom for
that job, not a workaround, and both already say so.

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

* Let the menu row own its colour, and drop a width cap that contradicted itself (#156)

The glyph was accent while the label beside it was text-strong. The row
sets the colour once now and both inherit it, so they cannot drift; only
the sublabel steps down.

The menu carried `min-width: max-content` next to `max-width: 270px`,
which say opposite things. max-content wins, so the cap never bounded the
sublabels it was added for and would only ever have fired on some future
longer label, wrapping exactly what the line above promised not to wrap.
It is `width: max-content` and nothing else.

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

* Load the shared list styles the way every other component does (#156)

The extracted .source-list shell was imported as '@/components/...css'.
Nothing else in the app imports CSS through the alias; every component
uses a relative './X.css'. The stylesheet never loaded, so the recents
rows lost their flex row and the remove control dropped onto its own
line. It lives in SourceRow.css now, which both lists already pull in by
rendering a row.

Two dead rules found by re-auditing what the first audit had EXCUSED
rather than fixed:

LandingBackdrop.css declared position, inset and z-index: -1 for a world
it no longer lives in, plus a comment about ".landing's opaque base fill"
that stopped existing when the landing went transparent. It only renders
inside .landing-stage, which sets its box. I had even added an override
to neutralize the z-index instead of deleting it. Both are gone.

.split-button-menu carried z-index: 1. It is the last child and it is
positioned, and a positioned element already paints above static
siblings, so the declaration never did anything.

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

* Cut the comments to the non-obvious why (#156)

Swept every file this branch touched, TS and Python, for comments that
narrate history rather than explain the code: "used to", "before this",
"the old X", "moved up to the header". Twenty of them, rewritten to say
what the code does and why, or deleted where the code already said it.
Fixed regardless of who wrote them, since a comment about a refactor
nobody can see is noise whatever its age.

Two assertions went with their comments. commitPane asserted
.commit-subject and .commit-loading were absent, guarding against classes
that exist nowhere in the app: they passed unconditionally and would have
kept passing however the pane broke.

Two hits were false positives and stayed: "used to namespace" means used
FOR, and a stale fetch's sha being "no longer current" is a fact about
that request. Two more kept their reference to the client's
computeFileStats, which is live: the server matches it deliberately, so
the constraint is real and only the word "old" was wrong.

README: one line. "Enter a repo URL and pick a branch" is no longer the
only way in now that Discover exists. Nothing else there documents the
chrome, so the refresh control and the "?" binding have nothing stale to
correct.

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

* Audit the new surfaces for accessibility, and fix what it found (#156)

The chrome bars and both dropdowns were not in tests/a11y. They are now,
each mounted in the state that actually has the DOM: the header with its
refresh menu open, the landing with the Discover tab and the open-project
menu open. Two real violations, both mine:

role="menu" takes only menuitem children, and the auto-refresh row sat
inside it as a plain div. The menu role now wraps the items alone and the
row sits outside it, which is also the truer statement: it is a control
on the same subject, not one of the actions.

The auto-refresh toggle dropped its htmlFor in the inert branch, so on a
remote source the checkbox had no accessible name. A disabled control
still needs one. Both go red if reintroduced.

Contrast, computed from the OKLCH tokens since axe-in-jsdom cannot
measure it: every text pair passes AA on the cluster surface, the
tightest being the inert row's reason at 4.64:1.

One finding left deliberately unfixed. The cluster border is 1.33:1
against the chrome bar, below the 3:1 that WCAG 1.4.11 asks of a UI
component boundary. It is not specific to this change: --cc-border-subtle
is 1.20:1, --cc-border-input 1.33:1 and --cc-border-tooltip 1.51:1, so no
border token in the system reaches 3:1 on that surface, and the next step
up (--cc-text-muted, 4.78:1) is far too loud for a hairline. Closing it
means a new token, which is a system decision rather than one to make
quietly inside a feature branch.

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

* Make the private-repo notice actually reachable (#156)

Three faults stacked, so the error remedy had never once appeared for a
URL anyone would paste. Only the standing notice ever showed.

The code was wired to the manifest stream alone. Pasting a URL fails at
the BRANCH LOOKUP first, which is the request that touches the remote
before anything is submitted, and it carried no code at all. It rides in
the shared error envelope now, {error, code}, the same vocabulary the SSE
event uses, via a CodedHTTPException the app's handler understands. My
first attempt passed a dict as the HTTPException detail, which the
handler feeds to ErrorResponse(error: str) — that was the 500.

The classifier only knew GitHub's "Repository not found" and GitLab's
"does not exist or you do not have access". A host that asks for
credentials instead of 404ing fell through to raw git stderr, which is
what Forgejo does: "Credentials are incorrect or have expired". Auth
failures now read as unreachable, with the same wording, because the
server cannot tell a private repo from a typo and must not guess.

hosted now beats a mount. A hosted instance can have local repos enabled
for its own filesystem, and the old order told a visitor to "clone it and
open the folder" — a folder only the server could ever see. The test
matrix had that row marked as a shape the deploy doesn't produce; it does.

The notice also stopped speaking twice. The remedy rendered above the
field while the raw message stayed below it, so one failure produced two
notices. There is one slot under the input now, in precedence order:
unreachable repo, then validation, then standing guidance. It takes the
field's own shape and describes it through aria-describedby.

Contrast: the remedy was text-secondary on the error tint, 4.91:1, which
clears AA but reads washed out beside a strong preamble. text-primary is
9.04:1.

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

* Lay out one-dimensional rows with flex, not grid

The unreachable notice held a glyph and a varying number of text
children in a two-column grid. With no explicit placement the text
auto-placed into the glyph's column, so the remedy sat full width under
a squeezed preamble. It is a row and a stack, so it is now a flex row
with a flex column inside it, top-aligned.

The footer did the header's job with different machinery: a single-row
grid where the header uses flex. Same two ends, same clipping, now the
same mechanism.

The remaining grids stay: the split-button item, the shortcuts list, the
street extension list, the binary facts list, the glyph repertoire and
the landing all align content across rows, which flex cannot do.

Refs #156

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

* Centre the notice glyph on its first line of text

Wrapping the icon in a one-line-tall slot lets the line-height do the
centring: the slot leaves font-size alone, so 1lh resolves against the
text's size and is exactly the first line's box.

The alternative was arithmetic on the type tokens, which said the same
thing in a form that goes stale the moment either token moves. Where
1lh is unsupported the declaration drops and the glyph top-aligns,
which is where it sat before this.

Refs #156

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

* Say what to do next in every state of the source notice

The notice had two jobs and told them apart by whether a preamble was
in front, so an empty field carried instructions for a failure that
hadn't happened, and a typed local path left the informational notice
standing under a field already painted red with aria-describedby unset.

Each state now names itself through a NoticeReason enum and the copy
leads with the action. A blocked path no longer suggests cloning a repo
that is already on the machine, and an unreachable one on an unmounted
instance says to turn local paths on rather than only that they're off.

Refs #156

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

* Name both reasons that share the local-paths action

The negated condition covered Standing and PathBlocked without naming
either, so the comment above it had to say "both" about a branch that
mentioned only Unreachable.

Refs #156

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

* Give every notice message the same shape and a docs link

One branch had degenerated into a bare string inside a ternary, which
was also the only message with nowhere to read more. Each remedy is now
a <p> returned from its own branch, the clone command is its own
component rather than repeated markup, and a test asserts every state
links out so a message can't quietly lose it again.

Test copy follows the wording as edited.

Refs #156

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

* Put every failure the field can explain under the field

A failed open painted its raw server message in a banner above the card
while the form kept its own slot below, so one failure spoke from two
places and only the coded repo-not-found case reached the notice that
knows what to do about it. The field itself was never marked invalid.

The message now goes to the form, which already ranks what to show: a
coded remedy, then a blocked path, then live validation, then this,
then standing guidance. Nothing renders above the field, so the banner
and its .card-error rule are gone (its paired .modal-error had no users
left either).

Refs #156

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

* Answer the review: enums, one refresh, shorter comments

Enums for the two variant props, matching SourceKind and NoticeReason.

iconOnly is gone: the chrome variant exists for the 32px bars, where a
word never fits, so the component derives it rather than being told.

The refresh menu listed Refresh next to the button that already is
Refresh. A menu is for what the button isn't, which is how the Open
split button already works, so the duplicate row goes.

listRecents() was RECENTS.value behind a name that read like a snapshot,
which is why it looked as though it would go stale. Call sites read the
signal directly and the wrapper is deleted.

Comments in ProjectsView and LandingBackdrop cut to the non-obvious why.

Refs #156

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

---------

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