This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 75d456f  Show only options on the vote page, and move checks to a new 
page
75d456f is described below

commit 75d456f5faefefe752cb5f1acddd0ce12e60fe36
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Dec 3 20:48:39 2025 +0000

    Show only options on the vote page, and move checks to a new page
---
 atr/admin/templates/all-releases.html        |   2 +-
 atr/admin/templates/validation.html          |   2 +-
 atr/db/interaction.py                        |   4 +-
 atr/get/__init__.py                          |   2 +
 atr/get/checks.py                            | 503 +++++++++++++++++++++++
 atr/get/file.py                              |   2 +-
 atr/get/keys.py                              |   6 +-
 atr/get/test.py                              |  33 ++
 atr/get/vote.py                              | 573 +++++++++++++++++++++++----
 atr/htm.py                                   |   2 +-
 atr/static/css/atr.css                       |  11 +
 atr/template.py                              |   6 +-
 atr/templates/blank.html                     |   3 +
 atr/templates/check-selected-path-table.html |   2 +-
 atr/templates/committee-view.html            |   2 +-
 atr/templates/includes/sidebar.html          |   4 +-
 16 files changed, 1058 insertions(+), 99 deletions(-)

diff --git a/atr/admin/templates/all-releases.html 
b/atr/admin/templates/all-releases.html
index 7c6673d..071782f 100644
--- a/atr/admin/templates/all-releases.html
+++ b/atr/admin/templates/all-releases.html
@@ -11,7 +11,7 @@
 {% block content %}
   <h1>All releases</h1>
 
-  <table class="table table-striped table-hover">
+  <table class="table table-striped">
     <thead>
       <tr>
         <th>Release name</th>
diff --git a/atr/admin/templates/validation.html 
b/atr/admin/templates/validation.html
index 6adb110..a57ea85 100644
--- a/atr/admin/templates/validation.html
+++ b/atr/admin/templates/validation.html
@@ -14,7 +14,7 @@
   {% if divergences|length == 0 %}
     <p>No validation errors were found.</p>
   {% else %}
-    <table class="table table-striped table-hover">
+    <table class="table table-striped">
       <thead>
         <tr>
           <th>Components</th>
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index d0a26c3..e59aebb 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -237,13 +237,13 @@ async def previews(project: sql.Project) -> 
list[sql.Release]:
     return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
 
 
-async def release_latest_vote_task(release: sql.Release) -> sql.Task | None:
+async def release_latest_vote_task(release: sql.Release, caller_data: 
db.Session | None = None) -> sql.Task | None:
     """Find the most recent VOTE_INITIATE task for this release."""
     disallowed_statuses = [sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]
     if util.is_dev_environment():
         disallowed_statuses = []
     via = sql.validate_instrumented_attribute
