@@ -87,178 +156,43 @@ + {% endif %}
{% if current_user %}
---- a/scss/nav.scss
-+++ b/scss/nav.scss
-@@ -22,6 +22,103 @@
- text-decoration: underline;
- }
-
-+ // The overflow panel of the service switcher. It hangs off its own list
-+ // item, so the navbar must not clip it: nothing here or above may introduce
-+ // overflow: hidden, or the panel is cut off at the row it drops out of.
-+ .nav-item.nav-more {
-+ position: relative;
-+
-+ // The default disclosure triangle is dropped in favour of a caret drawn
-+ // beside the count, so the summary reads as one label rather than as a
-+ // widget with a number next to it. list-style covers Firefox, the
-+ // ::-webkit-details-marker rule covers Safari.
-+ summary {
-+ list-style: none;
-+ cursor: pointer;
-+ padding: 0 0.5rem;
-+ color: $navbar-light-color;
-+ white-space: nowrap;
-+
-+ &::-webkit-details-marker {
-+ display: none;
-+ }
-+
-+ &::after {
-+ content: " \25be";
-+ }
-+
-+ &:hover {
-+ color: $navbar-light-active-color;
-+ text-decoration: underline;
-+ }
-+
-+ // The summary is the only keyboard-reachable way into the panel, so its
-+ // focus ring is not decoration.
-+ &:focus,
-+ &:focus-visible {
-+ outline: 2px solid $link-color;
-+ outline-offset: -2px;
-+ color: $navbar-light-active-color;
-+ }
-+ }
-+
-+ details[open] > summary::after {
-+ content: " \25b4";
-+ }
-+
-+ .nav-more-panel {
-+ position: absolute;
-+ z-index: 1030; // above page content, level with Bootstrap's fixed navbar
-+ right: 0;
-+ min-width: 12rem;
-+ padding: 0.5rem 0;
-+ background: $gray-100;
-+ border: 1px solid $gray-300;
-+
-+ // Two columns' worth of groups sit side by side on a wide viewport and
-+ // stack on a narrow one, so the panel grows sideways rather than into a
-+ // list long enough to need scrolling.
-+ display: flex;
-+ flex-wrap: wrap;
-+ align-items: flex-start;
-+ }
-+
-+ .nav-more-group {
-+ padding: 0 1rem;
-+ min-width: 8rem;
-+
-+ ul {
-+ list-style: none;
-+ margin: 0;
-+ padding: 0;
-+ }
-+
-+ a {
-+ display: block;
-+ padding: 0.1rem 0;
-+ white-space: nowrap;
-+ }
-+ }
-+
-+ .nav-more-title {
-+ color: $gray-600;
-+ font-size: 0.85rem;
-+ text-transform: uppercase;
-+ padding-bottom: 0.1rem;
-+ }
-+
-+ // On a phone the row already wraps rather than pushing the page sideways;
-+ // the panel follows it and spans the width instead of hanging off an edge
-+ // that may be off-screen.
-+ @include media-breakpoint-down(xs) {
-+ position: static;
-+
-+ .nav-more-panel {
-+ width: 100%;
-+ }
-+ }
-+ }
-+
- .navbar-text {
- color: $black;
-
---- a/scss/dark.scss
-+++ b/scss/dark.scss
-@@ -177,6 +177,34 @@
- .active .nav-link {
- color: $white;
- }
-+
-+ // The service switcher's overflow panel. Same two roles the rest of the
-+ // dark theme uses for a raised surface and its edge, so the panel sits on
-+ // the navbar the way .event sits on the page.
-+ .nav-item.nav-more {
-+ summary {
-+ color: lighten($gray-600, 5);
-+
-+ &:hover,
-+ &:focus,
-+ &:focus-visible {
-+ color: $white;
-+ }
-+ }
-+
-+ .nav-more-panel {
-+ background: $darker;
-+ border-color: $border;
-+ }
-+
-+ .nav-more-title {
-+ color: $muted-dark;
-+ }
-+
-+ a {
-+ color: $link-dark;
-+ }
-+ }
- }
- }
-
--- /dev/null
+++ b/srht/app/nav.py
-@@ -0,0 +1,147 @@
+@@ -0,0 +1,160 @@
+"""The service switcher: which services stay visible in the navbar, and how the
+rest are grouped behind the counter.
+
++COPYed into core.sr.ht's site-packages as srht/app/nav.py by Dockerfile.base,
++the way overlay/meta's two modules reach metasrht — a whole module dropped in
++beside upstream's, rather than one of the scripts/patch-*.py that edit files
++already there. patch-nav-groups.py is the other half: it teaches flask.py to
++import this and nav.html to render it. Nothing in this file knows it is an
++overlay.
++
+The strip between the brand and the login box is the instance's only catalogue,
-+and on an installation with more than a handful of services it stopped fitting
-+the row. It is now a pinned subset plus a grouped remainder, with both the
-+subset and the grouping declared in config.ini rather than in code:
++and at fourteen services it stopped fitting the row. It is now a pinned subset
++plus a grouped remainder, with both the subset and the grouping declared in
++config.ini rather than in code:
+
+ [sr.ht]
+ nav-groups=code work ci output account
+ nav-pinned=git builds artifacts
+
+ [bench.sr.ht]
-+ origin=https://bench.example
++ origin=https://bench.srht.bigb.es
+ nav-group=output
+
+An installation that sets none of these keys keeps the switcher it had, moved
+behind the counter: every service falls into one unnamed group, which prints as
+a plain alphabetical list with no headings. That is deliberate — it is what
-+makes this safe to run against a config nobody has edited yet.
++makes this safe to run against a config nobody has edited yet, and it is why
++the whole change can be offered upstream one day.
+
-+This module exists as a module, rather than as more lines in flask.py, because
-+the same three keys are read by the instance's non-Python services and the two
-+implementations have to keep agreeing. A self-contained function is the part
-+that can be compared against its counterpart, and the part a rebase onto a new
-+upstream does not have to be re-threaded through.
++The same three keys are read by sr-ht-ecore's chrome package, which draws this
++same strip for the instance's own Go services. The two implementations have to
++keep agreeing, which is why the taxonomy lives in the shared config and why
++this is one self-contained function rather than lines spread through flask.py:
++a function is the part that can be compared against its counterpart.
+
+One rule is worth stating on its own: members sort alphabetically inside a
+group, in every implementation. Not by config order — a config parser is free
@@ -275,6 +209,11 @@ +# Sections that are never entries in the switcher. paste and pages have no
+# top-level UI worth linking; hub is not a sibling service but the network's
+# front page, and the brand already points at it; api is the federated GraphQL
+# gateway and serves no pages at all.
++#
++# This is the only exclusion list that matters here. flask.py keeps its own for
++# the flat `network` list, but after patch-nav-brand.py and patch-nav-groups.py
++# nothing reads `network` any more — both of its uses were in nav.html, and both
++# were patched away.
+_excluded = [
+ "paste.sr.ht",
+ "pages.sr.ht",
@@ -300,7 +239,7 @@ + that is not excluded and resolves to an origin.
+
+ A section without an origin is skipped rather than listed with a dead link,
+ which is the one place this differs from the flat `network` list flask.py
-+ still exposes for the brand.
++ still builds.
+ """
+ items = []
+ for section in config:
diff --git a/patches/make-core-nav-groups-patch.sh b/patches/make-core-nav-groups-patch.sh
new file mode 100755
index 0000000000000000000000000000000000000000..da22f758bdf947b41eeb886e457e7a4d216aa0a6
--- /dev/null
+++ b/patches/make-core-nav-groups-patch.sh
@@ -0,0 +1,60 @@
+#!/bin/sh
+# Regenerate core-nav-groups.patch from the things that actually run.
+#
+# The patch is a derived artifact, not a source. What runs on this instance is
+# ../scripts/patch-nav-groups.py (which rewrites the installed flask.py and
+# nav.html) and ../overlay/core/nav.py (which is dropped in beside them); the
+# patch exists so the same change can be read as a diff against upstream and
+# offered there one day. Generating it from those two files is what stops the
+# two representations from drifting — a hazard that is otherwise real, because
+# nothing else compares them.
+#
+# Run it after touching either file, and commit the result.
+#
+# The scss half of the change is deliberately NOT here. core.sr.ht ships scss
+# and this instance compiles none of it: the Python services arrive with their
+# CSS prebuilt inside upstream's apks, and the Go ones build theirs in CI from
+# an unpatched clone. The panel is styled from an inline