Say what codecity is on the page that covers everything else - #155
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsxis gone.Link treatment is the surface's call via a
linkClassprop, using the classesstyles/text.cssalready defines rather than a fourth bespoke one:.linkon the landing: prose, so accent and always underlined.link--chromein the header and footer: accent, underline on hoverThat 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-strongfor 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
--cc-font-smnow. The size had been copied without being re-decided for the context..app-footer-creditto the shared.meta-creditleft 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.Verification
just test-app(2903) andjust lintgreen; the pre-push gate passed. Four new tests cover the landing's identity line in both modes, including both link targets and theirrel. 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