Project switcher and app chrome: hosted-aware guidance, Discover, a live featured city - #157
Merged
Merged
Conversation
`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>
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>
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>
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>
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>
…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>
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>
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>
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>
"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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
…#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>
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>
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>
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>
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>
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>
…ed 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
thalida
commented
Aug 10, 2026
| <button | ||
| type="button" | ||
| class={`btn-icon btn-icon--no-drag${copied ? ' is-copied' : ''}`} | ||
| class={`${variant === 'cluster' ? CLUSTER_ITEM_PRESS : 'btn-icon'}${copied ? ' is-copied' : ''}`} |
| // than painting a background: the calm areas are genuinely transparent and the | ||
| // city plays through them. Theme-aware (reads the --cc-gem-* tokens), honors | ||
| // prefers-reduced-motion (renders one static frame), non-interactive. If WebGL | ||
| // is unavailable it renders nothing and the clip shows on its own. |
Owner
Author
There was a problem hiding this comment.
Comment is too long, doesnt' follow my commenting guidelines.
| // Alpha, not a mix into uBg: the ribbons float over whatever is behind (the | ||
| // landing's demo clip), so the calm areas have to be genuinely transparent | ||
| // rather than painted background-coloured. Premultiplied, which is the | ||
| // WebGL default the canvas composites with. |
| // Recents and Discover share one card so the action column can't grow a new | ||
| // panel per feature. Recent is always offered, empty state and all, so a first | ||
| // visit learns that codecity remembers what you open; Discover only appears | ||
| // when the server actually sent a list. |
Owner
Author
There was a problem hiding this comment.
Comments too long all throughout this file.
| // panel per feature. Recent is always offered, empty state and all, so a first | ||
| // visit learns that codecity remembers what you open; Discover only appears | ||
| // when the server actually sent a list. | ||
| const hasRecents = listRecents().length > 0; |
Owner
Author
There was a problem hiding this comment.
shouldn't this be computed or a ref or something? won't it get out of date? when does it force the component to re-render?
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>
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.
Closes #156. Closes #120, absorbed rather than built separately: private-repo guidance turned out to be the local-paths notice with a different trigger, so it is one component.
What the server now tells the client
CODECITY_HOSTEDsplits the one bit that was collapsing two opposite situations. Hosted is a dead end for a private or local repo, so the advice is "run codecity on your own machine"; local-without-a-mount is fixable, so the advice is "turn on local paths"./api/configcarrieshostedandfeaturedRepo,/api/discoverserves a curated list, and a remote that can't be reached comes back with anErrorCoderather than only prose, so the client can pick a remedy without matching on the server's wording.api/config.pyis pydantic-settings now instead of hand-rolled env parsing, which is what makesCODECITY_DISCOVER=enabledan error instead of silently off. Env files split by owner:.env.local(gitignored, seeded byjust setup) against a tracked blank.env.local.example, andjust dev ~/some/repo CODECITY_HOSTED=1forwards flags without touching compose by hand.What the field says
One slot under the source input, ranked: a coded remedy, then a blocked local path, then live validation, then the message from a failed open, then standing guidance. Nothing renders above the field. Each state names itself through
NoticeReasonand each message leads with the action and links to docs.The message from a failed open used to sit in a banner above the card while the form kept its own slot below, so one failure spoke from two places and the field it described was never marked invalid. A typed local path with local paths off left the informational notice standing under an input already painted red, with
aria-describedbyunset.Discover, and something to look at
A Discover tab with a curated list,
CODECITY_FEATURED_REPOnaming a repo rendered live behind the landing. It is a real city, not a clip: the first attempt used a recorded video and looked it.Chasing why that city was invisible found a bare
canvas { background }rule inCenterPane.csspainting an opaque plate under every canvas in the app.Chrome
Header is the project, footer is the app. Both bars share one
--cc-chrome-hand oneChromeClusterthat owns the item box, after several rounds where each bar had its own overrides and drifted. The fresh-scan option moved out of anAdvanceddisclosure onto a split button next to Open, since skipping the cache is a way of opening rather than a setting.Verification
3005 frontend tests, 402 backend, eslint and prettier clean. axe-core over the new surfaces caught two ARIA violations (a
role="menu"with a non-menuitem child, and a toggle losinghtmlFor), both fixed.Guards were confirmed by breaking them and watching them go red, not by watching them pass: removing the
pathBlockedbranch, dropping a single docs link, and flipping the error precedence each fail their test.Not fixed, needs a decision. The chrome cluster border sits at 1.33:1 against the bar, under the 3:1 that WCAG 1.4.11 asks for on non-text. No border token in the system reaches it:
subtleis 1.20,input1.33,tooltip1.51, andtext-mutedat 4.78 is far too loud for a divider. That is a token to add, not a value to nudge here.jsdom cannot compute CSS, so none of the layout work above is covered by a test. It needs eyes in a browser.
🤖 Generated with Claude Code