Modern form controls site-wide; Projects as a gear menu 9e50caedf4db
Text fields, textareas and selects everywhere get a rounded border, padding, a focus ring and (selects) a drawn chevron, at zero specificity so places that size their controls still do, and over the older per-place rules in the GitHub look. Projects on issues and proposals is a menu of the owner's projects. Test updated. closes #87
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 19:59 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
9e50caedf4db9ae2d9f51c958799422847374bcdb257ff5ca9bddb73bd1c3cc9- Depends on
- 18e381ea88c2 4f37afcc3583 7493abf94a41 749d9a5fcd9f 8e96499f2705 a1b9869565aa b5668108487f b64e2537d702 cb71e18eb6f9 d23464c0ac60 de505df5cf8d
- Depended on by
- 4866be1c9040 c9aacc0e7dc1 8475176972e8 991b55638aaf d7ce41acf22c
- 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
- 11 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Modern form controls everywhere (#87).** Every text field, textarea and dropdown on the site is rounded and padded, rings in the accent colour on focus, and dropdowns draw their own chevron; checkboxes and radios take the accent colour. On issues and proposals, Projects is a gear menu like Labels and Reviewers instead of a dropdown and Add button. |
|
| 8 | + | ||
| 7 | 9 | - **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 | 10 | ||
| 9 | 11 | - **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. |
|
| … | 45 unchanged lines |
modified assets/github.css +12 −0
| … | 180 unchanged lines |
||
| 181 | 181 | :root[data-look="github"] table.tree td { padding: 8px 16px; } |
|
| 182 | 182 | :root[data-look="github"] table.tree td.g { color: var(--mute); } |
|
| 183 | 183 | :root[data-look="github"] table.tree tr:first-child > td { border-top: 0; } |
|
| 184 | + | ||
| 185 | + | /* Form controls everywhere get the look's rounding and focus ring, over |
|
| 186 | + | the older per-place rules (oxygenhub#87). */ |
|
| 187 | + | :root[data-look="github"] :is(input[type=text], input[type=search], input[type=email], input[type=password], input[type=number], input[type=url], input[type=date], input:not([type]), textarea, select) { |
|
| 188 | + | border-radius: 6px; border-color: var(--line); background-color: var(--bg); color: var(--ink); |
|
| 189 | + | } |
|
| 190 | + | :root[data-look="github"] :is(input[type=text], input[type=search], input[type=email], input[type=password], input[type=number], input[type=url], input[type=date], input:not([type]), textarea, select):focus { |
|
| 191 | + | outline: none; border-color: var(--acc); box-shadow: 0 0 0 3px color-mix(in srgb, var(--acc) 22%, transparent); |
|
| 192 | + | } |
|
| 193 | + | :root[data-look="github"] select:not([multiple]) { padding-right: 30px; } |
|
| 194 | + | :root[data-look="github"] button.menu-item.menu-pick { background: none; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; box-shadow: none; padding: 8px 12px; text-transform: none; font-size: 13px; font-weight: 400; text-align: left; } |
|
| 195 | + | :root[data-look="github"] button.menu-item.menu-pick:hover { background: var(--soft); } |
modified crates/hub/tests/api.rs +4 −1
| … | 1237 unchanged lines |
||
| 1238 | 1238 | let (st, _) = api(app, "POST", "/api/v1/projects/andy", Some("site"), Some(r#"{"title":"Board"}"#)).await; |
|
| 1239 | 1239 | assert!(st.is_success(), "{st}"); |
|
| 1240 | 1240 | let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body; |
|
| 1241 | - | assert!(p.contains("<option value=\"1\">Board</option>"), "the owner's projects to add to"); |
|
| 1241 | + | assert!( |
|
| 1242 | + | p.contains("name=\"project\" value=\"1\"") && p.contains("Add to a project") && p.contains("Board<small>"), |
|
| 1243 | + | "the owner's projects to add to, in a menu" |
|
| 1244 | + | ); |
|
| 1242 | 1245 | let csrf = csrf_of(&p); |
|
| 1243 | 1246 | call(app, "POST", "/andy/demo/proposal/3/project", None, Some(&andy), None, Some(&format!("_csrf={csrf}&project=1&action=add"))).await; |
|
| 1244 | 1247 | let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body; |
|
| … | 100 unchanged lines |
modified templates/issue.html +8 −2
| … | 81 unchanged lines |
||
| 82 | 82 | {% if let Some(ms) = milestone %}<p class="side-value"><a href="{{ repo.url }}/milestones">{{ ms.title }}</a></p>{% else %}<p class="side-empty">No milestone</p>{% endif %} |
|
| 83 | 83 | </section> |
|
| 84 | 84 | <section class="side-sec"> |
|
| 85 | - | <div class="side-head"><h3>Projects</h3></div> |
|
| 85 | + | <div class="side-head"><h3>Projects</h3> |
|
| 86 | + | {% if let Some(choices) = project_choices %}{% if !choices.is_empty() %}<details class="menu"><summary class="gear" title="Add to a project" aria-label="Add to a project">{% include "gear.html" %}</summary> |
|
| 87 | + | <form class="menu-pop" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="add"> |
|
| 88 | + | <p class="menu-title">Add to a project</p> |
|
| 89 | + | {% if choices.len() > 6 %}<input class="menu-filter" type="search" placeholder="Filter projects" aria-label="Filter projects" data-menu-filter>{% endif %} |
|
| 90 | + | <div class="menu-list">{% for pr in choices %}<button type="submit" name="project" value="{{ pr.number }}" class="menu-item menu-pick"><span class="menu-name">{{ pr.title }}<small>{{ pr.owner }}</small></span></button>{% endfor %}</div> |
|
| 91 | + | </form></details>{% endif %}{% endif %}</div> |
|
| 86 | 92 | {% if projects.is_empty() %}<p class="side-empty">None yet</p>{% else %}<ul class="plain small side-list">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %}<form class="inline" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 87 | - | {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="side-note"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% else %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="project" aria-label="Project">{% for pr in choices %}<option value="{{ pr.number }}">{{ pr.title }}</option>{% endfor %}</select> <button name="action" value="add" class="small">Add</button></form>{% endif %}{% endif %} |
|
| 93 | + | {% if let Some(choices) = project_choices %}{% if choices.is_empty() && projects.is_empty() %}<p class="side-note"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% endif %} |
|
| 88 | 94 | </section> |
|
| 89 | 95 | {% if can_triage %} |
|
| 90 | 96 | <section class="side-sec side-actions"> |
|
| … | 11 unchanged lines |
modified templates/proposal.html +8 −2
| … | 216 unchanged lines |
||
| 217 | 217 | {% if labels.is_empty() %}<p class="side-empty">None yet</p>{% else %}<p class="side-labels">{% for l in labels %}<span class="label" style="--c: {{ l.colour }}" title="{{ l.description }}">{{ l.name }}</span> {% endfor %}</p>{% endif %} |
|
| 218 | 218 | </section> |
|
| 219 | 219 | <section class="side-sec"> |
|
| 220 | - | <div class="side-head"><h3>Projects</h3></div> |
|
| 220 | + | <div class="side-head"><h3>Projects</h3> |
|
| 221 | + | {% if let Some(choices) = project_choices %}{% if !choices.is_empty() %}<details class="menu"><summary class="gear" title="Add to a project" aria-label="Add to a project">{% include "gear.html" %}</summary> |
|
| 222 | + | <form class="menu-pop" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="add"> |
|
| 223 | + | <p class="menu-title">Add to a project</p> |
|
| 224 | + | {% if choices.len() > 6 %}<input class="menu-filter" type="search" placeholder="Filter projects" aria-label="Filter projects" data-menu-filter>{% endif %} |
|
| 225 | + | <div class="menu-list">{% for pr in choices %}<button type="submit" name="project" value="{{ pr.number }}" class="menu-item menu-pick"><span class="menu-name">{{ pr.title }}<small>{{ pr.owner }}</small></span></button>{% endfor %}</div> |
|
| 226 | + | </form></details>{% endif %}{% endif %}</div> |
|
| 221 | 227 | {% if projects.is_empty() %}<p class="side-empty">None yet</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %}<form class="inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 222 | - | {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="side-note"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% else %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="project" aria-label="Project">{% for pr in choices %}<option value="{{ pr.number }}">{{ pr.title }}</option>{% endfor %}</select> <button name="action" value="add" class="small">Add</button></form>{% endif %}{% endif %} |
|
| 228 | + | {% if let Some(choices) = project_choices %}{% if choices.is_empty() && projects.is_empty() %}<p class="side-note"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% endif %} |
|
| 223 | 229 | </section> |
|
| 224 | 230 | <section class="side-sec"> |
|
| 225 | 231 | <div class="side-head"><h3>Checks</h3></div> |
|
| … | 17 unchanged lines |
modified templates/style.css +22 −0
| … | 755 unchanged lines |
||
| 756 | 756 | .outcome-card p { margin: 4px 0 0; color: var(--mute); } |
|
| 757 | 757 | .outcome-card form { margin: 0; padding: 0; } |
|
| 758 | 758 | .outcome-card form button { white-space: nowrap; } |
|
| 759 | + | ||
| 760 | + | /* Form controls, site-wide (oxygenhub#87): every text field, textarea and |
|
| 761 | + | select is rounded, padded and rings on focus; a select draws its own |
|
| 762 | + | chevron. Zero specificity, so a place that sizes its controls still |
|
| 763 | + | does. */ |
|
| 764 | + | :where(input[type=text], input[type=search], input[type=email], input[type=password], input[type=number], input[type=url], input[type=date], input:not([type]), textarea, select) { |
|
| 765 | + | box-sizing: border-box; font: inherit; font-size: 14px; line-height: 20px; color: var(--ink); |
|
| 766 | + | background-color: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 5px 10px; |
|
| 767 | + | } |
|
| 768 | + | :where(input[type=text], input[type=search], input[type=email], input[type=password], input[type=number], input[type=url], input[type=date], input:not([type]), textarea, select):focus { |
|
| 769 | + | outline: none; border-color: var(--acc); box-shadow: 0 0 0 3px color-mix(in srgb, var(--acc) 22%, transparent); |
|
| 770 | + | } |
|
| 771 | + | :where(input, textarea)::placeholder { color: var(--mute); opacity: 1; } |
|
| 772 | + | select:not([multiple]) { |
|
| 773 | + | appearance: none; -webkit-appearance: none; padding-right: 30px; cursor: pointer; |
|
| 774 | + | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%238592ad' d='M4.4 6.2a.75.75 0 0 1 1.06 0L8 8.74l2.54-2.54a.75.75 0 1 1 1.06 1.06l-3.07 3.07a.75.75 0 0 1-1.06 0L4.4 7.26a.75.75 0 0 1 0-1.06Z'/%3E%3C/svg%3E"); |
|
| 775 | + | background-repeat: no-repeat; background-position: right 8px center; background-size: 16px 16px; |
|
| 776 | + | } |
|
| 777 | + | input[type=checkbox], input[type=radio] { accent-color: var(--acc); } |
|
| 778 | + | .edit-row input { width: 100%; } |
|
| 779 | + | button.menu-item.menu-pick { all: unset; box-sizing: border-box; width: 100%; display: flex; padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer; font-size: 13px; color: var(--ink); } |
|
| 780 | + | button.menu-item.menu-pick:hover, button.menu-item.menu-pick:focus-visible { background: var(--soft); } |