fabrica

hanna/fabrica

feat(web): create-menu popover, layout polish, and settings gating

a062331 · hanna committed on 2026-07-25

Turn the top-bar + into a create popover (New repository always, plus New
issue / New pull request when inside a repo), a native <details> that also
works without JS. Fix the search bar drifting left when signed out (both the
create menu and search now hug the right edge). Add breathing room below the
top bar, center the new-repository form, restyle the label color swatch and
the SSH/GPG keys section, and hide the repo Labels settings when both issues
and pull requests are disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +132 −20UnifiedSplit
assets/base.css +91 −15
@@ -118,17 +118,19 @@ code, pre, .mono {
118118 background: var(--fb-bg-inset);
119119 }
120120
121-/* Top-bar search: a growing input with an inline submit icon. */
122-/* The new-repo button and search group at the right of the top bar. */
123-.topbar-new {
121+/* The create menu and search group at the right of the top bar. Both carry
122+ `margin-left: auto` so the group hugs the right edge whether or not the create
123+ menu is present (when signed out it is absent, so the search keeps its place). */
124+.topbar-create {
124125 margin-left: auto;
126+ position: relative;
125127 }
126128 .topbar-search {
127129 display: flex;
128130 align-items: center;
129131 gap: 0.25rem;
130- flex: 1;
131- max-width: 28rem;
132+ flex: 0 1 28rem;
133+ margin-left: auto;
132134 }
133135 .topbar-search input[type="search"] {
134136 width: 100%;
@@ -139,6 +141,40 @@ code, pre, .mono {
139141 color: var(--fb-fg);
140142 }
141143
144+/* Create (+) popover in the top bar. */
145+.topbar-create > summary {
146+ list-style: none;
147+ cursor: pointer;
148+}
149+.topbar-create > summary::-webkit-details-marker {
150+ display: none;
151+}
152+.menu-popover {
153+ position: absolute;
154+ right: 0;
155+ top: calc(100% + 0.35rem);
156+ z-index: 30;
157+ min-width: 13rem;
158+ padding: 0.35rem;
159+ background: var(--fb-bg-raised);
160+ border: 1px solid var(--fb-border);
161+ border-radius: var(--fb-radius);
162+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
163+}
164+.menu-item {
165+ display: flex;
166+ align-items: center;
167+ gap: 0.55rem;
168+ padding: 0.45rem 0.6rem;
169+ border-radius: var(--fb-radius);
170+ color: var(--fb-fg);
171+ white-space: nowrap;
172+}
173+.menu-item:hover {
174+ background: var(--fb-bg-inset);
175+ text-decoration: none;
176+}
177+
142178 .icon-btn {
143179 display: inline-flex;
144180 align-items: center;
@@ -228,7 +264,8 @@ body.drawer-open .drawer-backdrop {
228264 .page {
229265 max-width: 76rem;
230266 margin: 0 auto;
231- padding: var(--fb-gap);
267+ /* Extra breathing room below the fixed-height top bar. */
268+ padding: calc(var(--fb-gap) * 1.75) var(--fb-gap) calc(var(--fb-gap) * 2);
232269 }
233270
234271 .card {
@@ -314,9 +351,24 @@ body.drawer-open .drawer-backdrop {
314351 min-width: 10rem;
315352 }
316353 .label-add input[type="color"] {
317- width: 2.6rem;
318- padding: 0.15rem;
354+ width: 2.8rem;
319355 height: 2.4rem;
356+ padding: 0;
357+ border: 1px solid var(--fb-border);
358+ border-radius: var(--fb-radius);
359+ background: var(--fb-bg-inset);
360+ cursor: pointer;
361+}
362+.label-add input[type="color"]::-webkit-color-swatch-wrapper {
363+ padding: 3px;
364+}
365+.label-add input[type="color"]::-webkit-color-swatch {
366+ border: none;
367+ border-radius: 3px;
368+}
369+.label-add input[type="color"]::-moz-color-swatch {
370+ border: none;
371+ border-radius: 3px;
320372 }
321373 .label-add button {
322374 flex: none;
@@ -593,22 +645,45 @@ select:focus {
593645 margin-bottom: 1rem;
594646 }
595647 .key-list {
596- margin-bottom: 1rem;
648+ margin-bottom: 1.25rem;
649+}
650+.key-list .entry-row {
651+ align-items: center;
652+}
653+.key-list .entry-row-title {
654+ display: flex;
655+ align-items: center;
656+ gap: 0.5rem;
657+}
658+.key-list .entry-row-meta {
659+ margin-top: 0.2rem;
660+ font-size: 0.85em;
661+}
662+.key-add {
663+ padding-top: 1rem;
664+ border-top: 1px solid var(--fb-border);
597665 }
598666 .key-add textarea {
599667 font-family: var(--fb-font-mono);
668+ font-size: 0.88em;
600669 }
601670 .key-add-row {
602671 display: flex;
603672 align-items: center;
604- gap: 0.75rem;
673+ gap: 0.6rem;
605674 flex-wrap: wrap;
606- margin-bottom: 0.5rem;
675+ margin-bottom: 0.6rem;
607676 }
608-.key-add-row select,
609-.key-add-row input {
677+.key-add-row label {
678+ margin: 0;
679+}
680+.key-add-row select {
610681 width: auto;
611682 }
683+.key-add-row input {
684+ flex: 1;
685+ min-width: 12rem;
686+}
612687 .token-scopes {
613688 margin: 0.75rem 0 0;
614689 padding: 0.5rem 0.85rem;
@@ -669,9 +744,10 @@ label.checkbox input {
669744 display: none !important;
670745 }
671746
672-/* New-repository form: a comfortably narrow column. */
747+/* New-repository form: a comfortably narrow, centered column. */
673748 .new-repo {
674- max-width: 42rem;
749+ max-width: 40rem;
750+ margin: 0 auto;
675751 }
676752
677753 /* Centered auth card (sign in, invite activation). */
assets/fabrica.js +2 −2
@@ -51,7 +51,7 @@
5151 // open menu when a click lands outside it. Without JS the menus still work,
5252 // they just close on a second summary click.
5353 document.addEventListener("click", function (e) {
54- document.querySelectorAll("details.clone-menu[open], details.branch-menu[open]").forEach(function (d) {
54+ document.querySelectorAll("details.clone-menu[open], details.branch-menu[open], details[data-menu][open]").forEach(function (d) {
5555 if (!d.contains(e.target)) d.removeAttribute("open");
5656 });
5757 });
@@ -132,7 +132,7 @@
132132 }
133133 } else if (e.key === "Escape") {
134134 toggleDrawer(false);
135- document.querySelectorAll("details.clone-menu[open], details.branch-menu[open]").forEach(function (d) {
135+ document.querySelectorAll("details.clone-menu[open], details.branch-menu[open], details[data-menu][open]").forEach(function (d) {
136136 d.removeAttribute("open");
137137 });
138138 }
crates/web/src/layout.rs +34 −2
@@ -75,8 +75,7 @@ fn topbar(chrome: &Chrome) -> Markup {
7575 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }
7676 span class="slug" { a href="/" { (chrome.instance_name) } }
7777 @if chrome.user.is_some() {
78- a class="icon-btn topbar-new" href="/new" aria-label="New repository"
79- title="New repository" { (icon(Icon::Plus)) }
78+ (create_menu(chrome))
8079 }
8180 form class="topbar-search" method="get" action="/search" role="search" {
8281 input type="search" name="q" placeholder="Search…" aria-label="Search";
@@ -85,6 +84,39 @@ fn topbar(chrome: &Chrome) -> Markup {
8584 }
8685 }
8786
87+/// The create (`+`) popover: New repository always, plus New issue / New pull
88+/// request when the viewer is inside a repository. A native `<details>`, so it
89+/// opens without JavaScript; `fabrica.js` adds outside-click and Escape closing.
90+fn create_menu(chrome: &Chrome) -> Markup {
91+ let repo_base = repo_base(&chrome.path);
92+ html! {
93+ details class="topbar-create menu" data-menu {
94+ summary class="icon-btn" aria-label="Create new" title="Create new" {
95+ (icon(Icon::Plus))
96+ }
97+ div class="menu-popover" role="menu" {
98+ a class="menu-item" role="menuitem" href="/new" {
99+ (icon(Icon::Box)) span { "New repository" }
100+ }
101+ @if let Some(base) = &repo_base {
102+ a class="menu-item" role="menuitem" href=(format!("{base}/-/issues/new")) {
103+ (icon(Icon::Issue)) span { "New issue" }
104+ }
105+ a class="menu-item" role="menuitem" href=(format!("{base}/-/pulls/new")) {
106+ (icon(Icon::GitPull)) span { "New pull request" }
107+ }
108+ }
109+ }
110+ }
111+ }
112+}
113+
114+/// The repository base path (everything before `/-/`) when `path` is a repo view,
115+/// else `None`. Used to target the create menu's New issue / New pull request.
116+fn repo_base(path: &str) -> Option<String> {
117+ path.split_once("/-/").map(|(base, _)| base.to_string())
118+}
119+
88120 /// The off-canvas navigation drawer.
89121 fn drawer(chrome: &Chrome) -> Markup {
90122 html! {
crates/web/src/repo.rs +5 −1
@@ -806,7 +806,11 @@ async fn repo_settings_view(
806806 }
807807 }
808808 (collaborators_section(id, &chrome.csrf, &collaborators))
809- (labels_section(id, &chrome.csrf, &labels))
809+ // Labels are only used by issues and PRs, so hide the section when both
810+ // are disabled.
811+ @if ctx.repo.issues_enabled || ctx.repo.pulls_enabled {
812+ (labels_section(id, &chrome.csrf, &labels))
813+ }
810814 section class="listing" {
811815 h2 { "Danger zone" }
812816 div class="card danger-zone" {