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 {
118 background: var(--fb-bg-inset);118 background: var(--fb-bg-inset);
119}119}
120120
121/* Top-bar search: a growing input with an inline submit icon. */121/* The create menu and search group at the right of the top bar. Both carry
122/* The new-repo button and search group at the right of the top bar. */122 `margin-left: auto` so the group hugs the right edge whether or not the create
123.topbar-new {123 menu is present (when signed out it is absent, so the search keeps its place). */
124.topbar-create {
124 margin-left: auto;125 margin-left: auto;
126 position: relative;
125}127}
126.topbar-search {128.topbar-search {
127 display: flex;129 display: flex;
128 align-items: center;130 align-items: center;
129 gap: 0.25rem;131 gap: 0.25rem;
130 flex: 1;132 flex: 0 1 28rem;
131 max-width: 28rem;133 margin-left: auto;
132}134}
133.topbar-search input[type="search"] {135.topbar-search input[type="search"] {
134 width: 100%;136 width: 100%;
@@ -139,6 +141,40 @@ code, pre, .mono {
139 color: var(--fb-fg);141 color: var(--fb-fg);
140}142}
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
142.icon-btn {178.icon-btn {
143 display: inline-flex;179 display: inline-flex;
144 align-items: center;180 align-items: center;
@@ -228,7 +264,8 @@ body.drawer-open .drawer-backdrop {
228.page {264.page {
229 max-width: 76rem;265 max-width: 76rem;
230 margin: 0 auto;266 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);
232}269}
233270
234.card {271.card {
@@ -314,9 +351,24 @@ body.drawer-open .drawer-backdrop {
314 min-width: 10rem;351 min-width: 10rem;
315}352}
316.label-add input[type="color"] {353.label-add input[type="color"] {
317 width: 2.6rem;354 width: 2.8rem;
318 padding: 0.15rem;
319 height: 2.4rem;355 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;
320}372}
321.label-add button {373.label-add button {
322 flex: none;374 flex: none;
@@ -593,22 +645,45 @@ select:focus {
593 margin-bottom: 1rem;645 margin-bottom: 1rem;
594}646}
595.key-list {647.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);
597}665}
598.key-add textarea {666.key-add textarea {
599 font-family: var(--fb-font-mono);667 font-family: var(--fb-font-mono);
668 font-size: 0.88em;
600}669}
601.key-add-row {670.key-add-row {
602 display: flex;671 display: flex;
603 align-items: center;672 align-items: center;
604 gap: 0.75rem;673 gap: 0.6rem;
605 flex-wrap: wrap;674 flex-wrap: wrap;
606 margin-bottom: 0.5rem;675 margin-bottom: 0.6rem;
607}676}
608.key-add-row select,677.key-add-row label {
609.key-add-row input {678 margin: 0;
679}
680.key-add-row select {
610 width: auto;681 width: auto;
611}682}
683.key-add-row input {
684 flex: 1;
685 min-width: 12rem;
686}
612.token-scopes {687.token-scopes {
613 margin: 0.75rem 0 0;688 margin: 0.75rem 0 0;
614 padding: 0.5rem 0.85rem;689 padding: 0.5rem 0.85rem;
@@ -669,9 +744,10 @@ label.checkbox input {
669 display: none !important;744 display: none !important;
670}745}
671746
672/* New-repository form: a comfortably narrow column. */747/* New-repository form: a comfortably narrow, centered column. */
673.new-repo {748.new-repo {
674 max-width: 42rem;749 max-width: 40rem;
750 margin: 0 auto;
675}751}
676752
677/* Centered auth card (sign in, invite activation). */753/* Centered auth card (sign in, invite activation). */
assets/fabrica.js +2 −2
@@ -51,7 +51,7 @@
51 // open menu when a click lands outside it. Without JS the menus still work,51 // open menu when a click lands outside it. Without JS the menus still work,
52 // they just close on a second summary click.52 // they just close on a second summary click.
53 document.addEventListener("click", function (e) {53 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) {
55 if (!d.contains(e.target)) d.removeAttribute("open");55 if (!d.contains(e.target)) d.removeAttribute("open");
56 });56 });
57 });57 });
@@ -132,7 +132,7 @@
132 }132 }
133 } else if (e.key === "Escape") {133 } else if (e.key === "Escape") {
134 toggleDrawer(false);134 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) {
136 d.removeAttribute("open");136 d.removeAttribute("open");
137 });137 });
138 }138 }
crates/web/src/layout.rs +34 −2
@@ -75,8 +75,7 @@ fn topbar(chrome: &Chrome) -> Markup {
75 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }75 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }
76 span class="slug" { a href="/" { (chrome.instance_name) } }76 span class="slug" { a href="/" { (chrome.instance_name) } }
77 @if chrome.user.is_some() {77 @if chrome.user.is_some() {
78 a class="icon-btn topbar-new" href="/new" aria-label="New repository"78 (create_menu(chrome))
79 title="New repository" { (icon(Icon::Plus)) }
80 }79 }
81 form class="topbar-search" method="get" action="/search" role="search" {80 form class="topbar-search" method="get" action="/search" role="search" {
82 input type="search" name="q" placeholder="Search…" aria-label="Search";81 input type="search" name="q" placeholder="Search…" aria-label="Search";
@@ -85,6 +84,39 @@ fn topbar(chrome: &Chrome) -> Markup {
85 }84 }
86}85}
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.
90fn 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.
116fn repo_base(path: &str) -> Option<String> {
117 path.split_once("/-/").map(|(base, _)| base.to_string())
118}
119
88/// The off-canvas navigation drawer.120/// The off-canvas navigation drawer.
89fn drawer(chrome: &Chrome) -> Markup {121fn drawer(chrome: &Chrome) -> Markup {
90 html! {122 html! {
crates/web/src/repo.rs +5 −1
@@ -806,7 +806,11 @@ async fn repo_settings_view(
806 }806 }
807 }807 }
808 (collaborators_section(id, &chrome.csrf, &collaborators))808 (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 }
810 section class="listing" {814 section class="listing" {
811 h2 { "Danger zone" }815 h2 { "Danger zone" }
812 div class="card danger-zone" {816 div class="card danger-zone" {