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 {
516}516}
517.issue-title-line {517.issue-title-line {
518 display: flex;518 display: flex;
519 align-items: flex-start;519 align-items: center;
520 justify-content: space-between;520 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;
522}526}
523.issue-edit,527.issue-edit,
524.comment-edit {528.comment-edit {
@@ -533,30 +537,63 @@ body.drawer-open .drawer-backdrop {
533.comment-edit > summary::-webkit-details-marker {537.comment-edit > summary::-webkit-details-marker {
534 display: none;538 display: none;
535}539}
536.issue-edit .card {540/* Edit / Cancel label swap on the toggle. */
537 margin-top: 0.5rem;541.issue-edit:not([open]) .edit-close,
538 width: min(32rem, 90vw);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%;
539}565}
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. */
540.comment-edit {571.comment-edit {
541 margin-left: auto;572 margin-left: auto;
542}573}
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}
549.comment-edit > summary {574.comment-edit > summary {
550 font-size: 0.9em;575 font-size: 0.9em;
551 color: var(--fb-accent);576 color: var(--fb-accent);
552}577}
553.comment-edit form {578.comment-editor {
554 margin-top: 0.5rem;579 display: none;
580 padding: 0.85rem;
555}581}
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 {
557 width: 100%;589 width: 100%;
558 font-family: inherit;590 font-family: inherit;
559}591}
592.comment-editor-actions {
593 display: flex;
594 justify-content: flex-end;
595 margin-top: 0.5rem;
596}
560.issue-sub {597.issue-sub {
561 display: flex;598 display: flex;
562 align-items: center;599 align-items: center;
crates/web/src/issues.rs +30 −18
@@ -247,7 +247,7 @@ pub(crate) async fn view(
247 div class="issue-title-line" {247 div class="issue-title-line" {
248 h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }248 h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }
249 @if can_write || is_author {249 @if can_write || is_author {
250 (edit_issue_form(&issue, kind, &csrf))250 (edit_issue_form(&issue, &csrf))
251 }251 }
252 }252 }
253 div class="issue-sub" {253 div class="issue-sub" {
@@ -316,18 +316,27 @@ pub(crate) fn comment_card(
316 div class="comment-head muted" {316 div class="comment-head muted" {
317 strong { (author) }317 strong { (author) }
318 span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) }318 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() {
320 details class="comment-edit" {322 details class="comment-edit" {
321 summary { "Edit" }323 summary {
322 form method="post" action=(action) {324 span class="edit-open" { "Edit" }
323 input type="hidden" name="_csrf" value=(csrf);325 span class="edit-close" { "Cancel" }
324 textarea name="body" rows="4" required { (body) }
325 button class="btn inline-btn" type="submit" { "Save" }
326 }326 }
327 }327 }
328 }328 }
329 }329 }
330 div class="comment-body markdown" { (markdown::render(body)) }330 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 }
331 }340 }
332 }341 }
333}342}
@@ -416,20 +425,23 @@ pub(crate) fn lock_button(issue: &Issue, csrf: &str) -> Markup {
416 }425 }
417}426}
418427
419/// The edit-title form, revealed by a toggle in the issue/PR header. (The428/// The edit-title toggle in the issue/PR header. When open it hides the title and
420/// description is edited from its own comment card.)429/// shows a full-width single-line input with Cancel (the toggle) and Save beside
421pub(crate) fn edit_issue_form(issue: &Issue, kind: Kind, csrf: &str) -> Markup {430/// it, GitHub-style. (The description is edited from its own comment card.)
431pub(crate) fn edit_issue_form(issue: &Issue, csrf: &str) -> Markup {
432 let form_id = format!("title-edit-{}", issue.id);
422 html! {433 html! {
423 details class="issue-edit" {434 details class="issue-edit" {
424 summary class="btn" { "Edit" }435 summary class="btn edit-toggle" {
425 div class="card" {436 span class="edit-open" { "Edit" }
426 form method="post" action=(format!("/issue/{}/edit-title", issue.id)) {437 span class="edit-close" { "Cancel" }
427 input type="hidden" name="_csrf" value=(csrf);438 }
428 label for="edit-title" { "Title" }439 form id=(form_id) class="title-edit-form" method="post"
429 input type="text" id="edit-title" name="title" value=(issue.title) required;440 action=(format!("/issue/{}/edit-title", issue.id)) {
430 button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) " title" }441 input type="hidden" name="_csrf" value=(csrf);
431 }442 input type="text" name="title" value=(issue.title) aria-label="Title" required;
432 }443 }
444 button class="btn btn-primary title-save" type="submit" form=(form_id) { "Save" }
433 }445 }
434 }446 }
435}447}
crates/web/src/pulls.rs +1 −1
@@ -142,7 +142,7 @@ pub(crate) async fn view(
142 div class="issue-title-line" {142 div class="issue-title-line" {
143 h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }143 h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }
144 @if can_write || is_author {144 @if can_write || is_author {
145 (edit_issue_form(&issue, Kind::pulls(), &csrf))145 (edit_issue_form(&issue, &csrf))
146 }146 }
147 }147 }
148 div class="issue-sub" {148 div class="issue-sub" {