fabrica

hanna/fabrica

fix(web): GitHub-style inline title/comment editing

11da067 · hanna committed on 2026-07-25

While editing, hide the source and show only the editor. The title becomes a
full-width input with Cancel (the toggle) and Save beside it; the "Edit" button
relabels to "Cancel". A comment's rendered body is replaced by its textarea via
:has(.comment-edit[open]), with Save at the bottom. No JavaScript required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
3 files changed · +82 −33UnifiedSplit
assets/base.css +51 −14
@@ -516,9 +516,13 @@ body.drawer-open .drawer-backdrop {
516516 }
517517 .issue-title-line {
518518 display: flex;
519- align-items: flex-start;
519+ align-items: center;
520520 justify-content: space-between;
521- gap: 1rem;
521+ gap: 0.5rem;
522+}
523+/* Hide the title while its editor is open (GitHub-style inline rename). */
524+.issue-title-line:has(.issue-edit[open]) h1 {
525+ display: none;
522526 }
523527 .issue-edit,
524528 .comment-edit {
@@ -533,30 +537,63 @@ body.drawer-open .drawer-backdrop {
533537 .comment-edit > summary::-webkit-details-marker {
534538 display: none;
535539 }
536-.issue-edit .card {
537- margin-top: 0.5rem;
538- width: min(32rem, 90vw);
540+/* Edit / Cancel label swap on the toggle. */
541+.issue-edit:not([open]) .edit-close,
542+.comment-edit:not([open]) .edit-close {
543+ display: none;
544+}
545+.issue-edit[open] .edit-open,
546+.comment-edit[open] .edit-open {
547+ display: none;
548+}
549+/* Title editor: a full-width input with Cancel (the toggle) and Save beside it. */
550+.issue-edit[open] {
551+ display: flex;
552+ align-items: center;
553+ gap: 0.5rem;
554+ flex: 1 1 100%;
555+}
556+.issue-edit[open] > summary {
557+ order: 2;
558+}
559+.title-edit-form {
560+ order: 1;
561+ flex: 1;
562+}
563+.title-edit-form input[type="text"] {
564+ width: 100%;
539565 }
566+.title-save {
567+ order: 3;
568+}
569+/* Comment editor: the toggle sits in the header; when open the rendered body is
570+ replaced by the textarea. */
540571 .comment-edit {
541572 margin-left: auto;
542573 }
543-/* When opened, break onto its own full-width row so the form isn't cramped. */
544-.comment-edit[open] {
545- flex-basis: 100%;
546- margin-left: 0;
547- margin-top: 0.4rem;
548-}
549574 .comment-edit > summary {
550575 font-size: 0.9em;
551576 color: var(--fb-accent);
552577 }
553-.comment-edit form {
554- margin-top: 0.5rem;
578+.comment-editor {
579+ display: none;
580+ padding: 0.85rem;
555581 }
556-.comment-edit textarea {
582+.comment:has(.comment-edit[open]) .comment-editor {
583+ display: block;
584+}
585+.comment:has(.comment-edit[open]) .comment-body {
586+ display: none;
587+}
588+.comment-editor textarea {
557589 width: 100%;
558590 font-family: inherit;
559591 }
592+.comment-editor-actions {
593+ display: flex;
594+ justify-content: flex-end;
595+ margin-top: 0.5rem;
596+}
560597 .issue-sub {
561598 display: flex;
562599 align-items: center;
crates/web/src/issues.rs +30 −18
@@ -247,7 +247,7 @@ pub(crate) async fn view(
247247 div class="issue-title-line" {
248248 h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }
249249 @if can_write || is_author {
250- (edit_issue_form(&issue, kind, &csrf))
250+ (edit_issue_form(&issue, &csrf))
251251 }
252252 }
253253 div class="issue-sub" {
@@ -316,18 +316,27 @@ pub(crate) fn comment_card(
316316 div class="comment-head muted" {
317317 strong { (author) }
318318 span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) }
319- @if let Some((action, csrf)) = edit {
319+ // A bare toggle; CSS `:has(.comment-edit[open])` swaps the rendered
320+ // body for the editor below, so no JavaScript is needed.
321+ @if edit.is_some() {
320322 details class="comment-edit" {
321- summary { "Edit" }
322- form method="post" action=(action) {
323- input type="hidden" name="_csrf" value=(csrf);
324- textarea name="body" rows="4" required { (body) }
325- button class="btn inline-btn" type="submit" { "Save" }
323+ summary {
324+ span class="edit-open" { "Edit" }
325+ span class="edit-close" { "Cancel" }
326326 }
327327 }
328328 }
329329 }
330330 div class="comment-body markdown" { (markdown::render(body)) }
331+ @if let Some((action, csrf)) = edit {
332+ form class="comment-editor" method="post" action=(action) {
333+ input type="hidden" name="_csrf" value=(csrf);
334+ textarea name="body" rows="6" required { (body) }
335+ div class="comment-editor-actions" {
336+ button class="btn btn-primary inline-btn" type="submit" { "Save" }
337+ }
338+ }
339+ }
331340 }
332341 }
333342 }
@@ -416,20 +425,23 @@ pub(crate) fn lock_button(issue: &Issue, csrf: &str) -> Markup {
416425 }
417426 }
418427
419-/// The edit-title form, revealed by a toggle in the issue/PR header. (The
420-/// description is edited from its own comment card.)
421-pub(crate) fn edit_issue_form(issue: &Issue, kind: Kind, csrf: &str) -> Markup {
428+/// The edit-title toggle in the issue/PR header. When open it hides the title and
429+/// shows a full-width single-line input with Cancel (the toggle) and Save beside
430+/// it, GitHub-style. (The description is edited from its own comment card.)
431+pub(crate) fn edit_issue_form(issue: &Issue, csrf: &str) -> Markup {
432+ let form_id = format!("title-edit-{}", issue.id);
422433 html! {
423434 details class="issue-edit" {
424- summary class="btn" { "Edit" }
425- div class="card" {
426- form method="post" action=(format!("/issue/{}/edit-title", issue.id)) {
427- input type="hidden" name="_csrf" value=(csrf);
428- label for="edit-title" { "Title" }
429- input type="text" id="edit-title" name="title" value=(issue.title) required;
430- button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) " title" }
431- }
435+ summary class="btn edit-toggle" {
436+ span class="edit-open" { "Edit" }
437+ span class="edit-close" { "Cancel" }
438+ }
439+ form id=(form_id) class="title-edit-form" method="post"
440+ action=(format!("/issue/{}/edit-title", issue.id)) {
441+ input type="hidden" name="_csrf" value=(csrf);
442+ input type="text" name="title" value=(issue.title) aria-label="Title" required;
432443 }
444+ button class="btn btn-primary title-save" type="submit" form=(form_id) { "Save" }
433445 }
434446 }
435447 }
crates/web/src/pulls.rs +1 −1
@@ -142,7 +142,7 @@ pub(crate) async fn view(
142142 div class="issue-title-line" {
143143 h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }
144144 @if can_write || is_author {
145- (edit_issue_form(&issue, Kind::pulls(), &csrf))
145+ (edit_issue_form(&issue, &csrf))
146146 }
147147 }
148148 div class="issue-sub" {