Proposal page: edit in place from a pencil and a ··· menu; one action row; landed card 4f37afcc3583
The description card's ··· menu copies its link or Markdown and edits it in place (with the title and target), as the title's pencil does; the disclosure at the foot is gone. The state buttons sit in the comment box's action row (form=pstate); the review is a compact card with a segmented verdict. A landed proposal ends with a card that offers to delete its source bookmark while it exists and the viewer can; a closed one says so. Tests. closes #86
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 19:50 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
4f37afcc35832326a0a8d3fa8fe8fbe2317fcb3cfe52ef9a3604aeaaa23dbeca- Depends on
- 0b20f1ec4007 145306044896 749d9a5fcd9f 8e96499f2705 a1b9869565aa b5668108487f cb71e18eb6f9 d23464c0ac60 de505df5cf8d ea85398a51bc
- Depended on by
- 668b2ad7495c b5a0a69b3622 ffb67a81188c 16626c0d69c0 ed89fae5180b 7d2eb590051f 991b55638aaf 9e50caedf4db
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 20 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A tidier proposal conversation (#86).** A pencil by the title and a ··· menu on the description (Copy link, Copy Markdown, Edit) edit the title, target and description in place, replacing the box at the foot of the page. The comment box has one action row (Back to draft, Close proposal, Comment); the review is a compact card with Comment / Approve / Request changes as one segmented control beside Submit. A landed proposal ends with a "Proposal landed" card (who, how many changes, onto which bookmark), offering to delete the source bookmark while it is still there; a closed one with a "Proposal closed" card. |
|
| 8 | + | ||
| 7 | 9 | - **An apt repository at `/apt` (#85).** The hub serves the `.deb` assets of the releases of the repositories named in `[apt] repos` (public ones only) as a signed apt repository: `dists/stable/InRelease`, `Release` and `Release.gpg`, `main/binary-{amd64,arm64,riscv64}/Packages(.gz)`, and the pool. The index is rebuilt when a release's packages change, and signed with the key in the data directory's `apt-gnupg` (public key at `/apt/oxygenhub.gpg`). `/apt` says how to add it. |
|
| 8 | 10 | ||
| 9 | 11 | - **The proposal page's sidebar matches the issue page's (#84).** Sections with headings; Reviewers and Labels open filterable menus from a gear (people with access to the repository, with avatars; labels with colour and description) that apply in one go; requested reviewers show with avatars; Landing, Checks, Approvals, Projects, Code owners and Depends on as sections. The reviewers form takes the whole list (`action=set`). |
|
| … | 43 unchanged lines |
modified assets/github.css +2 −0
| … | 101 unchanged lines |
||
| 102 | 102 | :root[data-look="github"] aside.side button.side-action { background: none; border: 0; box-shadow: none; padding: 5px 0; text-transform: none; font-size: 13px; font-weight: 600; color: var(--ink); } |
|
| 103 | 103 | :root[data-look="github"] aside.side button.side-action:hover { background: none; color: var(--acc); } |
|
| 104 | 104 | :root[data-look="github"] .side-empty button.linkish { font-size: 12px; } |
|
| 105 | + | :root[data-look="github"] .menu-actions .menu-action { background: none; border: 0; box-shadow: none; border-radius: 0; padding: 6px 14px; text-transform: none; font-size: 13px; font-weight: 400; color: var(--ink); } |
|
| 106 | + | :root[data-look="github"] .menu-actions .menu-action:hover { background: var(--soft); } |
|
| 105 | 107 | ||
| 106 | 108 | /* Boxes: rounded, one border, rows divided inside. */ |
|
| 107 | 109 | :root[data-look="github"] .panel, :root[data-look="github"] form.card, :root[data-look="github"] dl.facts, :root[data-look="github"] .table, |
|
| … | 68 unchanged lines |
modified assets/hub.js +20 −1
| … | 24 unchanged lines |
||
| 25 | 25 | var toolbar = [b("bold", E.toggleBold, "B", "Bold"), b("italic", E.toggleItalic, "I", "Italic"), b("heading", E.toggleHeadingSmaller, "H", "Heading"), "|", |
|
| 26 | 26 | b("code", E.toggleCodeBlock, "</>", "Code"), b("quote", E.toggleBlockquote, "“", "Quote"), b("ul", E.toggleUnorderedList, "•", "List"), b("ol", E.toggleOrderedList, "1.", "Numbered list"), "|", |
|
| 27 | 27 | b("link", E.drawLink, "link", "Link"), "|", b("preview", E.togglePreview, "preview", "Preview")]; |
|
| 28 | - | mds.forEach(function (t) { new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar }); }); |
|
| 28 | + | mds.forEach(function (t) { t._mde = new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar }); }); |
|
| 29 | + | /* An editor shown later (a :target edit form) lays itself out again. */ |
|
| 30 | + | window.addEventListener("hashchange", function () { mds.forEach(function (t) { if (t._mde) setTimeout(function () { t._mde.codemirror.refresh(); }, 0); }); }); |
|
| 29 | 31 | }); |
|
| 30 | 32 | ||
| 31 | 33 | /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */ |
|
| … | 372 unchanged lines |
||
| 404 | 406 | document.addEventListener("click", function (ev) { |
|
| 405 | 407 | document.querySelectorAll("details.menu[open]").forEach(function (d) { if (!d.contains(ev.target)) d.open = false; }); |
|
| 406 | 408 | }); |
|
| 409 | + | ||
| 410 | + | /* ··· menus: copy a link to a part of the page, or some text. */ |
|
| 411 | + | function copied(btn, text) { |
|
| 412 | + | if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () { |
|
| 413 | + | var was = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = was; }, 1200); |
|
| 414 | + | }); |
|
| 415 | + | var d = btn.closest("details"); if (d) d.open = false; |
|
| 416 | + | } |
|
| 417 | + | document.querySelectorAll("[data-copy-link]").forEach(function (b) { |
|
| 418 | + | b.addEventListener("click", function () { copied(b, location.origin + location.pathname + location.search + b.getAttribute("data-copy-link")); }); |
|
| 419 | + | }); |
|
| 420 | + | document.querySelectorAll("[data-copy-text]").forEach(function (b) { |
|
| 421 | + | b.addEventListener("click", function () { copied(b, b.getAttribute("data-copy-text")); }); |
|
| 422 | + | }); |
|
| 423 | + | document.querySelectorAll('.menu-actions a[href^="#"]').forEach(function (a) { |
|
| 424 | + | a.addEventListener("click", function () { var d = a.closest("details"); if (d) d.open = false; }); |
|
| 425 | + | }); |
|
| 407 | 426 | })(); |
modified crates/hub/src/web/proposals.rs +15 −0
| … | 618 unchanged lines |
||
| 619 | 619 | requirements: pdb::Requirements, |
|
| 620 | 620 | landed_already: bool, |
|
| 621 | 621 | can_edit: bool, |
|
| 622 | + | /// The landed proposal's source bookmark, when it is this repository's, |
|
| 623 | + | /// still there, and the viewer may delete it (oxygenhub#86). |
|
| 624 | + | deletable_source: Option<String>, |
|
| 622 | 625 | can_land: bool, |
|
| 623 | 626 | hub_can_land: bool, |
|
| 624 | 627 | notice: Option<String>, |
|
| … | 83 unchanged lines |
||
| 708 | 711 | super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await; |
|
| 709 | 712 | let me = viewer.account(); |
|
| 710 | 713 | let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain); |
|
| 714 | + | let deletable_source = |
|
| 715 | + | if p.state == "landed" && p.source_kind == "bookmark" && role >= Role::Write && p.source_bookmark != row.default_bookmark { |
|
| 716 | + | let b = p.source_bookmark.clone(); |
|
| 717 | + | app.repos |
|
| 718 | + | .read(&repo, move |r, _| Ok(r.view()?.bookmarks.contains_key(&b))) |
|
| 719 | + | .await |
|
| 720 | + | .unwrap_or(false) |
|
| 721 | + | .then(|| p.source_bookmark.clone()) |
|
| 722 | + | } else { |
|
| 723 | + | None |
|
| 724 | + | }; |
|
| 711 | 725 | let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty(); |
|
| 712 | 726 | let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write); |
|
| 713 | 727 | let requested = pdb::review_requests(&app.db, p.id).await.unwrap_or_default(); |
|
| … | 73 unchanged lines |
||
| 787 | 801 | requirements: v.requirements, |
|
| 788 | 802 | landed_already: loaded.landed_already, |
|
| 789 | 803 | can_edit, |
|
| 804 | + | deletable_source, |
|
| 790 | 805 | can_land, |
|
| 791 | 806 | hub_can_land, |
|
| 792 | 807 | notice, |
|
| … | 632 unchanged lines |
modified crates/hub/tests/proposals.rs +11 −0
| … | 247 unchanged lines |
||
| 248 | 248 | assert_eq!(v["landed"]["via"], "web"); |
|
| 249 | 249 | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
|
| 250 | 250 | assert!(page.body.contains("by <a href=\"/andy\">andy</a>, web"), "{}", page.body); |
|
| 251 | + | // oxygenhub#86: a landed card offering to delete the source bookmark, |
|
| 252 | + | // the description edited in place from its ··· menu or the pencil. |
|
| 253 | + | assert!(page.body.contains("Proposal landed") && page.body.contains("value=\"feature\""), "{}", page.body); |
|
| 254 | + | assert!(page.body.contains("can be safely deleted"), "{}", page.body); |
|
| 255 | + | assert!(page.body.contains("class=\"head-pencil\"") && page.body.contains("id=\"edit\"") && page.body.contains("Copy link")); |
|
| 256 | + | assert!(!page.body.contains("Edit title, target and description</summary>"), "no disclosure at the bottom"); |
|
| 257 | + | let csrf = csrf_of(&page.body); |
|
| 258 | + | let r = post(app, "/andy/demo/bookmarks/delete", Some(&andy), &format!("_csrf={csrf}&bookmark=feature")).await; |
|
| 259 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 260 | + | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
|
| 261 | + | assert!(page.body.contains("Proposal landed") && !page.body.contains("can be safely deleted"), "gone, so not offered again"); |
|
| 251 | 262 | // Lists by state through the API. |
|
| 252 | 263 | let list = |q: &'static str| async move { |
|
| 253 | 264 | let r = send(app, "GET", &format!("/api/v1/repos/andy/demo/proposals{q}"), None, None, Some("site")).await; |
|
| … | 49 unchanged lines |
modified templates/proposal.html +62 −38
| … | 4 unchanged lines |
||
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %} |
|
| 6 | 6 | {% block mainclass %}{% if tab != "conversation" %} class="wide"{% endif %}{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | - | <div class="head"><h1>{{ p.title }} <small class="px kind-proposal">proposal #{{ p.number }}</small>{% if can_edit %} <a class="go small head-edit" href="#edit" onclick="document.getElementById('edit').open=true">Edit</a>{% endif %} |
|
| 8 | + | <div class="head"><h1>{{ p.title }}{% if can_edit %} <a class="head-pencil" href="#edit" title="Edit title, target and description" aria-label="Edit title, target and description"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M11.01 1.31a1.75 1.75 0 0 1 2.48 0l1.2 1.2a1.75 1.75 0 0 1 0 2.48L5.1 14.6a1.75 1.75 0 0 1-.8.46l-3.13.88a.75.75 0 0 1-.92-.92l.88-3.13a1.75 1.75 0 0 1 .46-.8Zm1.42 1.06a.25.25 0 0 0-.36 0L10.5 3.94 12.06 5.5l1.57-1.57a.25.25 0 0 0 0-.36ZM11 6.56 9.44 5 3.15 11.3a.25.25 0 0 0-.06.11l-.55 1.95 1.95-.55a.25.25 0 0 0 .11-.06Z"/></svg></a>{% endif %} <small class="px kind-proposal">proposal #{{ p.number }}</small> |
|
| 9 | 9 | <small> |
|
| 10 | 10 | <span class="tag{% if p.state == "landed" %} head{% else if p.state == "closed" %} bad{% endif %}">{{ p.state }}</span> |
|
| 11 | 11 | {{ p.author }} wants <span class="lbl lbl-bm">{{ source_label }}</span> into <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a> |
|
| … | 66 unchanged lines |
||
| 78 | 78 | <div class="tl-item"> |
|
| 79 | 79 | <img class="tl-avatar" src="/avatar/{{ p.author }}" alt="" width="36" height="36"> |
|
| 80 | 80 | <div class="tl-card"> |
|
| 81 | - | <div class="tl-head"><a href="/{{ p.author }}"><strong>{{ p.author }}</strong></a> opened this proposal <time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time>{% if can_edit %} <a class="tl-edit small" href="#edit">edit</a>{% endif %}</div> |
|
| 82 | - | {% if p.body.is_empty() %}<p class="tl-empty mute">No description.</p>{% else %}<article class="doc tl-doc">{{ body_html|safe }}</article>{% endif %} |
|
| 81 | + | <div class="tl-head" id="description"><span><a href="/{{ p.author }}"><strong>{{ p.author }}</strong></a> opened this proposal <a class="tl-when" href="#description"><time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time></a></span> |
|
| 82 | + | <details class="menu tl-menu"><summary class="dots" title="More" aria-label="More options">···</summary> |
|
| 83 | + | <div class="menu-pop menu-actions"> |
|
| 84 | + | <button type="button" class="menu-action" data-copy-link="#description">Copy link</button> |
|
| 85 | + | <button type="button" class="menu-action" data-copy-text="{{ p.body }}">Copy Markdown</button> |
|
| 86 | + | {% if can_edit %}<hr><a class="menu-action" href="#edit">Edit</a>{% endif %} |
|
| 87 | + | </div></details></div> |
|
| 88 | + | <div class="tl-view">{% if p.body.is_empty() %}<p class="tl-empty mute">No description.</p>{% else %}<article class="doc tl-doc">{{ body_html|safe }}</article>{% endif %}</div> |
|
| 89 | + | {% if can_edit %} |
|
| 90 | + | <form class="tl-editform" id="edit" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit"> |
|
| 91 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 92 | + | <div class="edit-row"><label for="etitle">Title</label><input type="text" id="etitle" name="title" value="{{ p.title }}"></div> |
|
| 93 | + | <div class="edit-row"><label for="etarget">Into bookmark</label><input type="text" id="etarget" name="target" value="{{ p.target }}"></div> |
|
| 94 | + | <textarea id="ebody" name="body" class="md" rows="10" aria-label="Description (Markdown)">{{ p.body }}</textarea> |
|
| 95 | + | <div class="tl-actions"><a class="go" href="#description">Cancel</a> <button type="submit" class="primary-btn">Update proposal</button></div> |
|
| 96 | + | </form> |
|
| 97 | + | {% endif %} |
|
| 83 | 98 | </div> |
|
| 84 | 99 | </div> |
|
| 85 | 100 | {% for t in timeline %} |
|
| … | 17 unchanged lines |
||
| 103 | 118 | <p class="tl-event"><span class="tl-dot"></span><img class="tl-mini" src="/avatar/{{ e.actor }}" alt="" width="18" height="18"> <strong>{{ e.actor }}</strong> {{ e.event }}{% if e.event.ends_with("by change") && !e.detail.is_empty() %}{% let hash = e.detail.split(' ').next().unwrap_or("") %} <a href="{{ repo.url }}/change/{{ hash }}"><code>{{ hash }}</code></a>{{ e.detail[hash.len()..] }}{% else if !e.detail.is_empty() %}: {{ e.detail }}{% endif %} <time class="mute" title="{{ e.at_ms|when_i }}">{{ e.at_ms|ago_i }}</time></p> |
|
| 104 | 119 | {% endif %} |
|
| 105 | 120 | {% endfor %} |
|
| 121 | + | {% if p.state == "landed" %} |
|
| 122 | + | <div class="tl-item tl-outcome landed"> |
|
| 123 | + | <span class="tl-badge" aria-hidden="true"><svg viewBox="0 0 16 16" width="18" height="18"><path fill="currentColor" d="M5.45 5.15A3.5 3.5 0 0 0 8.75 7.5h1.94a2.25 2.25 0 1 1 0 1.5H8.75a5 5 0 0 1-3.5-1.43v3.04a2.25 2.25 0 1 1-1.5 0V5.39a2.25 2.25 0 1 1 1.7-.24ZM4.5 2.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25-4.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg></span> |
|
| 124 | + | <div class="tl-card outcome-card"> |
|
| 125 | + | <div><strong>Proposal landed</strong> |
|
| 126 | + | <p class="small">{% if !p.landed_by.is_empty() %}<a href="/{{ p.landed_by }}">{{ p.landed_by }}</a> landed{% else %}Landed{% endif %} {{ changes.len()|plural("change", "changes") }} into <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a>{% if p.landed_ms > 0 %} <time title="{{ p.landed_ms|when_i }}">{{ p.landed_ms|ago_i }}</time>{% endif %}.{% if let Some(b) = deletable_source %} The <code>{{ b }}</code> bookmark can be safely deleted.{% endif %}</p></div> |
|
| 127 | + | {% if let Some(b) = deletable_source %}<form method="post" action="{{ repo.url }}/bookmarks/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b }}"><button type="submit">Delete bookmark</button></form>{% endif %} |
|
| 128 | + | </div> |
|
| 129 | + | </div> |
|
| 130 | + | {% else if p.state == "closed" %} |
|
| 131 | + | <div class="tl-item tl-outcome closed"> |
|
| 132 | + | <span class="tl-badge" aria-hidden="true">✕</span> |
|
| 133 | + | <div class="tl-card outcome-card"><div><strong>Proposal closed</strong><p class="small">It was not landed{% if can_edit %}; reopen it below to carry on{% endif %}.</p></div></div> |
|
| 134 | + | </div> |
|
| 135 | + | {% endif %} |
|
| 106 | 136 | {% if let Some(me) = shell.user %} |
|
| 137 | + | {% if can_edit && (p.state == "draft" || p.is_open() || p.state == "closed") %}<form id="pstate" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/state" hidden><input type="hidden" name="_csrf" value="{{ shell.csrf }}"></form>{% endif %} |
|
| 107 | 138 | <div class="tl-item"> |
|
| 108 | 139 | <img class="tl-avatar" src="/avatar/{{ me }}" alt="" width="36" height="36"> |
|
| 109 | - | <div class="tl-card"> |
|
| 140 | + | <div class="tl-card tl-compose"> |
|
| 110 | 141 | <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment"> |
|
| 111 | 142 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 112 | - | <label for="body">Comment (Markdown)</label><textarea id="body" name="body" class="md" rows="4" required></textarea> |
|
| 113 | - | <div class="tl-actions"><button type="submit" class="primary-btn">Comment</button></div> |
|
| 143 | + | <p class="compose-title">Add a comment</p> |
|
| 144 | + | <textarea id="body" name="body" class="md" rows="4" required aria-label="Comment (Markdown)"></textarea> |
|
| 145 | + | <div class="compose-actions"> |
|
| 146 | + | {% if can_edit %} |
|
| 147 | + | {% if p.state == "draft" %}<button type="submit" form="pstate" name="action" value="ready" formnovalidate>Ready for review</button>{% endif %} |
|
| 148 | + | {% if p.state == "open" %}<button type="submit" form="pstate" name="action" value="draft" formnovalidate>Back to draft</button>{% endif %} |
|
| 149 | + | {% if p.is_open() %}<button type="submit" form="pstate" name="action" value="close" class="danger-btn" formnovalidate>Close proposal</button>{% endif %} |
|
| 150 | + | {% if p.state == "closed" %}<button type="submit" form="pstate" name="action" value="reopen" formnovalidate>Reopen proposal</button>{% endif %} |
|
| 151 | + | {% endif %} |
|
| 152 | + | <button type="submit" class="primary-btn">Comment</button> |
|
| 153 | + | </div> |
|
| 114 | 154 | </form> |
|
| 115 | - | {% if can_edit && (p.state == "draft" || p.is_open() || p.state == "closed") %} |
|
| 116 | - | <form class="tl-actions" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/state"> |
|
| 117 | - | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 118 | - | {% if p.state == "draft" %}<button type="submit" name="action" value="ready">Ready for review</button>{% endif %} |
|
| 119 | - | {% if p.state == "open" %}<button type="submit" name="action" value="draft">Back to draft</button>{% endif %} |
|
| 120 | - | {% if p.is_open() %}<button type="submit" name="action" value="close" class="danger-btn">Close proposal</button>{% endif %} |
|
| 121 | - | {% if p.state == "closed" %}<button type="submit" name="action" value="reopen">Reopen</button>{% endif %} |
|
| 122 | - | </form> |
|
| 123 | - | {% endif %} |
|
| 124 | 155 | </div> |
|
| 125 | 156 | </div> |
|
| 126 | 157 | {% if p.is_open() %} |
|
| 127 | - | <form class="card tl-review" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/review"> |
|
| 128 | - | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 129 | - | <h2>Review</h2> |
|
| 130 | - | <label for="rbody">Summary (optional)</label><textarea id="rbody" name="body" rows="3"></textarea> |
|
| 131 | - | <div class="choice"> |
|
| 132 | - | <label><input type="radio" name="verdict" value="comment" checked> comment</label> |
|
| 133 | - | <label><input type="radio" name="verdict" value="approve"> approve</label> |
|
| 134 | - | <label><input type="radio" name="verdict" value="changes"> request changes</label> |
|
| 135 | - | </div> |
|
| 136 | - | <p class="small mute">An approval counts once you seal the changes from a client: <code>oxy seal --proposal {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</code></p> |
|
| 137 | - | <button type="submit">SUBMIT REVIEW</button> |
|
| 138 | - | </form> |
|
| 139 | - | {% endif %} |
|
| 140 | - | {% endif %} |
|
| 141 | - | {% if can_edit %} |
|
| 142 | - | <details class="panel tl-editbox" id="edit"> |
|
| 143 | - | <summary>Edit title, target and description</summary> |
|
| 144 | - | <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit"> |
|
| 158 | + | <div class="tl-item"> |
|
| 159 | + | <span class="tl-badge review" aria-hidden="true">◈</span> |
|
| 160 | + | <form class="tl-card tl-compose" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/review"> |
|
| 145 | 161 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 146 | - | <label for="etitle">Title</label><input type="text" id="etitle" name="title" value="{{ p.title }}"> |
|
| 147 | - | <label for="etarget">Target bookmark</label><input type="text" id="etarget" name="target" value="{{ p.target }}"> |
|
| 148 | - | <label for="ebody">Description (Markdown)</label><textarea id="ebody" name="body" class="md" rows="8">{{ p.body }}</textarea> |
|
| 149 | - | <div class="tl-actions"><button type="submit" class="primary-btn">Save</button></div> |
|
| 162 | + | <p class="compose-title">Review this proposal</p> |
|
| 163 | + | <textarea id="rbody" name="body" rows="2" placeholder="Leave a summary (optional)" aria-label="Review summary"></textarea> |
|
| 164 | + | <div class="compose-actions"> |
|
| 165 | + | <div class="segmented" role="radiogroup" aria-label="Verdict"> |
|
| 166 | + | <label><input type="radio" name="verdict" value="comment" checked><span>Comment</span></label> |
|
| 167 | + | <label><input type="radio" name="verdict" value="approve"><span>Approve</span></label> |
|
| 168 | + | <label><input type="radio" name="verdict" value="changes"><span>Request changes</span></label> |
|
| 169 | + | </div> |
|
| 170 | + | <button type="submit" class="primary-btn">Submit review</button> |
|
| 171 | + | </div> |
|
| 172 | + | <p class="compose-note">An approval counts once you seal the changes from a client: <code>oxy seal --proposal {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</code></p> |
|
| 150 | 173 | </form> |
|
| 151 | - | </details> |
|
| 174 | + | </div> |
|
| 175 | + | {% endif %} |
|
| 152 | 176 | {% endif %} |
|
| 153 | 177 | </div> |
|
| 154 | 178 | <aside class="side side-sections"> |
|
| … | 64 unchanged lines |
modified templates/style.css +43 −0
| … | 712 unchanged lines |
||
| 713 | 713 | .device-done.no .device-mark { background: var(--bad); } |
|
| 714 | 714 | .side-note-inline { font-size: 12px; color: var(--mute); font-weight: 400; } |
|
| 715 | 715 | .side-list li { margin: 0 0 4px; } |
|
| 716 | + | ||
| 717 | + | /* The proposal conversation (oxygenhub#86): a pencil by the title, a ··· |
|
| 718 | + | menu on the description that edits it in place, one action row under |
|
| 719 | + | the comment box, a compact review, and a card when it landed. */ |
|
| 720 | + | .head-pencil { color: var(--mute); display: inline-flex; vertical-align: middle; padding: 4px; margin-left: 6px; border-radius: 6px; } |
|
| 721 | + | .head-pencil:hover { color: var(--acc); background: var(--soft); } |
|
| 722 | + | .tl-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } |
|
| 723 | + | .tl-head .tl-when { color: inherit; } |
|
| 724 | + | .tl-menu > summary.dots { font-weight: 700; letter-spacing: 1px; padding: 0 6px; line-height: 20px; } |
|
| 725 | + | .menu-pop.menu-actions { width: 180px; padding: 6px 0; top: 26px; } |
|
| 726 | + | .menu-actions hr { border: 0; border-top: 1px solid var(--line); margin: 6px 0; } |
|
| 727 | + | .menu-action { all: unset; display: block; box-sizing: border-box; width: 100%; padding: 6px 14px; font-size: 13px; color: var(--ink); cursor: pointer; } |
|
| 728 | + | .menu-action:hover { background: var(--soft); color: var(--ink); } |
|
| 729 | + | .tl-editform { display: none; padding: 12px 14px; } |
|
| 730 | + | .tl-editform:target { display: block; } |
|
| 731 | + | .tl-card:has(.tl-editform:target) .tl-view { display: none; } |
|
| 732 | + | .edit-row { display: grid; grid-template-columns: 110px 1fr; align-items: center; gap: 10px; margin-bottom: 8px; } |
|
| 733 | + | .edit-row label { margin: 0; font-size: 13px; font-weight: 600; color: var(--ink); text-transform: none; letter-spacing: 0; } |
|
| 734 | + | .tl-editform .tl-actions { margin-top: 10px; align-items: center; } |
|
| 735 | + | .tl-compose { padding: 12px 14px; } |
|
| 736 | + | .tl-card.tl-compose form { padding: 0; } |
|
| 737 | + | form.tl-card.tl-compose { padding: 12px 14px; margin: 0; } |
|
| 738 | + | .tl-compose textarea:not(.md) { font: inherit; font-size: 14px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); resize: vertical; } |
|
| 739 | + | .compose-title { margin: 0 0 8px; font-weight: 600; font-size: 14px; color: var(--ink); } |
|
| 740 | + | .compose-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 10px; } |
|
| 741 | + | .compose-actions .segmented { margin-right: auto; } |
|
| 742 | + | .compose-note { margin: 10px 0 0; font-size: 12px; color: var(--mute); } |
|
| 743 | + | .segmented { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; } |
|
| 744 | + | .segmented label { margin: 0; text-transform: none; letter-spacing: 0; cursor: pointer; } |
|
| 745 | + | .segmented input { position: absolute; opacity: 0; pointer-events: none; } |
|
| 746 | + | .segmented span { display: block; padding: 5px 12px; font-size: 13px; font-weight: 500; color: var(--ink); border-right: 1px solid var(--line); } |
|
| 747 | + | .segmented label:last-child span { border-right: 0; } |
|
| 748 | + | .segmented input:checked + span { background: var(--acc); color: #fff; } |
|
| 749 | + | .segmented input:focus-visible + span { outline: 2px solid var(--acc); outline-offset: -2px; } |
|
| 750 | + | .tl-badge { width: 36px; height: 36px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: #fff; background: var(--mute); font-size: 15px; } |
|
| 751 | + | .tl-badge.review { background: var(--soft); color: var(--mute); border: 1px solid var(--line); } |
|
| 752 | + | .tl-outcome.landed .tl-badge { background: #8250df; } |
|
| 753 | + | .tl-outcome.closed .tl-badge { background: var(--bad); } |
|
| 754 | + | .outcome-card { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; } |
|
| 755 | + | .tl-outcome.landed .outcome-card { border-color: #8250df; } |
|
| 756 | + | .outcome-card p { margin: 4px 0 0; color: var(--mute); } |
|
| 757 | + | .outcome-card form { margin: 0; padding: 0; } |
|
| 758 | + | .outcome-card form button { white-space: nowrap; } |