git-ents.gitmain
⌘K
foforge
commit ddab0ef
roots: cap reading content at a narrow width and grid-align forms

A shared .readable wrapper (58rem) keeps entity cards, threads, and forms from smearing across wide viewports while the commits table, file lists, and diffs keep the full shell width. Form fields become compact two-column grid rows (label left, control right, stacking on mobile); rev/lines/state/verdict size to their content, textareas get a useful height, and verdict and issue state gain datalists of the conventional values — text inputs, not selects, since both are open vocabularies per model.review and model.issue.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/assets/ents.css @@ -28,6 +28,7 @@ --font-serif: ui-serif, Georgia, serif; --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; --max-width: 78rem; + --content-narrow: 58rem; --color-bg: #faf8f4; --color-surface: #fff; --color-text: #2a2518; @@ -148,6 +149,11 @@ } .content { max-width: var(--max-width); width: 100%; margin: 0 auto; padding: 2.25rem 1.5rem 3rem; flex: 1; } +/* Single-column reading content (an entity's cards, a discussion thread, + * a form) caps at `--content-narrow` so it never smears across a wide + * viewport; wide surfaces (the commits table, file lists, diffs) stay at + * the shell's full `--max-width`. */ +.readable { max-width: var(--content-narrow); } .page-header { margin-bottom: 1.75rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; } .page-header::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; } .page-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 700; letter-spacing: -.01em; line-height: 1.3; } @@ -267,12 +273,20 @@ .badge { font-family: var(--font-mono); font-size: .68rem; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: 0 .5rem; margin-left: .5rem; } -/* Forms: the one write flow this crate demonstrates (`account`, `comments`). */ -form { display: flex; flex-direction: column; gap: .5rem; max-width: 40rem; margin-bottom: 1.5rem; } -form label { display: flex; flex-direction: column; gap: .25rem; font-family: var(--font-sans); font-size: .82rem; font-weight: 600; color: var(--color-text-muted); } +/* Forms (`account`, `comments`, `issues`, the review form): each labeled + * field is a compact two-column grid row -- the label in a fixed left + * column, its control on the right -- stacking back to one column under + * the mobile breakpoint. Controls size to their content: the short + * token-shaped fields (`rev`, `lines`, `state`, `verdict`) stop at + * `12rem` instead of stretching, while `title`/`path` inputs and `body` + * textareas keep the full control column. */ +form { display: flex; flex-direction: column; gap: .6rem; margin-bottom: 1.5rem; } +form label { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: .25rem .9rem; align-items: center; font-family: var(--font-sans); font-size: .82rem; font-weight: 600; color: var(--color-text-muted); } +form label:has(textarea) { align-items: start; } form input, form textarea { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem; } form input:focus, form textarea:focus { border-color: var(--color-accent); } -form textarea { resize: vertical; } +form input[name="rev"], form input[name="lines"], form input[name="state"], form input[name="verdict"] { width: 12rem; justify-self: start; } +form textarea { resize: vertical; min-height: 9rem; } form button { align-self: flex-start; font-size: .88rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: .45rem 1rem; cursor: pointer; transition: background .15s; } form button:hover { background: var(--color-link-hover); } @@ -420,6 +434,7 @@ @media (max-width: 640px) { html { font-size: 16px; } .content { padding: 1.5rem 1.25rem 2.5rem; } + form label { grid-template-columns: minmax(0, 1fr); } .nav-inner { gap: .75rem; padding: 0 1.25rem; } .repo-header { padding: 1rem 1.25rem .75rem; } .tabs { padding: 0 1.25rem; }
crates/cli/ents-web/src/pages/account.rs @@ -57,13 +57,15 @@ super::Tab::Account, "account", html! { - (view) - h2 { "create or update" } - form method="post" action="/account" { - (super::csrf_input(&session)) - label { "member" input type="text" name="member" value=(member_value); } - label { "login" input type="text" name="login" value=(login_value); } - button type="submit" { "save" } + div.readable { + (view) + h2 { "create or update" } + form method="post" action="/account" { + (super::csrf_input(&session)) + label { "member" input type="text" name="member" value=(member_value); } + label { "login" input type="text" name="login" value=(login_value); } + button type="submit" { "save" } + } } }, ))
crates/cli/ents-web/src/pages/comments.rs @@ -85,16 +85,18 @@ super::Tab::Comments, "comments", html! { - ul { - @for (id, comment) in &rows { - li { - a href=(format!("/comments/{id}")) { (ents_forge::abbreviate_id(id)) } - ": " (comment.body) + div.readable { + ul { + @for (id, comment) in &rows { + li { + a href=(format!("/comments/{id}")) { (ents_forge::abbreviate_id(id)) } + ": " (comment.body) + } } } + h2 { "add a comment" } + (add_form(&query.rev, &session, &query.file, &query.lines)) } - h2 { "add a comment" } - (add_form(&query.rev, &session, &query.file, &query.lines)) }, )) } @@ -149,22 +151,24 @@ ents_forge::abbreviate_id(&id), html! { (super::child_crumbs("comments", "/comments", ents_forge::abbreviate_id(&id))) - dl { - dt { "state" } dd { (comment.state) } - @if let Some(context) = &comment.context { - dt { "context" } dd { (context) } + div.readable { + dl { + dt { "state" } dd { (comment.state) } + @if let Some(context) = &comment.context { + dt { "context" } dd { (context) } + } + @if let Some(parent) = &comment.parent { + dt { "parent" } dd { (parent) } + } + @if let Some((anchor, projection)) = &projected { + dt { "path" } dd { (anchor.path) } + dt { "lines" } dd { (format!("{:?}", anchor.lines)) } + dt { "projection at " (query.rev) } dd { (format!("{projection:?}")) } + } + dt { "body" } dd { (comment.body) } } - @if let Some(parent) = &comment.parent { - dt { "parent" } dd { (parent) } - } - @if let Some((anchor, projection)) = &projected { - dt { "path" } dd { (anchor.path) } - dt { "lines" } dd { (format!("{:?}", anchor.lines)) } - dt { "projection at " (query.rev) } dd { (format!("{projection:?}")) } - } - dt { "body" } dd { (comment.body) } + (action_forms(&session, &id, resolved, &return_to)) } - (action_forms(&session, &id, resolved, &return_to)) }, )) }
crates/cli/ents-web/src/pages/commits.rs @@ -263,42 +263,50 @@ &subject, html! { (super::child_crumbs("commits", "/commits", &super::short_oid(&object_id))) - div.card { - div.card-header { "commit " code { (super::short_oid(&object_id)) } } - div.commit { - div.commit-subject { (subject) } - @if let Some(body) = &body { - div.commit-msg { (body) } - } - div.commit-meta { - (author_name) - @if let Some(ago) = &ago { " \u{b7} " (ago) } - } - div.commit-meta { - "tree " a href={ "/files" } { "browse at HEAD" } - @if !parents.is_empty() { - " \u{b7} parents: " - @for (index, parent) in parents.iter().enumerate() { - @if index > 0 { ", " } - a href={ "/commit/" (parent) } { code { (super::short_oid(parent)) } } - } - } @else { - " \u{b7} root commit" + // The commit card, its reviews, and the conversation are + // single-column reading content, capped at `.readable`'s + // narrow width; only the diff sections between them keep the + // shell's full width (see `ents.css`'s own `.readable` note). + div.readable { + div.card { + div.card-header { "commit " code { (super::short_oid(&object_id)) } } + div.commit { + div.commit-subject { (subject) } + @if let Some(body) = &body { + div.commit-msg { (body) } + } + div.commit-meta { + (author_name) + @if let Some(ago) = &ago { " \u{b7} " (ago) } + } + div.commit-meta { + "tree " a href={ "/files" } { "browse at HEAD" } + @if !parents.is_empty() { + " \u{b7} parents: " + @for (index, parent) in parents.iter().enumerate() { + @if index > 0 { ", " } + a href={ "/commit/" (parent) } { code { (super::short_oid(parent)) } } + } + } @else { + " \u{b7} root commit" + } + " \u{b7} " + a href={ "/comments?rev=" (object_id) } { "comment on this commit" } } - " \u{b7} " - a href={ "/comments?rev=" (object_id) } { "comment on this commit" } } } + (reviews) } - (reviews) (diff) @if truncated { div.card { div.binary { "Diff truncated (over " (MAX_DIFF_BYTES / (1024 * 1024)) " MiB)." } } } @if !comments.is_empty() { - h2 { "conversation" } - @for (index, comment) in comments.iter().enumerate() { - (super::comments::comment_card(index, comment, super::comments::LinkMode::CrossFile)) + div.readable { + h2 { "conversation" } + @for (index, comment) in comments.iter().enumerate() { + (super::comments::comment_card(index, comment, super::comments::LinkMode::CrossFile)) + } } } }, @@ -444,12 +452,21 @@ /// The start-a-review form (`POST /commit/{oid}/review`): a verdict /// (`approve`, `request-changes`, or any custom value -- `model.review` -/// makes these conventions, not an enum) and a body. +/// makes these conventions, not an enum, which is exactly why the verdict +/// field is a free text input with a `datalist` of the conventional +/// values, never a closed `select`) and a body. fn start_review_form(session: &Session, oid: &str) -> Markup { html! { form method="post" action=(format!("/commit/{oid}/review")) { (super::csrf_input(session)) - label { "verdict" input type="text" name="verdict" value="approve"; } + label { + "verdict" + input type="text" name="verdict" value="approve" list="verdict-values"; + } + datalist id="verdict-values" { + option value="approve" {} + option value="request-changes" {} + } label { "body" textarea name="body" {} } button type="submit" { "start a review" } }
crates/cli/ents-web/src/pages/issues.rs @@ -51,27 +51,29 @@ super::Tab::Issues, "issues", html! { - @if rows.is_empty() { - p { "No issues yet." } - } @else { - table.entity-list { - thead { - tr { th { "issue" } th { "state" } th { "assignees" } th { "labels" } } - } - tbody { - @for (id, issue) in &rows { - tr { - td { a href=(format!("/issues/{id}")) { (issue.title) } } - td { span.comment-state { (issue.state) } } - td { (join_members(&issue.assignees)) } - td { (issue.labels.join(", ")) } + div.readable { + @if rows.is_empty() { + p { "No issues yet." } + } @else { + table.entity-list { + thead { + tr { th { "issue" } th { "state" } th { "assignees" } th { "labels" } } + } + tbody { + @for (id, issue) in &rows { + tr { + td { a href=(format!("/issues/{id}")) { (issue.title) } } + td { span.comment-state { (issue.state) } } + td { (join_members(&issue.assignees)) } + td { (issue.labels.join(", ")) } + } } } } } + h2 { "open an issue" } + (new_form(&session)) } - h2 { "open an issue" } - (new_form(&session)) }, )) } @@ -109,22 +111,24 @@ &issue.title, html! { (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id))) - div.card { - dl { - dt { "state" } dd { span.comment-state { (issue.state) } } - dt { "assignees" } dd { (join_members(&issue.assignees)) } - dt { "labels" } dd { (issue.labels.join(", ")) } + div.readable { + div.card { + dl { + dt { "state" } dd { span.comment-state { (issue.state) } } + dt { "assignees" } dd { (join_members(&issue.assignees)) } + dt { "labels" } dd { (issue.labels.join(", ")) } + } + div.doc-body { (body) } } - div.doc-body { (body) } + details { + summary { "edit" } + (edit_form(&session, &issue)) + } + h2 { "discussion" } + (crate::pages::comments::thread_section(&state, &session, &thread, &return_to)) + h2 { "add a comment" } + (comment_form(&session, &id)) } - details { - summary { "edit" } - (edit_form(&session, &issue)) - } - h2 { "discussion" } - (crate::pages::comments::thread_section(&state, &session, &thread, &return_to)) - h2 { "add a comment" } - (comment_form(&session, &id)) }, )) } @@ -304,13 +308,21 @@ Ok(Redirect::to(&format!("/issues/{id}"))) } -/// The open-an-issue form (`POST /issues`). +/// The open-an-issue form (`POST /issues`). The `state` field is a free +/// text input with a [`state_datalist`] of the conventional values, never +/// a closed `select` -- `model.issue` keeps states an open vocabulary +/// ("custom states are schema, not platform features"; see +/// [`ents_forge::Issue`]'s own doc). fn new_form(session: &Session) -> Markup { html! { form method="post" action="/issues" { (super::csrf_input(session)) label { "title" input type="text" name="title"; } - label { "state" input type="text" name="state" value="open"; } + label { + "state" + input type="text" name="state" value="open" list="issue-states"; + } + (state_datalist()) label { "assignees" input type="text" name="assignees" placeholder="alice, bob"; } label { "labels" input type="text" name="labels" placeholder="bug, gate"; } label { "body" textarea name="body" {} } @@ -320,12 +332,17 @@ } /// The edit-issue form (`POST /issues/{id}`), its fields pre-filled from -/// the current issue. +/// the current issue. Its `state` field carries the same [`state_datalist`] +/// as [`new_form`]'s, for the same open-vocabulary reason. fn edit_form(session: &Session, issue: &ents_forge::Issue) -> Markup { html! { form method="post" action="" { (super::csrf_input(session)) - label { "state" input type="text" name="state" value=(issue.state); } + label { + "state" + input type="text" name="state" value=(issue.state) list="issue-states"; + } + (state_datalist()) label { "assignees" input type="text" name="assignees" value=(join_members(&issue.assignees)); @@ -336,6 +353,20 @@ } } +/// The `datalist` of conventional issue states both forms above attach to +/// their `state` input -- suggestions only, since `model.issue`'s state is +/// an open string vocabulary, not an enum a `select` could close over. +/// Rendered once per form; the two forms never share a page, so the id +/// never collides. +fn state_datalist() -> Markup { + html! { + datalist id="issue-states" { + option value="open" {} + option value="closed" {} + } + } +} + /// The comment-on-this-issue form (`POST /issues/{id}/comment`). fn comment_form(session: &Session, id: &str) -> Markup { html! {