-    async with db.session() as data:
+    async with db.ensure_session(caller_data) as data:
         query = (
             sqlmodel.select(sql.Task)
             .where(sql.Task.project_name == release.project_name)
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 0273f3c..103e9e3 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -21,6 +21,7 @@ from typing import Final, Literal
 
 import atr.get.announce as announce
 import atr.get.candidate as candidate
+import atr.get.checks as checks
 import atr.get.committees as committees
 import atr.get.compose as compose
 import atr.get.distribution as distribution
@@ -55,6 +56,7 @@ ROUTES_MODULE: Final[Literal[True]] = True
 __all__ = [
     "announce",
     "candidate",
+    "checks",
     "committees",
     "compose",
     "distribution",
diff --git a/atr/get/checks.py b/atr/get/checks.py
new file mode 100644
index 0000000..47c2879
--- /dev/null
+++ b/atr/get/checks.py
@@ -0,0 +1,503 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pathlib
+from collections.abc import Callable
+from typing import NamedTuple
+
+import asfquart.base as base
+import htpy
+
+import atr.blueprints.get as get
+import atr.db as db
+import atr.get.download as download
+import atr.get.ignores as ignores
+import atr.get.report as report
+import atr.get.vote as vote
+import atr.htm as htm
+import atr.models.sql as sql
+import atr.shared as shared
+import atr.storage as storage
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
+class FileStats(NamedTuple):
+    file_pass_before: int
+    file_warn_before: int
+    file_err_before: int
+    file_pass_after: int
+    file_warn_after: int
+    file_err_after: int
+    member_pass_before: int
+    member_warn_before: int
+    member_err_before: int
+    member_pass_after: int
+    member_warn_after: int
+    member_err_after: int
+
+    @property
+    def total_pass_before(self) -> int:
+        return self.file_pass_before + self.member_pass_before
+
+    @property
+    def total_warn_before(self) -> int:
+        return self.file_warn_before + self.member_warn_before
+
+    @property
+    def total_err_before(self) -> int:
+        return self.file_err_before + self.member_err_before
+
+    @property
+    def total_pass_after(self) -> int:
+        return self.file_pass_after + self.member_pass_after
+
+    @property
+    def total_warn_after(self) -> int:
+        return self.file_warn_after + self.member_warn_after
+
+    @property
+    def total_err_after(self) -> int:
+        return self.file_err_after + self.member_err_after
+
+
+async def get_file_totals(release: sql.Release, session: web.Committer | None) 
-> FileStats:
+    """Get file level check totals after ignores are applied."""
+    if release.committee is None:
+        raise ValueError("Release has no committee")
+
+    base_path = util.release_directory(release)
+    paths = [path async for path in util.paths_recursive(base_path)]
+
+    async with storage.read(session) as read:
+        ragp = read.as_general_public()
+        match_ignore = await 
ragp.checks.ignores_matcher(release.committee.name)
+
+    _, totals = await _compute_stats(release, paths, match_ignore)
+    return totals
+
+
[email protected]("/checks/<project_name>/<version_name>")
+async def selected(session: web.Committer | None, project_name: str, 
version_name: str) -> str:
+    """Show the file checks for a release candidate."""
+    async with db.session() as data:
+        release = await data.release(
+            project_name=project_name,
+            version=version_name,
+            phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+            _committee=True,
+        ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
+
+    if release.committee is None:
+        raise ValueError("Release has no committee")
+
+    base_path = util.release_directory(release)
+    paths = [path async for path in util.paths_recursive(base_path)]
+    paths.sort()
+
+    async with storage.read(session) as read:
+        ragp = read.as_general_public()
+        match_ignore = await 
ragp.checks.ignores_matcher(release.committee.name)
+
+    per_file_stats, totals = await _compute_stats(release, paths, match_ignore)
+
+    page = htm.Block()
+    _render_header(page, release)
+    _render_summary(page, totals, paths, per_file_stats)
+    _render_checks_table(page, release, paths, per_file_stats)
+    _render_ignores_section(page, release)
+    _render_debug_table(page, paths, per_file_stats)
+
+    return await template.blank(
+        f"File checks for {release.project.short_display_name} 
{release.version}",
+        content=page.collect(),
+    )
+
+
+async def _compute_stats(  # noqa: C901
+    release: sql.Release,
+    paths: list[pathlib.Path],
+    match_ignore: Callable[[sql.CheckResult], bool],
+) -> tuple[dict[pathlib.Path, FileStats], FileStats]:
+    per_file: dict[pathlib.Path, dict[str, int]] = {
+        p: {
+            "file_pass_before": 0,
+            "file_warn_before": 0,
+            "file_err_before": 0,
+            "file_pass_after": 0,
+            "file_warn_after": 0,
+            "file_err_after": 0,
+            "member_pass_before": 0,
+            "member_warn_before": 0,
+            "member_err_before": 0,
+            "member_pass_after": 0,
+            "member_warn_after": 0,
+            "member_err_after": 0,
+        }
+        for p in paths
+    }
+
+    if release.latest_revision_number is None:
+        # TODO: Or raise an exception?
+        empty_stats = FileStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
+        return {p: empty_stats for p in paths}, empty_stats
+
+    async with db.session() as data:
+        check_results = await data.check_result(
+            release_name=release.name,
+            revision_number=release.latest_revision_number,
+        ).all()
+
+    for cr in check_results:
+        if not cr.primary_rel_path:
+            continue
+
+        file_path = pathlib.Path(cr.primary_rel_path)
+        if file_path not in per_file:
+            continue
+
+        is_member = cr.member_rel_path is not None
+        is_ignored = match_ignore(cr)
+        prefix = "member" if is_member else "file"
+
+        if cr.status == sql.CheckResultStatus.SUCCESS:
+            per_file[file_path][f"{prefix}_pass_before"] += 1
+            per_file[file_path][f"{prefix}_pass_after"] += 1
+        elif cr.status == sql.CheckResultStatus.WARNING:
+            per_file[file_path][f"{prefix}_warn_before"] += 1
+            if not is_ignored:
+                per_file[file_path][f"{prefix}_warn_after"] += 1
+        else:
+            per_file[file_path][f"{prefix}_err_before"] += 1
+            if not is_ignored:
+                per_file[file_path][f"{prefix}_err_after"] += 1
+
+    per_file_stats = {p: FileStats(**c) for p, c in per_file.items()}
+
+    total_counts = {
+        "file_pass_before": 0,
+        "file_warn_before": 0,
+        "file_err_before": 0,
+        "file_pass_after": 0,
+        "file_warn_after": 0,
+        "file_err_after": 0,
+        "member_pass_before": 0,
+        "member_warn_before": 0,
+        "member_err_before": 0,
+        "member_pass_after": 0,
+        "member_warn_after": 0,
+        "member_err_after": 0,
+    }
+    for stats in per_file_stats.values():
+        for field in total_counts:
+            total_counts[field] += getattr(stats, field)
+
+    return per_file_stats, FileStats(**total_counts)
+
+
+def _render_checks_table(
+    page: htm.Block,
+    release: sql.Release,
+    paths: list[pathlib.Path],
+    per_file_stats: dict[pathlib.Path, FileStats],
+) -> None:
+    if not paths:
+        page.div(".alert.alert-info")["This release candidate does not have 
any files."]
+        return
+
+    table = htm.Block(htpy.table, 
classes=".table.table-striped.align-middle.table-sm.mb-0.border")
+
+    thead = htm.Block(htpy.thead, classes=".table-light")
+    # TODO: We forbid inline styles in Jinja2 through linting
+    # But we use it here
+    # It is convenient, and we should consider whether or not to allow it
+    thead.tr[
+        htpy.th(".py-2.ps-3")["Path"],
+        htpy.th(".py-2.text-center", style="width: 5em")["Pass"],
+        htpy.th(".py-2.text-center", style="width: 5em")["Warning"],
+        htpy.th(".py-2.text-center", style="width: 5em")["Error"],
+        htpy.th(".py-2.text-end.pe-3")[""],
+    ]
+    table.append(thead.collect())
+
+    tbody = htm.Block(htpy.tbody)
+    for path in paths:
+        stats = per_file_stats.get(path, FileStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 
0, 0, 0))
+        _render_file_row(tbody, release, path, stats)
+    table.append(tbody.collect())
+
+    page.div(".table-responsive.card.mb-4")[table.collect()]
+
+
+def _render_debug_table(
+    page: htm.Block,
+    paths: list[pathlib.Path],
+    per_file_stats: dict[pathlib.Path, FileStats],
+) -> None:
+    # Bootstrap does have striping, but that's for horizontal stripes
+    # These are vertical stripes, to make it easier to distinguish collections
+    stripe_a = "background-color: #f0f0f0; text-align: center;"
+    stripe_b = "background-color: #ffffff; text-align: center;"
+
+    table = htm.Block(htpy.table, 
classes=".table.table-bordered.table-sm.mb-0.text-center")
+
+    thead = htm.Block(htpy.thead, classes=".table-light")
+    thead.tr[
+        htpy.th(rowspan="2", style="text-align: center; vertical-align: 
middle;")["Path"],
+        htpy.th(colspan="3", style=stripe_a)["File (before)"],
+        htpy.th(colspan="3", style=stripe_b)["File (after)"],
+        htpy.th(colspan="3", style=stripe_a)["Member (before)"],
+        htpy.th(colspan="3", style=stripe_b)["Member (after)"],
+        htpy.th(colspan="3", style=stripe_a)["Total (before)"],
+        htpy.th(colspan="3", style=stripe_b)["Total (after)"],
+    ]
+    thead.tr[
+        htpy.th(style=stripe_a)["P"],
+        htpy.th(style=stripe_a)["W"],
+        htpy.th(style=stripe_a)["E"],
+        htpy.th(style=stripe_b)["P"],
+        htpy.th(style=stripe_b)["W"],
+        htpy.th(style=stripe_b)["E"],
+        htpy.th(style=stripe_a)["P"],
+        htpy.th(style=stripe_a)["W"],
+        htpy.th(style=stripe_a)["E"],
+        htpy.th(style=stripe_b)["P"],
+        htpy.th(style=stripe_b)["W"],
+        htpy.th(style=stripe_b)["E"],
+        htpy.th(style=stripe_a)["P"],
+        htpy.th(style=stripe_a)["W"],
+        htpy.th(style=stripe_a)["E"],
+        htpy.th(style=stripe_b)["P"],
+        htpy.th(style=stripe_b)["W"],
+        htpy.th(style=stripe_b)["E"],
+    ]
+    table.append(thead.collect())
+
+    tbody = htm.Block(htpy.tbody)
+    for path in paths:
+        stats = per_file_stats.get(path, FileStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 
0, 0, 0))
+        tbody.tr[
+            htpy.td(class_="text-start")[htpy.code[str(path)]],
+            htpy.td(style=stripe_a)[str(stats.file_pass_before)],
+            htpy.td(style=stripe_a)[str(stats.file_warn_before)],
+            htpy.td(style=stripe_a)[str(stats.file_err_before)],
+            htpy.td(style=stripe_b)[str(stats.file_pass_after)],
+            htpy.td(style=stripe_b)[str(stats.file_warn_after)],
+            htpy.td(style=stripe_b)[str(stats.file_err_after)],
+            htpy.td(style=stripe_a)[str(stats.member_pass_before)],
+            htpy.td(style=stripe_a)[str(stats.member_warn_before)],
+            htpy.td(style=stripe_a)[str(stats.member_err_before)],
+            htpy.td(style=stripe_b)[str(stats.member_pass_after)],
+            htpy.td(style=stripe_b)[str(stats.member_warn_after)],
+            htpy.td(style=stripe_b)[str(stats.member_err_after)],
+            htpy.td(style=stripe_a)[str(stats.total_pass_before)],
+            htpy.td(style=stripe_a)[str(stats.total_warn_before)],
+            htpy.td(style=stripe_a)[str(stats.total_err_before)],
+            htpy.td(style=stripe_b)[str(stats.total_pass_after)],
+            htpy.td(style=stripe_b)[str(stats.total_warn_after)],
+            htpy.td(style=stripe_b)[str(stats.total_err_after)],
+        ]
+    table.append(tbody.collect())
+
+    page.append(
+        htpy.details(".mt-4")[
+            htpy.summary["All statistics"],
+            htpy.div(".table-responsive.mt-3")[table.collect()],
+        ]
+    )
+
+
+def _render_file_row(
+    tbody: htm.Block,
+    release: sql.Release,
+    path: pathlib.Path,
+    stats: FileStats,
+) -> None:
+    path_str = str(path)
+    num_style = "font-size: 1.1rem;"
+
+    pass_count = stats.file_pass_after
+    warn_count = stats.file_warn_after
+    err_count = stats.file_err_after
+    has_checks_before = (stats.file_pass_before + stats.file_warn_before + 
stats.file_err_before) > 0
+    has_checks_after = (pass_count + warn_count + err_count) > 0
+
+    report_url = util.as_url(
+        report.selected_path,
+        project_name=release.project.name,
+        version_name=release.version,
+        rel_path=path_str,
+    )
+    download_url = util.as_url(
+        download.path,
+        project_name=release.project.name,
+        version_name=release.version,
+        file_path=path_str,
+    )
+
+    if not has_checks_before:
+        path_display = htpy.code(".text-muted")[path_str]
+        pass_cell = htpy.span(".text-muted", style=num_style)["-"]
+        warn_cell = htpy.span(".text-muted", style=num_style)["-"]
+        err_cell = htpy.span(".text-muted", style=num_style)["-"]
+        report_btn = 
htpy.span(".btn.btn-sm.btn-outline-secondary.disabled")["No checks"]
+    elif not has_checks_after:
+        path_display = htpy.code[path_str]
+        pass_cell = htpy.span(".text-muted", style=num_style)["0"]
+        warn_cell = htpy.span(".text-muted", style=num_style)["0"]
+        err_cell = htpy.span(".text-muted", style=num_style)["0"]
+        report_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", 
href=report_url)["Show details"]
+    elif err_count > 0:
+        path_display = htpy.strong[htpy.code(".text-danger")[path_str]]
+        pass_cell = (
+            htpy.span(".text-success", style=num_style)[str(pass_count)]
+            if pass_count > 0
+            else htpy.span(".text-muted", style=num_style)["0"]
+        )
+        warn_cell = (
+            htpy.span(".text-warning", style=num_style)[str(warn_count)]
+            if warn_count > 0
+            else htpy.span(".text-muted", style=num_style)["0"]
+        )
+        err_cell = htpy.span(".text-danger.fw-bold", 
style=num_style)[str(err_count)]
+        report_btn = htpy.a(".btn.btn-sm.btn-outline-danger", 
href=report_url)["Show details"]
+    elif warn_count > 0:
+        path_display = htpy.strong[htpy.code(".text-warning")[path_str]]
+        pass_cell = (
+            htpy.span(".text-success", style=num_style)[str(pass_count)]
+            if pass_count > 0
+            else htpy.span(".text-muted", style=num_style)["0"]
+        )
+        warn_cell = htpy.span(".text-warning.fw-bold", 
style=num_style)[str(warn_count)]
+        err_cell = htpy.span(".text-muted", style=num_style)["0"]
+        report_btn = htpy.a(".btn.btn-sm.btn-outline-warning", 
href=report_url)["Show details"]
+    else:
+        path_display = htpy.code[path_str]
+        pass_cell = htpy.span(".text-success", 
style=num_style)[str(pass_count)]
+        warn_cell = htpy.span(".text-muted", style=num_style)["0"]
+        err_cell = htpy.span(".text-muted", style=num_style)["0"]
+        report_btn = htpy.a(".btn.btn-sm.btn-outline-success", 
href=report_url)["Show details"]
+
+    download_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", 
href=download_url)["Download"]
+
+    tbody.tr[
+        htpy.td(".py-2.ps-3")[path_display],
+        htpy.td(".py-2.text-center")[pass_cell],
+        htpy.td(".py-2.text-center")[warn_cell],
+        htpy.td(".py-2.text-center")[err_cell],
+        htpy.td(".text-end.text-nowrap.py-2.pe-3")[
+            htpy.div(".d-flex.justify-content-end.align-items-center.gap-2")[
+                report_btn,
+                download_btn,
+            ],
+        ],
+    ]
+
+
+def _render_header(page: htm.Block, release: sql.Release) -> None:
+    shared.distribution.html_nav(
+        page,
+        back_url=util.as_url(vote.selected, project_name=release.project.name, 
version_name=release.version),
+        back_anchor=f"Vote on {release.project.short_display_name} 
{release.version}",
+        phase="VOTE",
+    )
+
+    page.h1[
+        "File checks for ",
+        htm.strong[release.project.short_display_name],
+        " ",
+        htm.em[release.version],
+    ]
+
+
+def _render_ignores_section(page: htm.Block, release: sql.Release) -> None:
+    if release.committee is None:
+        return
+
+    # TODO: We should choose a consistent " ..." or "... " style
+    page.h2["Check ignores"]
+    page.p[
+        "Committee members can configure rules to ignore specific check 
results. "
+        "Ignored checks are excluded from the counts shown above.",
+    ]
+    ignores_url = util.as_url(ignores.ignores, 
committee_name=release.committee.name)
+    page.div[htpy.a(".btn.btn-outline-primary", href=ignores_url)["Manage 
check ignores"],]
+
+
+def _render_summary(
+    page: htm.Block,
+    totals: FileStats,
+    paths: list[pathlib.Path],
+    per_file_stats: dict[pathlib.Path, FileStats],
+) -> None:
+    files_with_errors = sum(1 for s in per_file_stats.values() if 
s.file_err_after > 0)
+    files_with_warnings = sum(1 for s in per_file_stats.values() if 
(s.file_warn_after > 0) and (s.file_err_after == 0))
+    files_passed = sum(
+        1
+        for s in per_file_stats.values()
+        if (s.file_pass_after > 0) and (s.file_warn_after == 0) and 
(s.file_err_after == 0)
+    )
+    files_skipped = len(paths) - files_passed - files_with_warnings - 
files_with_errors
+
+    file_word = "file" if (len(paths) == 1) else "files"
+    passed_word = "file passed" if (files_passed == 1) else "files passed"
+    warn_file_word = "file has" if (files_with_warnings == 1) else "files have"
+    err_file_word = "file has" if (files_with_errors == 1) else "files have"
+    skipped_word = "file did not require checking" if (files_skipped == 1) 
else "files did not require checking"
+    no_errors_word = "no" if ((files_passed > 0) or (files_with_warnings > 0)) 
else "No"
+
+    page.p[
+        f"Showing check results for {len(paths)} {file_word}. ",
+        f"{files_passed} {passed_word} all checks, " if (files_passed > 0) 
else "",
+        f"{files_with_warnings} {warn_file_word} warnings, " if 
(files_with_warnings > 0) else "",
+        f"{files_with_errors} {err_file_word} errors."
+        if (files_with_errors > 0)
+        else f"{no_errors_word} files have errors.",
+        f" {files_skipped} {skipped_word}." if (files_skipped > 0) else "",
+    ]
+
+    check_word = "check" if (totals.file_pass_after == 1) else "checks"
+    warn_word = "warning" if (totals.file_warn_after == 1) else "warnings"
+    err_word = "error" if (totals.file_err_after == 1) else "errors"
+
+    summary_div = htm.Block(htm.div, classes=".d-flex.flex-wrap.gap-4.mb-3")
+    summary_div.span(".text-success")[
+        htpy.i(".bi.bi-check-circle-fill.me-2"),
+        f"{totals.file_pass_after} {check_word} passed",
+    ]
+    if totals.file_warn_after > 0:
+        summary_div.span(".text-warning")[
+            htpy.i(".bi.bi-exclamation-triangle-fill.me-2"),
+            f"{totals.file_warn_after} {warn_word}",
+        ]
+    else:
+        summary_div.span(".text-muted")[
+            htpy.i(".bi.bi-exclamation-triangle.me-2"),
+            "0 warnings",
+        ]
+    if totals.file_err_after > 0:
+        summary_div.span(".text-danger")[
+            htpy.i(".bi.bi-x-circle-fill.me-2"),
+            f"{totals.file_err_after} {err_word}",
+        ]
+    else:
+        summary_div.span(".text-muted")[
+            htpy.i(".bi.bi-x-circle.me-2"),
+            "0 errors",
+        ]
+    page.append(summary_div.collect())
diff --git a/atr/get/file.py b/atr/get/file.py
index fcbc758..bb56d9d 100644
--- a/atr/get/file.py
+++ b/atr/get/file.py
@@ -102,7 +102,7 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
 
         files_card.div(".card-body")[
             htm.div(".table-responsive")[
-                htm.table(".table.table-striped.table-hover")[
+                htm.table(".table.table-striped")[
                     htm.thead[
                         htm.tr[
                             htm.th["Permissions"],
diff --git a/atr/get/keys.py b/atr/get/keys.py
index da1f174..d969200 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -312,7 +312,7 @@ def _committee_keys(page: htm.Block, 
user_committees_with_keys: list[sql.Committ
                     tbody.append(row.collect())
 
                 page.div(".table-responsive.mb-2")[
-                    
htm.table(".table.border.table-striped.table-hover.table-sm")[thead, 
tbody.collect()]
+                    htm.table(".table.border.table-striped.table-sm")[thead, 
tbody.collect()]
                 ]
                 page.p(".text-muted")[
                     "The ",
@@ -399,9 +399,7 @@ def _openpgp_keys(page: htm.Block, user_keys: 
list[sql.PublicSigningKey]) -> Non
                 )
             tbody.append(row.collect())
 
-        page.div(".table-responsive.mb-5")[
-            
htm.table(".table.border.table-striped.table-hover.table-sm")[thead, 
tbody.collect()]
-        ]
+        
page.div(".table-responsive.mb-5")[htm.table(".table.border.table-striped.table-sm")[thead,
 tbody.collect()]]
     else:
         page.p[htm.strong["You haven't added any personal OpenPGP keys yet."]]
 
diff --git a/atr/get/test.py b/atr/get/test.py
index 3f3bf80..adf26cb 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -22,7 +22,9 @@ import atr.blueprints.get as get
 import atr.config as config
 import atr.form as form
 import atr.get.root as root
+import atr.get.vote as vote
 import atr.htm as htm
+import atr.models.sql as sql
 import atr.shared as shared
 import atr.template as template
 import atr.web as web
@@ -115,3 +117,34 @@ async def test_single(session: web.Committer | None) -> 
str:
     ]
 
     return await template.blank(title="Test single form", content=forms_html)
+
+
[email protected]("/test/vote/<category>/<project_name>/<version_name>")
+async def test_vote(session: web.Committer | None, category: str, 
project_name: str, version_name: str) -> str:
+    if not config.get().ALLOW_TESTS:
+        raise base.ASFQuartException("Test routes not enabled", errorcode=404)
+
+    category_map = {
+        "unauthenticated": vote.UserCategory.UNAUTHENTICATED,
+        "committer": vote.UserCategory.COMMITTER,
+        "committer_rm": vote.UserCategory.COMMITTER_RM,
+        "pmc_member": vote.UserCategory.PMC_MEMBER,
+        "pmc_member_rm": vote.UserCategory.PMC_MEMBER_RM,
+    }
+
+    user_category = category_map.get(category.lower())
+    if user_category is None:
+        raise base.ASFQuartException(
+            f"Invalid category: {category}. Valid options: {', 
'.join(category_map.keys())}",
+            errorcode=400,
+        )
+
+    if (user_category != vote.UserCategory.UNAUTHENTICATED) and (session is 
None):
+        raise base.ASFQuartException("You must be logged in to preview 
authenticated views", errorcode=401)
+
+    _, release, latest_vote_task = await vote.category_and_release(session, 
project_name, version_name)
+
+    if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
+        raise base.ASFQuartException("Release is not a candidate", 
errorcode=404)
+
+    return await vote.render_options_page(session, release, user_category, 
latest_vote_task)
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 98eb936..6bd8095 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -15,124 +15,531 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from __future__ import annotations
+
+import enum
+import urllib.parse
+from typing import TYPE_CHECKING
+
 import asfquart.base as base
 import htpy
 
 import atr.blueprints.get as get
+import atr.config as config
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.form as form
-import atr.forms as forms
-import atr.log as log
+import atr.get.download as download
+import atr.get.keys as keys
+import atr.get.root as root
+import atr.htm as htm
 import atr.mapping as mapping
-import atr.models.results as results
 import atr.models.sql as sql
+import atr.post as post
 import atr.shared as shared
 import atr.storage as storage
+import atr.template as template
 import atr.user as user
 import atr.util as util
 import atr.web as web
 
+if TYPE_CHECKING:
+    import atr.get.checks as checks
 
[email protected]("/vote/<project_name>/<version_name>")
-async def selected(session: web.Committer | None, project_name: str, 
version_name: str) -> web.WerkzeugResponse | str:
-    """Show the contents of the release candidate draft."""
+
+class UserCategory(str, enum.Enum):
+    COMMITTER = "Committer"
+    COMMITTER_RM = "Committer (Release Manager)"
+    PMC_MEMBER = "PMC Member"
+    PMC_MEMBER_RM = "PMC Member (Release Manager)"
+    UNAUTHENTICATED = "Unauthenticated"
+
+
+async def category_and_release(
+    session: web.Committer | None, project_name: str, version_name: str
+) -> tuple[UserCategory, sql.Release, sql.Task | None]:
     async with db.session() as data:
         release = await data.release(
             project_name=project_name,
             version=version_name,
             _committee=True,
-            _project_release_policy=True,
         ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
 
+        if release.committee is None:
+            raise ValueError("Release has no committee")
+
+        latest_vote_task = await interaction.release_latest_vote_task(release, 
data)
+        vote_initiator_uid: str | None = None
+        if latest_vote_task is not None:
+            vote_initiator_uid = latest_vote_task.task_args.get("initiator_id")
+
+    if session is None:
+        return UserCategory.UNAUTHENTICATED, release, latest_vote_task
+
+    is_pmc_member = (user.is_committee_member(release.committee, session.uid)) 
or (user.is_admin(session.uid))
+    is_release_manager = (vote_initiator_uid is not None) and (session.uid == 
vote_initiator_uid)
+
+    if is_pmc_member and is_release_manager:
+        user_category = UserCategory.PMC_MEMBER_RM
+    elif is_pmc_member:
+        user_category = UserCategory.PMC_MEMBER
+    elif is_release_manager:
+        user_category = UserCategory.COMMITTER_RM
+    else:
+        user_category = UserCategory.COMMITTER
+
+    return user_category, release, latest_vote_task
+
+
+async def render_options_page(
+    session: web.Committer | None,
+    release: sql.Release,
+    user_category: UserCategory,
+    latest_vote_task: sql.Task | None,
+) -> str:
+    """Render the vote options page for a release candidate."""
+    import atr.get.checks as checks
+
+    show_resolve_section = user_category in (
+        UserCategory.UNAUTHENTICATED,
+        UserCategory.COMMITTER_RM,
+        UserCategory.PMC_MEMBER_RM,
+    )
+
+    file_totals = await checks.get_file_totals(release, session)
+    archive_url = await _get_archive_url(release, session, latest_vote_task)
+
+    page = htm.Block()
+    _render_header(page, release, show_resolve_section)
+    _render_section_download(page, release, session, user_category)
+    _render_section_checks(page, release, file_totals)
+    await _render_section_vote(page, release, session, user_category, 
archive_url)
+    if show_resolve_section:
+        _render_section_resolve(page, release, user_category)
+
+    return await template.blank(
+        f"Vote on {release.project.short_display_name} {release.version}",
+        content=page.collect(),
+        init_js=True,
+    )
+
+
[email protected]("/vote/<project_name>/<version_name>")
+async def selected(session: web.Committer | None, project_name: str, 
version_name: str) -> web.WerkzeugResponse | str:
+    """Show voting options for a release candidate."""
+    user_category, release, latest_vote_task = await 
category_and_release(session, project_name, version_name)
+
     if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
         if session is None:
             raise base.ASFQuartException("Release is not a candidate", 
errorcode=404)
         return await mapping.release_as_redirect(session, release)
 
+    return await render_options_page(session, release, user_category, 
latest_vote_task)
+
+
+def _download_browse(release: sql.Release) -> htm.Element:
+    browse_url = util.as_url(
+        download.path_empty,
+        project_name=release.project.name,
+        version_name=release.version,
+    )
+    return htm.div(".d-flex.align-items-center.gap-2")[
+        htpy.a(".btn.btn-outline-primary", href=browse_url)[
+            htpy.i(".bi.bi-folder2-open.me-1"),
+            "→ Browse files",
+        ],
+    ]
+
+
+def _download_curl(release: sql.Release) -> htm.Element:
+    app_host = config.get().APP_HOST
+    script_url = util.as_url(
+        download.sh_selected,
+        project_name=release.project.name,
+        version_name=release.version,
+    )
+    curl_command = f"curl -s https://{app_host}{script_url} | sh"
+
+    return htm.div(".mb-3")[
+        htm.div(".mb-2")[htm.strong["Use curl:"]],
+        htm.div(".input-group")[
+            htpy.span(
+                "#curl-command.form-control.font-monospace.bg-light",
+            )[curl_command],
+            htpy.button(
+                ".btn.btn-outline-secondary.atr-copy-btn",
+                type="button",
+                data_clipboard_target="#curl-command",
+            )[htpy.i(".bi.bi-clipboard"), " Copy"],
+        ],
+        htm.div(".form-text.text-muted")["This command downloads all release 
files to the current directory.",],
+    ]
+
+
+def _download_rsync(release: sql.Release, session: web.Committer) -> 
htm.Element:
+    server_domain = config.get().APP_HOST.split(":", 1)[0]
+    if not session.uid.isalnum():
+        raise ValueError("Invalid UID")
+
+    rsync_command = (
+        f"rsync -av -e 'ssh -p 2222' 
{session.uid}@{server_domain}:/{release.project.name}/{release.version}/ ./"
+    )
+
+    return htm.div(".mb-3")[
+        htm.div(".mb-2")[htm.strong["Use rsync:"]],
+        htm.div(".input-group")[
+            htpy.span(
+                "#rsync-command.form-control.font-monospace.bg-light",
+            )[rsync_command],
+            htpy.button(
+                ".btn.btn-outline-secondary.atr-copy-btn",
+                type="button",
+                data_clipboard_target="#rsync-command",
+            )[htpy.i(".bi.bi-clipboard"), " Copy"],
+        ],
+        htm.div(".form-text.text-muted")[
+            "Requires SSH key configuration. ",
+            htpy.a(href=util.as_url(keys.keys))["Manage your SSH keys"],
+            ".",
+        ],
+    ]
+
+
+def _download_zip(release: sql.Release) -> htm.Element:
+    zip_url = util.as_url(
+        download.zip_selected,
+        project_name=release.project.name,
+        version_name=release.version,
+    )
+    return htm.div(".d-flex.align-items-center.gap-2")[
+        htpy.a(".btn.btn-primary", href=zip_url)[
+            htpy.i(".bi.bi-file-earmark-zip.me-1"),
+            "Download all (ZIP)",
+        ],
+    ]
+
+
+async def _get_archive_url(
+    release: sql.Release, session: web.Committer | None, latest_vote_task: 
sql.Task | None
+) -> str | None:
+    if latest_vote_task is None:
+        return None
+
+    task_mid = interaction.task_mid_get(latest_vote_task)
+    if task_mid is None:
+        return None
+
+    task_recipient = interaction.task_recipient_get(latest_vote_task)
+    async with storage.write(session) as write:
+        wagp = write.as_general_public()
+        return await wagp.cache.get_message_archive_url(task_mid, 
task_recipient)
+
+
+def _render_header(page: htm.Block, release: sql.Release, 
show_resolve_section: bool) -> None:
+    shared.distribution.html_nav(
+        page,
+        back_url=util.as_url(root.index),
+        back_anchor="Select a release",
+        phase="VOTE",
+    )
+
+    page.h1[
+        "Vote on ",
+        htm.strong[release.project.short_display_name],
+        " ",
+        htm.em[release.version],
+    ]
+
     if release.committee is None:
         raise ValueError("Release has no committee")
 
-    is_authenticated = session is not None
-    is_committee_member = is_authenticated and (
-        user.is_committee_member(release.committee, session.uid) or 
user.is_admin(session.uid)
-    )
-    can_vote = is_committee_member
-    can_resolve = is_committee_member
-
-    latest_vote_task = await interaction.release_latest_vote_task(release)
-    archive_url = None
-    task_mid = None
-
-    if latest_vote_task is not None:
-        if util.is_dev_environment():
-            log.warning("Setting vote task to completed in dev environment")
-            latest_vote_task.status = sql.TaskStatus.COMPLETED
-            latest_vote_task.result = results.VoteInitiate(
-                kind="vote_initiate",
-                message="Vote announcement email sent successfully",
-                email_to="[email protected]",
-                vote_end="2025-07-01 12:00:00",
-                subject="Test vote",
-                mid=interaction.TEST_MID,
-                mail_send_warnings=[],
-            )
-
-        # Move task_mid_get here?
-        task_mid = interaction.task_mid_get(latest_vote_task)
-        task_recipient = interaction.task_recipient_get(latest_vote_task)
-        async with storage.write(session) as write:
-            wagp = write.as_general_public()
-            archive_url = await wagp.cache.get_message_archive_url(task_mid, 
task_recipient)
-
-    resolve_form = None
-    if can_resolve:
-        # Special form for the [ Resolve vote ] button, to make it POST
-        resolve_form = await forms.Submit.create_form()
-        resolve_form.submit.label.text = "Resolve vote"
-
-    cast_vote_form = None
-    if can_vote:
+    page.p[
+        "The ",
+        htm.strong[release.committee.display_name],
+        " committee is currently voting on the release candidate for"
+        f" {release.project.display_name} {release.version}.",
+    ]
+
+    page.p["To participate in this vote, please select your next step:"]
+
+    steps = htm.Block(htpy.ol, classes=".atr-steps")
+    steps.li[htpy.a(".atr-step-link", href="#download")["Download the release 
files"]]
+    steps.li[htpy.a(".atr-step-link", href="#checks")["Review file checks"]]
+    steps.li[htpy.a(".atr-step-link", href="#vote")["Cast your vote"]]
+    if show_resolve_section:
+        steps.li[htpy.a(".atr-step-link", href="#resolve")["Resolve the vote 
(release managers only)"]]
+    page.append(steps.collect())
+
+
+def _render_section_checks(page: htm.Block, release: sql.Release, file_totals: 
checks.FileStats) -> None:
+    import atr.get.checks as checks
+
+    page.h2("#checks")["2. Review file checks"]
+
+    page.p["ATR has checked this release candidate with the following 
results:"]
+
+    summary = htm.Block(htm.div, classes=".card.mb-4")
+    summary.div(".card-header.bg-light")["Automated checks"]
+
+    body = htm.Block(htm.div, classes=".card-body")
+
+    pass_count = file_totals.file_pass_after
+    warn_count = file_totals.file_warn_after
+    err_count = file_totals.file_err_after
+
+    check_word = "check" if (pass_count == 1) else "checks"
+    warn_word = "warning" if (warn_count == 1) else "warnings"
+    err_word = "error" if (err_count == 1) else "errors"
+
+    checks_list = htm.Block(htm.div, classes=".d-flex.flex-wrap.gap-4.mb-3")
+    checks_list.span(".text-success")[
+        htpy.i(".bi.bi-check-circle-fill.me-2"),
+        f"{pass_count} {check_word} passed",
+    ]
+    if warn_count > 0:
+        checks_list.span(".text-warning")[
+            htpy.i(".bi.bi-exclamation-triangle-fill.me-2"),
+            f"{warn_count} {warn_word}",
+        ]
+    else:
+        checks_list.span(".text-muted")[
+            htpy.i(".bi.bi-exclamation-triangle.me-2"),
+            "0 warnings",
+        ]
+    if err_count > 0:
+        checks_list.span(".text-danger")[
+            htpy.i(".bi.bi-x-circle-fill.me-2"),
+            f"{err_count} {err_word}",
+        ]
+    else:
+        checks_list.span(".text-muted")[
+            htpy.i(".bi.bi-x-circle.me-2"),
+            "0 errors",
+        ]
+    body.append(checks_list.collect())
+
+    body.div[
+        htpy.a(
+            ".btn.btn-outline-primary",
+            href=util.as_url(checks.selected, 
project_name=release.project.name, version_name=release.version),
+        )["→ View detailed results"],
+    ]
+
+    summary.append(body.collect())
+    page.append(summary.collect())
+
+
+def _render_section_download(
+    page: htm.Block, release: sql.Release, session: web.Committer | None, 
user_category: UserCategory
+) -> None:
+    page.h2("#download")["1. Download the release files"]
+
+    page.p[
+        "Download the release files to verify signatures, licenses, and test 
functionality before casting your vote."
+    ]
+
+    is_authenticated = user_category != UserCategory.UNAUTHENTICATED
+
+    page.div(".mb-2")[htm.strong["Use your browser:"]]
+    buttons_row = htm.Block(htm.div, classes=".d-flex.flex-wrap.gap-3.mb-3")
+    buttons_row.append(_download_browse(release))
+    if is_authenticated and (session is not None):
+        buttons_row.append(_download_zip(release))
+    page.append(buttons_row.collect())
+
+    if is_authenticated and (session is not None):
+        page.append(_download_rsync(release, session))
+
+    page.append(_download_curl(release))
+
+    if not is_authenticated:
+        page.div(".mb-2")[htm.strong["Use alternatives:"]]
+        redirect_url = util.as_url(selected, 
project_name=release.project.name, version_name=release.version)
+        login_url = f"/auth?login={urllib.parse.quote(redirect_url, safe='')}"
+        page.div(".mt-3")[
+            htpy.a(".btn.btn-outline-secondary", href=login_url)[
+                htpy.i(".bi.bi-box-arrow-in-right.me-1"),
+                "Log in for ZIP and rsync downloads",
+            ],
+        ]
+
+
+def _render_section_resolve(page: htm.Block, release: sql.Release, 
user_category: UserCategory) -> None:
+    page.h2("#resolve")["4. Resolve the vote (release managers only)"]
+
+    if user_category == UserCategory.UNAUTHENTICATED:
+        page.p["If you are the release manager, log in to access vote tallying 
and resolution tools."]
+        redirect_url = util.as_url(selected, 
project_name=release.project.name, version_name=release.version)
+        login_url = f"/auth?login={urllib.parse.quote(redirect_url, safe='')}"
+        page.div[
+            htpy.a(".btn.btn-outline-secondary", href=login_url)[
+                htpy.i(".bi.bi-box-arrow-in-right.me-1"),
+                "Log in as Release Manager",
+            ]
+        ]
+    else:
+        page.p["When the voting period concludes, use the resolution page to 
tally votes and record the outcome."]
+
+        # POST form for resolve button
+        resolve_url = util.as_url(
+            post.resolve.selected,
+            project_name=release.project.name,
+            version_name=release.version,
+        )
+        page.form(".mb-0", method="post", action=resolve_url)[
+            form.csrf_input(),
+            htpy.input(type="hidden", name="variant", value="tabulate"),
+            htpy.button(".btn.btn-success", type="submit")[
+                htpy.i(".bi.bi-clipboard-check.me-1"),
+                "Resolve vote",
+            ],
+        ]
+
+
+async def _render_section_vote(
+    page: htm.Block,
+    release: sql.Release,
+    session: web.Committer | None,
+    user_category: UserCategory,
+    archive_url: str | None,
+) -> None:
+    page.h2("#vote")["3. Cast your vote"]
+
+    if release.committee is None:
+        raise ValueError("Release has no committee")
+
+    if user_category == UserCategory.UNAUTHENTICATED:
+        _render_vote_unauthenticated(page, release, archive_url)
+    else:
+        await _render_vote_authenticated(page, release, session, 
user_category, archive_url)
+
+
+async def _render_vote_authenticated(
+    page: htm.Block,
+    release: sql.Release,
+    session: web.Committer | None,
+    user_category: UserCategory,
+    archive_url: str | None,
+) -> None:
+    if release.committee is None:
+        raise ValueError("Release has no committee")
+    if session is None:
+        raise ValueError("Session required for authenticated vote")
+
+    # Determine vote potency based on user category
+    # For podlings, incubator PMC membership grants binding status always
+    # This breaks the test route though
+    is_pmc_member = user_category in (UserCategory.PMC_MEMBER, 
UserCategory.PMC_MEMBER_RM)
+
+    if release.committee.is_podling:
         async with storage.write() as write:
             try:
-                if release.committee.is_podling:
-                    _wacm = write.as_committee_member("incubator")
-                else:
-                    _wacm = write.as_committee_member(release.committee.name)
-                potency = "Binding"
+                _wacm = write.as_committee_member("incubator")
+                is_binding = True
             except storage.AccessError:
-                potency = "Non-binding"
-
-        vote_widget = htpy.div(class_="btn-group", role="group")[
-            htpy.input(
-                type="radio", class_="btn-check", name="decision", 
id="decision_0", value="+1", autocomplete="off"
-            ),
-            htpy.label(class_="btn btn-outline-success", 
for_="decision_0")[f"+1 ({potency})"],
-            htpy.input(
-                type="radio", class_="btn-check", name="decision", 
id="decision_1", value="0", autocomplete="off"
-            ),
-            htpy.label(class_="btn btn-outline-secondary", 
for_="decision_1")["0"],
-            htpy.input(
-                type="radio", class_="btn-check", name="decision", 
id="decision_2", value="-1", autocomplete="off"
-            ),
-            htpy.label(class_="btn btn-outline-danger", 
for_="decision_2")[f"-1 ({potency})"],
+                is_binding = False
+        binding_committee = "Incubator"
+    else:
+        is_binding = is_pmc_member
+        binding_committee = release.committee.display_name
+
+    potency = "Binding" if is_binding else "Non-binding"
+    if is_binding:
+        page.p[
+            f"As a member of the {binding_committee} committee, your vote is ",
+            htpy.strong["binding"],
+            ".",
+        ]
+    else:
+        page.p[
+            f"You are not a member of the {binding_committee} committee. ",
+            "Your vote will be recorded as ",
+            htpy.strong["non-binding"],
+            " but is still valued by the community.",
         ]
 
-        cast_vote_form = form.render(
-            model_cls=shared.vote.CastVoteForm,
-            submit_label="Submit vote",
-            form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
-            custom={"decision": vote_widget},
-        )
+    # Note about where vote goes, with link to thread if available
+    mailing_list = f"dev@{release.committee.name}.apache.org"
+    if archive_url:
+        page.p[
+            "Your vote will be sent to ",
+            htpy.code[mailing_list],
+            " (",
+            htpy.a(href=archive_url, target="_blank", rel="noopener")["view 
thread"],
+            ").",
+        ]
+    else:
+        page.p["Your vote will be sent to ", htpy.code[mailing_list], "."]
 
-    return await shared.check(
-        session,
-        release,
-        task_mid=task_mid,
-        form=cast_vote_form,
-        resolve_form=resolve_form,
-        archive_url=archive_url,
-        vote_task=latest_vote_task,
-        can_vote=can_vote,
-        can_resolve=can_resolve,
+    # Build the vote widget
+    vote_widget = htpy.div(class_="btn-group", role="group")[
+        htpy.input(type="radio", class_="btn-check", name="decision", 
id="decision_0", value="+1", autocomplete="off"),
+        htpy.label(class_="btn btn-outline-success", for_="decision_0")[f"+1 
({potency})"],
+        htpy.input(type="radio", class_="btn-check", name="decision", 
id="decision_1", value="0", autocomplete="off"),
+        htpy.label(class_="btn btn-outline-secondary", for_="decision_1")["0"],
+        htpy.input(type="radio", class_="btn-check", name="decision", 
id="decision_2", value="-1", autocomplete="off"),
+        htpy.label(class_="btn btn-outline-danger", for_="decision_2")[f"-1 
({potency})"],
+    ]
+
+    # Render the form
+    vote_action_url = util.as_url(
+        post.vote.selected_post,
+        project_name=release.project.name,
+        version_name=release.version,
+    )
+    cast_vote_form = form.render(
+        model_cls=shared.vote.CastVoteForm,
+        action=vote_action_url,
+        submit_label="Submit vote",
+        form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
+        custom={"decision": vote_widget},
     )
+    page.append(cast_vote_form)
+
+
+def _render_vote_unauthenticated(page: htm.Block, release: sql.Release, 
archive_url: str | None) -> None:
+    page.p["Once you have reviewed the release, you can cast your vote."]
+
+    redirect_url = util.as_url(selected, project_name=release.project.name, 
version_name=release.version)
+    login_url = f"/auth?login={urllib.parse.quote(redirect_url, safe='')}"
+
+    # ASF Committers box
+    committer_box = htm.Block(htm.div, classes=".card.mb-3")
+    committer_box.div(".card-header.bg-light")[
+        htpy.i(".bi.bi-key-fill.me-2"),
+        "ASF Committers",
+    ]
+    committer_body = htm.Block(htm.div, classes=".card-body")
+    committer_body.p["Log in to vote directly through ATR. Your vote will be 
recorded and sent to the mailing list."]
+    committer_body.div[
+        htpy.a(".btn.btn-outline-secondary", href=login_url)[
+            htpy.i(".bi.bi-box-arrow-in-right.me-1"),
+            "Log in to vote",
+        ],
+    ]
+    committer_box.append(committer_body.collect())
+    page.append(committer_box.collect())
+
+    # Everyone else box
+    email_box = htm.Block(htm.div, classes=".card.mb-3")
+    email_box.div(".card-header.bg-light")[
+        htpy.i(".bi.bi-envelope-fill.me-2"),
+        "Everyone else",
+    ]
+    email_body = htm.Block(htm.div, classes=".card-body")
+    email_body.p["Contributors and community members can vote by replying to 
the vote thread on the mailing list."]
+    if archive_url:
+        email_body.div[
+            htpy.a(".btn.btn-outline-primary", href=archive_url, 
target="_blank", rel="noopener")[
+                "View vote thread ",
+                htpy.i(".bi.bi-box-arrow-up-right"),
+            ],
+        ]
+    else:
+        committee_name = release.committee.name if release.committee else 
"unknown"
+        email_body.p(".text-muted.mb-0")[
+            "The vote thread archive is not yet available. ",
+            f"Check the dev@{committee_name}.apache.org mailing list.",
+        ]
+    email_box.append(email_body.collect())
+    page.append(email_box.collect())
diff --git a/atr/htm.py b/atr/htm.py
index 24466f5..ef96399 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -273,7 +273,7 @@ class Block:
 
     @property
     def tr(self) -> BlockElementCallable:
-        self.__check_parent("tr", {"tbody", "table"})
+        self.__check_parent("tr", {"tbody", "thead", "table"})
         return BlockElementCallable(self, tr)
 
     @property
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index e962a4f..67c673d 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -523,6 +523,17 @@ aside.sidebar nav a:hover {
     white-space: pre-wrap;
 }
 
+.atr-steps {
+    padding-left: 1.6rem;
+    font-size: 1.4rem;
+    font-weight: 500;
+    line-height: 1.6;
+}
+
+.atr-step-link {
+    font-weight: 500;
+}
+
 .atr-nowrap {
     white-space: nowrap;
 }
diff --git a/atr/template.py b/atr/template.py
index 9916e88..d175a6d 100644
--- a/atr/template.py
+++ b/atr/template.py
@@ -28,8 +28,10 @@ import atr.htm as htm
 render_async = quart.render_template
 
 
-async def blank(title: str, content: str | htm.Element, description: str | 
None = None) -> str:
-    return await render_sync("blank.html", title=title, 
description=description or title, content=content)
+async def blank(title: str, content: str | htm.Element, description: str | 
None = None, init_js: bool = False) -> str:
+    return await render_sync(
+        "blank.html", title=title, description=description or title, 
content=content, init_js=init_js
+    )
 
 
 async def render_sync(
diff --git a/atr/templates/blank.html b/atr/templates/blank.html
index b8d1536..2f6ca90 100644
--- a/atr/templates/blank.html
+++ b/atr/templates/blank.html
@@ -17,4 +17,7 @@
   {% if javascripts %}
     {% for js in javascripts %}<script src="{{- js -}}"></script>{% endfor %}
   {% endif %}
+  {% if init_js %}<script>
+    init();
+</script>{% endif %}
 {% endblock javascripts %}
diff --git a/atr/templates/check-selected-path-table.html 
b/atr/templates/check-selected-path-table.html
index 42f95d7..c8020dc 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -1,6 +1,6 @@
 <div class="table-responsive">
   {# This table uses pairs of rows, so it must be manually striped #}
-  <table class="table table-hover align-middle table-sm mb-0 border">
+  <table class="table align-middle table-sm mb-0 border">
     <tbody>
       {% for path in paths %}
         {% set has_errors = info and (info.errors[path]|length > 0) %}
diff --git a/atr/templates/committee-view.html 
b/atr/templates/committee-view.html
index 43cb28d..b03fd98 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -52,7 +52,7 @@
         </div>
         {% if committee.public_signing_keys %}
           <div class="table-responsive mb-2">
-            <table class="table border table-striped table-hover table-sm">
+            <table class="table border table-striped table-sm">
               <thead>
                 <tr>
                   <th class="px-2" scope="col">Key ID</th>
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index 7cbbc01..467f97b 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -18,13 +18,13 @@
         <br />
         <a href="#"
            onclick="location.href='/auth?logout=/';"
-           class="logout-link btn btn-sm btn-outline-secondary mt-2">Logout</a>
+           class="logout-link btn btn-sm btn-outline-secondary mt-2">Log 
out</a>
       </div>
     {% else %}
 
       <a href="#"
          onclick="location.href='/auth?login=' + window.location.pathname;"
-         class="login-link btn btn-sm btn-secondary">Login</a>
+         class="login-link btn btn-sm btn-secondary">Log in</a>
     {% endif %}
   </div>
   <nav>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to