OXYGENHUB

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; }