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 ebf892b  Add a user interface development guide to the documentation
ebf892b is described below

commit ebf892b3e972d8852d9c8e0db4a6527d0bc920bb
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Oct 12 13:09:59 2025 +0100

    Add a user interface development guide to the documentation
---
 atr/docs/build-processes.html      |   6 +-
 atr/docs/build-processes.md        |   6 +-
 atr/docs/code-conventions.html     |   6 +-
 atr/docs/code-conventions.md       |   6 +-
 atr/docs/developer-guide.html      |   7 +-
 atr/docs/developer-guide.md        |   7 +-
 atr/docs/how-to-contribute.html    |   4 +-
 atr/docs/how-to-contribute.md      |   4 +-
 atr/docs/index.html                |   7 +-
 atr/docs/index.md                  |   7 +-
 atr/docs/overview-of-the-code.html |   1 +
 atr/docs/overview-of-the-code.md   |   2 +
 atr/docs/storage-interface.html    |   2 +-
 atr/docs/storage-interface.md      |   2 +-
 atr/docs/user-interface.html       | 112 ++++++++++++++++++++++++++
 atr/docs/user-interface.md         | 157 +++++++++++++++++++++++++++++++++++++
 atr/routes/keys.py                 |   2 +-
 17 files changed, 307 insertions(+), 31 deletions(-)

diff --git a/atr/docs/build-processes.html b/atr/docs/build-processes.html
index 0962705..f7a270c 100644
--- a/atr/docs/build-processes.html
+++ b/atr/docs/build-processes.html
@@ -1,7 +1,7 @@
-<h1 id="build-processes">3.5. Build processes</h1>
+<h1 id="build-processes">3.6. Build processes</h1>
 <p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer 
guide</a></p>
-<p><strong>Prev</strong>: <code>3.4.</code> <a 
href="storage-interface">Storage interface</a></p>
-<p><strong>Next</strong>: <code>3.6.</code> <a href="code-conventions">Code 
conventions</a></p>
+<p><strong>Prev</strong>: <code>3.5.</code> <a href="user-interface">User 
interface</a></p>
+<p><strong>Next</strong>: <code>3.7.</code> <a href="code-conventions">Code 
conventions</a></p>
 <p><strong>Sections</strong>:</p>
 <ul>
 <li><a href="#documentation-build-script">Documentation build script</a></li>
diff --git a/atr/docs/build-processes.md b/atr/docs/build-processes.md
index ed51416..c5bb313 100644
--- a/atr/docs/build-processes.md
+++ b/atr/docs/build-processes.md
@@ -1,10 +1,10 @@
-# 3.5. Build processes
+# 3.6. Build processes
 
 **Up**: `3.` [Developer guide](developer-guide)
 
-**Prev**: `3.4.` [Storage interface](storage-interface)
+**Prev**: `3.5.` [User interface](user-interface)
 
-**Next**: `3.6.` [Code conventions](code-conventions)
+**Next**: `3.7.` [Code conventions](code-conventions)
 
 **Sections**:
 
diff --git a/atr/docs/code-conventions.html b/atr/docs/code-conventions.html
index f959f8a..0a5b59b 100644
--- a/atr/docs/code-conventions.html
+++ b/atr/docs/code-conventions.html
@@ -1,7 +1,7 @@
-<h1 id="code-conventions">3.6. Code conventions</h1>
+<h1 id="code-conventions">3.7. Code conventions</h1>
 <p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer 
guide</a></p>
-<p><strong>Prev</strong>: <code>3.5.</code> <a href="build-processes">Build 
processes</a></p>
-<p><strong>Next</strong>: <code>3.7.</code> <a href="how-to-contribute">How to 
contribute</a></p>
+<p><strong>Prev</strong>: <code>3.6.</code> <a href="build-processes">Build 
processes</a></p>
+<p><strong>Next</strong>: <code>3.8.</code> <a href="how-to-contribute">How to 
contribute</a></p>
 <p><strong>Sections</strong>:</p>
 <ul>
 <li><a href="#python-code">Python code</a></li>
diff --git a/atr/docs/code-conventions.md b/atr/docs/code-conventions.md
index 03fe859..8ec5b99 100644
--- a/atr/docs/code-conventions.md
+++ b/atr/docs/code-conventions.md
@@ -1,10 +1,10 @@
-# 3.6. Code conventions
+# 3.7. Code conventions
 
 **Up**: `3.` [Developer guide](developer-guide)
 
-**Prev**: `3.5.` [Build processes](build-processes)
+**Prev**: `3.6.` [Build processes](build-processes)
 
-**Next**: `3.7.` [How to contribute](how-to-contribute)
+**Next**: `3.8.` [How to contribute](how-to-contribute)
 
 **Sections**:
 
diff --git a/atr/docs/developer-guide.html b/atr/docs/developer-guide.html
index bb0fc47..bab6ec6 100644
--- a/atr/docs/developer-guide.html
+++ b/atr/docs/developer-guide.html
@@ -8,9 +8,10 @@
 <li><code>3.2.</code> <a href="overview-of-the-code">Overview of the 
code</a></li>
 <li><code>3.3.</code> <a href="database">Database</a></li>
 <li><code>3.4.</code> <a href="storage-interface">Storage interface</a></li>
-<li><code>3.5.</code> <a href="build-processes">Build processes</a></li>
-<li><code>3.6.</code> <a href="code-conventions">Code conventions</a></li>
-<li><code>3.7.</code> <a href="how-to-contribute">How to contribute</a></li>
+<li><code>3.5.</code> <a href="user-interface">User interface</a></li>
+<li><code>3.6.</code> <a href="build-processes">Build processes</a></li>
+<li><code>3.7.</code> <a href="code-conventions">Code conventions</a></li>
+<li><code>3.8.</code> <a href="how-to-contribute">How to contribute</a></li>
 </ul>
 <p><strong>Sections</strong>:</p>
 <ul>
diff --git a/atr/docs/developer-guide.md b/atr/docs/developer-guide.md
index 1880239..5d5e798 100644
--- a/atr/docs/developer-guide.md
+++ b/atr/docs/developer-guide.md
@@ -12,9 +12,10 @@
 * `3.2.` [Overview of the code](overview-of-the-code)
 * `3.3.` [Database](database)
 * `3.4.` [Storage interface](storage-interface)
-* `3.5.` [Build processes](build-processes)
-* `3.6.` [Code conventions](code-conventions)
-* `3.7.` [How to contribute](how-to-contribute)
+* `3.5.` [User interface](user-interface)
+* `3.6.` [Build processes](build-processes)
+* `3.7.` [Code conventions](code-conventions)
+* `3.8.` [How to contribute](how-to-contribute)
 
 **Sections**:
 
diff --git a/atr/docs/how-to-contribute.html b/atr/docs/how-to-contribute.html
index b80c5f1..8beb49b 100644
--- a/atr/docs/how-to-contribute.html
+++ b/atr/docs/how-to-contribute.html
@@ -1,6 +1,6 @@
-<h1 id="how-to-contribute">3.7. How to contribute</h1>
+<h1 id="how-to-contribute">3.8. How to contribute</h1>
 <p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer 
guide</a></p>
-<p><strong>Prev</strong>: <code>3.6.</code> <a href="code-conventions">Code 
conventions</a></p>
+<p><strong>Prev</strong>: <code>3.7.</code> <a href="code-conventions">Code 
conventions</a></p>
 <p><strong>Next</strong>: (none)</p>
 <p><strong>Sections</strong>:</p>
 <ul>
diff --git a/atr/docs/how-to-contribute.md b/atr/docs/how-to-contribute.md
index d0f0762..b49ff15 100644
--- a/atr/docs/how-to-contribute.md
+++ b/atr/docs/how-to-contribute.md
@@ -1,8 +1,8 @@
-# 3.7. How to contribute
+# 3.8. How to contribute
 
 **Up**: `3.` [Developer guide](developer-guide)
 
-**Prev**: `3.6.` [Code conventions](code-conventions)
+**Prev**: `3.7.` [Code conventions](code-conventions)
 
 **Next**: (none)
 
diff --git a/atr/docs/index.html b/atr/docs/index.html
index f312ed4..702cfe5 100644
--- a/atr/docs/index.html
+++ b/atr/docs/index.html
@@ -11,9 +11,10 @@
 <li><code>3.2.</code> <a href="overview-of-the-code">Overview of the 
code</a></li>
 <li><code>3.3.</code> <a href="database">Database</a></li>
 <li><code>3.4.</code> <a href="storage-interface">Storage interface</a></li>
-<li><code>3.5.</code> <a href="build-processes">Build processes</a></li>
-<li><code>3.6.</code> <a href="code-conventions">Code conventions</a></li>
-<li><code>3.7.</code> <a href="how-to-contribute">How to contribute</a></li>
+<li><code>3.5.</code> <a href="user-interface">User interface</a></li>
+<li><code>3.6.</code> <a href="build-processes">Build processes</a></li>
+<li><code>3.7.</code> <a href="code-conventions">Code conventions</a></li>
+<li><code>3.8.</code> <a href="how-to-contribute">How to contribute</a></li>
 </ul>
 </li>
 </ul>
diff --git a/atr/docs/index.md b/atr/docs/index.md
index 9b801b8..28bb016 100644
--- a/atr/docs/index.md
+++ b/atr/docs/index.md
@@ -13,6 +13,7 @@ NOTE: This documentation is a work in progress.
   * `3.2.` [Overview of the code](overview-of-the-code)
   * `3.3.` [Database](database)
   * `3.4.` [Storage interface](storage-interface)
-  * `3.5.` [Build processes](build-processes)
-  * `3.6.` [Code conventions](code-conventions)
-  * `3.7.` [How to contribute](how-to-contribute)
+  * `3.5.` [User interface](user-interface)
+  * `3.6.` [Build processes](build-processes)
+  * `3.7.` [Code conventions](code-conventions)
+  * `3.8.` [How to contribute](how-to-contribute)
diff --git a/atr/docs/overview-of-the-code.html 
b/atr/docs/overview-of-the-code.html
index 4da090b..86b55db 100644
--- a/atr/docs/overview-of-the-code.html
+++ b/atr/docs/overview-of-the-code.html
@@ -31,6 +31,7 @@
 <p>Template rendering can be slow if templates are loaded from disk on every 
request. To address this, we use <a 
href="/ref/atr/preload.py"><code>preload</code></a> to load all templates into 
memory before the server starts serving requests. The <a 
href="/ref/atr/preload.py:setup_template_preloading"><code>preload.setup_template_preloading</code></a>
 function registers a startup hook that finds and caches every template 
file.</p>
 <p>The ATR user interface includes many HTML forms. We use <a 
href="https://wtforms.readthedocs.io/";>WTForms</a> for form handling, accessed 
through the ATR <a href="/ref/atr/forms.py"><code>forms</code></a> module. The 
<a href="/ref/atr/forms.py:Typed"><code>forms.Typed</code></a> base class 
extends the standard <code>QuartForm</code> class in <a 
href="https://quart-wtf.readthedocs.io/";>Quart-WTF</a>. Each form field is 
created using helper functions such as <a href="/ref/atr/forms.py:s [...]
 <p>In addition to templates, we sometimes need to generate HTML 
programmatically in Python. For this we use <a 
href="https://htpy.dev/";>htpy</a>, another third party library, for building 
HTML using Python syntax. The ATR <a 
href="/ref/atr/htm.py"><code>htm</code></a> module extends htpy with a <a 
href="/ref/atr/htm.py:Block"><code>Block</code></a> class that makes it easier 
to build complex HTML structures incrementally. Using htpy means that we get 
type checking for our HTML generation [...]
+<p>Refer to the <a href="user-interface">full user interface development 
guide</a> for more information about this topic.</p>
 <h2 id="scheduling-and-tasks">Scheduling and tasks</h2>
 <p>Many operations in ATR are too slow to run during an HTTP request, so we 
run them asynchronously in background worker processes. The task scheduling 
system in ATR is built from three components: a task queue stored in the SQLite 
database, a worker manager that spawns and monitors worker processes, and the 
worker processes themselves that claim and execute tasks.</p>
 <p>The task queue is stored in a table defined by the <a 
href="/ref/atr/models/sql.py:Task"><code>Task</code></a> model in <a 
href="/ref/atr/models/sql.py"><code>models.sql</code></a>. Each task has a 
status, a type, arguments encoded as JSON, and metadata such as when it was 
added and which user created it. When route handlers need to perform slow 
operations, they create a new <code>Task</code> row with status 
<code>QUEUED</code> and commit it to the database.</p>
diff --git a/atr/docs/overview-of-the-code.md b/atr/docs/overview-of-the-code.md
index 264c19b..3b93c71 100644
--- a/atr/docs/overview-of-the-code.md
+++ b/atr/docs/overview-of-the-code.md
@@ -54,6 +54,8 @@ The ATR user interface includes many HTML forms. We use 
[WTForms](https://wtform
 
 In addition to templates, we sometimes need to generate HTML programmatically 
in Python. For this we use [htpy](https://htpy.dev/), another third party 
library, for building HTML using Python syntax. The ATR 
[`htm`](/ref/atr/htm.py) module extends htpy with a 
[`Block`](/ref/atr/htm.py:Block) class that makes it easier to build complex 
HTML structures incrementally. Using htpy means that we get type checking for 
our HTML generation, and can compose HTML elements just like any other Python 
[...]
 
+Refer to the [full user interface development guide](user-interface) for more 
information about this topic.
+
 ## Scheduling and tasks
 
 Many operations in ATR are too slow to run during an HTTP request, so we run 
them asynchronously in background worker processes. The task scheduling system 
in ATR is built from three components: a task queue stored in the SQLite 
database, a worker manager that spawns and monitors worker processes, and the 
worker processes themselves that claim and execute tasks.
diff --git a/atr/docs/storage-interface.html b/atr/docs/storage-interface.html
index 4170aef..98818c2 100644
--- a/atr/docs/storage-interface.html
+++ b/atr/docs/storage-interface.html
@@ -1,7 +1,7 @@
 <h1 id="storage-interface">3.4. Storage interface</h1>
 <p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer 
guide</a></p>
 <p><strong>Prev</strong>: <code>3.3.</code> <a href="database">Database</a></p>
-<p><strong>Next</strong>: <code>3.5.</code> <a href="build-processes">Build 
processes</a></p>
+<p><strong>Next</strong>: <code>3.5.</code> <a href="user-interface">User 
interface</a></p>
 <p><strong>Sections</strong>:</p>
 <ul>
 <li><a href="#introduction">Introduction</a></li>
diff --git a/atr/docs/storage-interface.md b/atr/docs/storage-interface.md
index 5c47e48..fe7fad4 100644
--- a/atr/docs/storage-interface.md
+++ b/atr/docs/storage-interface.md
@@ -4,7 +4,7 @@
 
 **Prev**: `3.3.` [Database](database)
 
-**Next**: `3.5.` [Build processes](build-processes)
+**Next**: `3.5.` [User interface](user-interface)
 
 **Sections**:
 
diff --git a/atr/docs/user-interface.html b/atr/docs/user-interface.html
new file mode 100644
index 0000000..c7ce898
--- /dev/null
+++ b/atr/docs/user-interface.html
@@ -0,0 +1,112 @@
+<h1 id="user-interface">3.5. User interface</h1>
+<p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer 
guide</a></p>
+<p><strong>Prev</strong>: <code>3.4.</code> <a 
href="storage-interface">Storage interface</a></p>
+<p><strong>Next</strong>: <code>3.6.</code> <a href="build-processes">Build 
processes</a></p>
+<p><strong>Sections</strong>:</p>
+<ul>
+<li><a href="#introduction">Introduction</a></li>
+<li><a href="#jinja2-templates">Jinja2 templates</a></li>
+<li><a href="#forms">Forms</a></li>
+<li><a href="#programmatic-html">Programmatic HTML</a></li>
+<li><a href="#the-htmblock-class">The htm.Block class</a></li>
+<li><a href="#how-a-route-renders-ui">How a route renders UI</a></li>
+</ul>
+<h2 id="introduction">Introduction</h2>
+<p>ATR uses server-side rendering almost exclusively: the server generates 
HTML and sends it to the browser, which displays it. We try to avoid 
client-side scripting, and in the rare cases where we need dynamic front end 
components we use plain TypeScript without recourse to any third party 
framework. (We have some JavaScript too, but we aim to use TypeScript only.) 
Sometimes we incur a full page load where perhaps it would be more ideal to 
update a fragment of the DOM in place, but page [...]
+<p>The UI is built from three main pieces: <a 
href="https://jinja.palletsprojects.com/";>Jinja2</a> for templates, <a 
href="https://wtforms.readthedocs.io/";>WTForms</a> for HTML forms, and <a 
href="https://htpy.dev/";>htpy</a> for programmatic HTML generation. We style 
everything with <a href="https://getbootstrap.com/";>Bootstrap</a>, which we 
customize slightly.</p>
+<h2 id="jinja2-templates">Jinja2 templates</h2>
+<p>Templates live in <a 
href="/ref/atr/templates/"><code>templates/</code></a>. Each template is a 
Jinja2 file that defines HTML structure with placeholders for dynamic content. 
Route handlers render templates by calling <a 
href="/ref/atr/template.py:render"><code>template.render</code></a>, which is 
an alias for <a 
href="/ref/atr/template.py:render_sync"><code>template.render_sync</code></a>. 
The function is asynchronous and takes a template name plus keyword arguments 
for the template  [...]
+<p>Here is an example from <a 
href="/ref/atr/routes/keys.py:add"><code>routes/keys.py</code></a>:</p>
+<pre><code class="language-python">return await template.render(
+    "keys-add.html",
+    asf_id=session.uid,
+    user_committees=participant_of_committees,
+    form=form,
+    key_info=key_info,
+    algorithms=route.algorithms,
+)
+</code></pre>
+<p>The template receives these variables and can access them directly. If you 
pass a variable called <code>form</code>, the template can use <code>{{ form 
}}</code> to render it. <a 
href="https://jinja.palletsprojects.com/en/stable/templates/#list-of-control-structures";>Jinja2
 has control structures</a> like <code>{% for %}</code> and <code>{% if 
%}</code>, which you use when iterating over data or conditionally showing 
content.</p>
+<p>Templates are loaded into memory at server startup by <a 
href="/ref/atr/preload.py:setup_template_preloading"><code>preload.setup_template_preloading</code></a>.
 This means that changing a template requires restarting the server in 
development, which can be configured to happen automatically, but it also means 
that rendering is fast because we never do a disk read during request handling. 
The preloading scans <a href="/ref/atr/templates/"><code>templates/</code></a> 
recursively and ca [...]
+<p>Template rendering happens in a thread pool to avoid blocking the async 
event loop. The function <a 
href="/ref/atr/template.py:_render_in_thread"><code>_render_in_thread</code></a>
 uses <code>asyncio.to_thread</code> to execute Jinja2's synchronous 
<code>render</code> method.</p>
+<h2 id="forms">Forms</h2>
+<p>HTML forms in ATR are handled by <a 
href="https://wtforms.readthedocs.io/";>WTForms</a>, accessed through our <a 
href="/ref/atr/forms.py"><code>forms</code></a> module. Each form is a class 
that inherits from <a 
href="/ref/atr/forms.py:Typed"><code>forms.Typed</code></a>, which itself 
inherits from <code>QuartForm</code> in <a 
href="https://quart-wtf.readthedocs.io/";>Quart-WTF</a>. Form fields are class 
attributes created using helper functions from the <code>forms</code> 
module.</p>
+<p>Here is a typical form definition from <a 
href="/ref/atr/routes/keys.py:AddOpenPGPKeyForm"><code>routes/keys.py</code></a>:</p>
+<pre><code class="language-python">class AddOpenPGPKeyForm(forms.Typed):
+    public_key = forms.textarea(
+        "Public OpenPGP key",
+        placeholder="Paste your ASCII-armored public OpenPGP key here...",
+        description="Your public key should be in ASCII-armored format, 
starting with"
+        ' "-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+    )
+    selected_committees = forms.checkboxes(
+        "Associate key with committees",
+        description="Select the committees with which to associate your key.",
+    )
+    submit = forms.submit("Add OpenPGP key")
+</code></pre>
+<p>The helper functions like <a 
href="/ref/atr/forms.py:textarea"><code>forms.textarea</code></a>, <a 
href="/ref/atr/forms.py:checkboxes"><code>forms.checkboxes</code></a>, and <a 
href="/ref/atr/forms.py:submit"><code>forms.submit</code></a> create WTForms 
field objects with appropriate validators. The first argument is always the 
label text. Optional fields take <code>optional=True</code>, and you can 
provide placeholders, descriptions, and other field-specific options. If you do 
not pa [...]
+<p>To use a form in a route, create it with <code>await 
FormClass.create_form()</code>. For POST requests, pass <code>data=await 
quart.request.form</code> to populate it with the submitted data. Then validate 
with <code>await form.validate_on_submit()</code>. If validation passes, you 
extract data from <code>form.field_name.data</code> and proceed. If validation 
fails, re-render the template with the form object, which will then display 
error messages.</p>
+<p>The <a href="/ref/atr/forms.py"><code>forms</code></a> module also provides 
rendering functions that generate Bootstrap-styled HTML. The function <a 
href="/ref/atr/forms.py:render_columns"><code>forms.render_columns</code></a> 
creates a two-column layout with labels on the left and inputs on the right. 
The function <a 
href="/ref/atr/forms.py:render_simple"><code>forms.render_simple</code></a> 
creates a simpler vertical layout. The function <a 
href="/ref/atr/forms.py:render_table"><cod [...]
+<h2 id="programmatic-html">Programmatic HTML</h2>
+<p>Sometimes you need to generate HTML in Python rather than in a template. 
For this we use <a href="https://htpy.dev/";>htpy</a>, which provides a Python 
API for building HTML elements. You import <code>htpy</code> and then use it 
like this:</p>
+<pre><code class="language-python">import htpy
+
+element = htpy.div(".container")[
+    htpy.h1["Release Candidate"],
+    htpy.p["This is a release candidate."],
+]
+</code></pre>
+<p>The square brackets syntax is how htpy accepts children. The parentheses 
syntax is for attributes. If you want a div with an id, you write 
<code>htpy.div(id="content")</code>. If you want a div with a class, you can 
use CSS selector syntax like <code>htpy.div(".my-class")</code> or you can use 
<code>htpy.div(class_="my-class")</code>, remembering to use the underscore in 
<code>class_</code>.</p>
+<p>You can nest elements arbitrarily, mix strings and elements, and pass lists 
of elements. Converting an htpy element to a string renders it as HTML. 
Templates can therefore render htpy elements directly by passing them as 
variables.</p>
+<p>The htpy library provides type annotations for HTML elements. It does not 
validate attribute names or values, so you can pass nonsensical attributes 
without error. We plan to fix this by adding stricter types in our 
<code>htm</code> wrapper. The main benefit to using <code>htpy</code> (via 
<code>htm</code>) is having a clean Python API for HTML generation rather than 
concatenating strings or using templating.</p>
+<h2 id="the-htmblock-class">The htm.Block class</h2>
+<p>The ATR <a href="/ref/atr/htm.py"><code>htm</code></a> module extends htpy 
with a <a href="/ref/atr/htm.py:Block"><code>Block</code></a> class that makes 
it easier to build complex HTML structures incrementally. You create a block, 
append elements to it, and then collect them into a final element. Here is the 
typical usage pattern:</p>
+<pre><code class="language-python">import atr.htm as htm
+
+div = htm.Block()
+div.h1["Release Information"]
+div.p["The release was created on ", release.created.isoformat(), "."]
+if release.released:
+    div.p["It was published on ", release.released.isoformat(), "."]
+return div.collect()
+</code></pre>
+<p>The block class provides properties for common HTML elements like 
<code>h1</code>, <code>h2</code>, <code>p</code>, <code>div</code>, 
<code>ul</code>, and so on. When you access these properties, you get back a <a 
href="/ref/atr/htm.py:BlockElementCallable"><code>BlockElementCallable</code></a>,
 which you can call to create an element with attributes or use square brackets 
to add grandchildren of the block. The element is automatically appended to the 
block's internal list of children.</p>
+<p>The <code>collect</code> method assembles all of the elements into a single 
htpy element. If you created the block with an outer element like 
<code>htm.Block(htpy.div(".container"))</code>, that element wraps all the 
children. If you created the block with no outer element, <code>collect</code> 
wraps everything in a div. You can also pass a <code>separator</code> argument 
to <code>collect</code>, which inserts a text separator between elements.</p>
+<p>The block class is useful when you are building HTML in a loop or when you 
have conditional elements. Instead of managing a list of elements manually, you 
can let the block class do it for you: append elements as you go, and at the 
end call <code>collect</code> to get the final result. This is cleaner than 
concatenating strings or maintaining lists yourself.</p>
+<p>The block class also adds a <code>data-src</code> attribute to elements, 
which records which function created the element. If you see an element in the 
browser inspector with <code>data-src="atr.routes.keys:selected"</code>, you 
know that it came from the <code>selected</code> function in 
<code>routes/keys.py</code>. The source is extracted automatically using <a 
href="/ref/atr/log.py:caller_name"><code>log.caller_name</code></a>.</p>
+<h2 id="how-a-route-renders-ui">How a route renders UI</h2>
+<p>A typical route that renders UI first authenticates the user, loads data 
from the database, creates and validates a form if necessary, and renders a 
template with the data and form. Here is a simplified example from <a 
href="/ref/atr/routes/keys.py:add"><code>routes/keys.py</code></a>:</p>
+<pre><code class="language-python">@route.committer("/keys/add", 
methods=["GET", "POST"])
+async def add(session: route.CommitterSession) -&gt; str:
+    async with storage.write() as write:
+        participant_of_committees = await write.participant_of_committees()
+
+    committee_choices: forms.Choices = [
+        (c.name, c.display_name or c.name)
+        for c in participant_of_committees
+    ]
+
+    form = await AddOpenPGPKeyForm.create_form(
+        data=(await quart.request.form) if (quart.request.method == "POST") 
else None
+    )
+    forms.choices(form.selected_committees, committee_choices)
+
+    if await form.validate_on_submit():
+        # Process the form data
+        # ...
+        await quart.flash(f"OpenPGP key added successfully.", "success")
+        form = await AddOpenPGPKeyForm.create_form()
+        forms.choices(form.selected_committees, committee_choices)
+
+    return await template.render(
+        "keys-add.html",
+        asf_id=session.uid,
+        user_committees=participant_of_committees,
+        form=form,
+    )
+</code></pre>
+<p>The route is decorated with <code>@route.committer</code>, which ensures 
that the route fails before the function is even entered if authentication 
fails. The function receives a <code>session</code> object, which is an 
instance of <a 
href="/ref/atr/route.py:CommitterSession"><code>route.CommitterSession</code></a>
 with a range of useful properties and methods. The function then loads data, 
creates a form, checks if the request is a POST, and either processes the form 
or displays it.  [...]
+<p>The template receives the form object and renders it by passing it to one 
of the <code>forms.render_*</code> functions. We previously used Jinja2 macros 
for this, but are migrating to the new rendering functions in Python (e.g. in 
<a 
href="/ref/atr/routes/distribution.py"><code>routes/distribution.py</code></a> 
and <a href="/ref/atr/routes/ignores.py"><code>routes/ignores.py</code></a>). 
The template also receives other data like <code>asf_id</code> and 
<code>user_committees</code>, w [...]
+<p>If you use the programmatic rendering functions from <a 
href="/ref/atr/forms.py"><code>forms</code></a>, you can skip the template 
entirely. These functions return htpy elements, which you can combine with 
other htpy elements and return directly from the route, which is often useful 
for admin routes, for example. You can also use <a 
href="/ref/atr/template.py:blank"><code>template.blank</code></a>, which 
renders a minimal template with just a title and content area. This is useful 
for [...]
+<p>Bootstrap CSS classes are applied automatically by the form rendering 
functions. The functions use classes like <code>form-control</code>, 
<code>form-select</code>, <code>btn-primary</code>, <code>is-invalid</code>, 
and <code>invalid-feedback</code>. We currently use Bootstrap 5. If you 
generate HTML manually with htpy, you can apply Bootstrap classes yourself by 
using the CSS selector syntax like <code>htpy.div(".container")</code> or the 
class attribute like <code>htpy.div(class_="c [...]
diff --git a/atr/docs/user-interface.md b/atr/docs/user-interface.md
new file mode 100644
index 0000000..2638e52
--- /dev/null
+++ b/atr/docs/user-interface.md
@@ -0,0 +1,157 @@
+# 3.5. User interface
+
+**Up**: `3.` [Developer guide](developer-guide)
+
+**Prev**: `3.4.` [Storage interface](storage-interface)
+
+**Next**: `3.6.` [Build processes](build-processes)
+
+**Sections**:
+
+* [Introduction](#introduction)
+* [Jinja2 templates](#jinja2-templates)
+* [Forms](#forms)
+* [Programmatic HTML](#programmatic-html)
+* [The htm.Block class](#the-htmblock-class)
+* [How a route renders UI](#how-a-route-renders-ui)
+
+## Introduction
+
+ATR uses server-side rendering almost exclusively: the server generates HTML 
and sends it to the browser, which displays it. We try to avoid client-side 
scripting, and in the rare cases where we need dynamic front end components we 
use plain TypeScript without recourse to any third party framework. (We have 
some JavaScript too, but we aim to use TypeScript only.) Sometimes we incur a 
full page load where perhaps it would be more ideal to update a fragment of the 
DOM in place, but page lo [...]
+
+The UI is built from three main pieces: 
[Jinja2](https://jinja.palletsprojects.com/) for templates, 
[WTForms](https://wtforms.readthedocs.io/) for HTML forms, and 
[htpy](https://htpy.dev/) for programmatic HTML generation. We style everything 
with [Bootstrap](https://getbootstrap.com/), which we customize slightly.
+
+## Jinja2 templates
+
+Templates live in [`templates/`](/ref/atr/templates/). Each template is a 
Jinja2 file that defines HTML structure with placeholders for dynamic content. 
Route handlers render templates by calling 
[`template.render`](/ref/atr/template.py:render), which is an alias for 
[`template.render_sync`](/ref/atr/template.py:render_sync). The function is 
asynchronous and takes a template name plus keyword arguments for the template 
variables.
+
+Here is an example from [`routes/keys.py`](/ref/atr/routes/keys.py:add):
+
+```python
+return await template.render(
+    "keys-add.html",
+    asf_id=session.uid,
+    user_committees=participant_of_committees,
+    form=form,
+    key_info=key_info,
+    algorithms=route.algorithms,
+)
+```
+
+The template receives these variables and can access them directly. If you 
pass a variable called `form`, the template can use `{{ form }}` to render it. 
[Jinja2 has control 
structures](https://jinja.palletsprojects.com/en/stable/templates/#list-of-control-structures)
 like `{% for %}` and `{% if %}`, which you use when iterating over data or 
conditionally showing content.
+
+Templates are loaded into memory at server startup by 
[`preload.setup_template_preloading`](/ref/atr/preload.py:setup_template_preloading).
 This means that changing a template requires restarting the server in 
development, which can be configured to happen automatically, but it also means 
that rendering is fast because we never do a disk read during request handling. 
The preloading scans [`templates/`](/ref/atr/templates/) recursively and caches 
every file.
+
+Template rendering happens in a thread pool to avoid blocking the async event 
loop. The function 
[`_render_in_thread`](/ref/atr/template.py:_render_in_thread) uses 
`asyncio.to_thread` to execute Jinja2's synchronous `render` method.
+
+## Forms
+
+HTML forms in ATR are handled by [WTForms](https://wtforms.readthedocs.io/), 
accessed through our [`forms`](/ref/atr/forms.py) module. Each form is a class 
that inherits from [`forms.Typed`](/ref/atr/forms.py:Typed), which itself 
inherits from `QuartForm` in [Quart-WTF](https://quart-wtf.readthedocs.io/). 
Form fields are class attributes created using helper functions from the 
`forms` module.
+
+Here is a typical form definition from 
[`routes/keys.py`](/ref/atr/routes/keys.py:AddOpenPGPKeyForm):
+
+```python
+class AddOpenPGPKeyForm(forms.Typed):
+    public_key = forms.textarea(
+        "Public OpenPGP key",
+        placeholder="Paste your ASCII-armored public OpenPGP key here...",
+        description="Your public key should be in ASCII-armored format, 
starting with"
+        ' "-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+    )
+    selected_committees = forms.checkboxes(
+        "Associate key with committees",
+        description="Select the committees with which to associate your key.",
+    )
+    submit = forms.submit("Add OpenPGP key")
+```
+
+The helper functions like [`forms.textarea`](/ref/atr/forms.py:textarea), 
[`forms.checkboxes`](/ref/atr/forms.py:checkboxes), and 
[`forms.submit`](/ref/atr/forms.py:submit) create WTForms field objects with 
appropriate validators. The first argument is always the label text. Optional 
fields take `optional=True`, and you can provide placeholders, descriptions, 
and other field-specific options. If you do not pass `optional=True`, the field 
is required by default. The [`forms.string`](/ref/ [...]
+
+To use a form in a route, create it with `await FormClass.create_form()`. For 
POST requests, pass `data=await quart.request.form` to populate it with the 
submitted data. Then validate with `await form.validate_on_submit()`. If 
validation passes, you extract data from `form.field_name.data` and proceed. If 
validation fails, re-render the template with the form object, which will then 
display error messages.
+
+The [`forms`](/ref/atr/forms.py) module also provides rendering functions that 
generate Bootstrap-styled HTML. The function 
[`forms.render_columns`](/ref/atr/forms.py:render_columns) creates a two-column 
layout with labels on the left and inputs on the right. The function 
[`forms.render_simple`](/ref/atr/forms.py:render_simple) creates a simpler 
vertical layout. The function 
[`forms.render_table`](/ref/atr/forms.py:render_table) puts the form inside a 
table. All three functions return ht [...]
+
+## Programmatic HTML
+
+Sometimes you need to generate HTML in Python rather than in a template. For 
this we use [htpy](https://htpy.dev/), which provides a Python API for building 
HTML elements. You import `htpy` and then use it like this:
+
+```python
+import htpy
+
+element = htpy.div(".container")[
+    htpy.h1["Release Candidate"],
+    htpy.p["This is a release candidate."],
+]
+```
+
+The square brackets syntax is how htpy accepts children. The parentheses 
syntax is for attributes. If you want a div with an id, you write 
`htpy.div(id="content")`. If you want a div with a class, you can use CSS 
selector syntax like `htpy.div(".my-class")` or you can use 
`htpy.div(class_="my-class")`, remembering to use the underscore in `class_`.
+
+You can nest elements arbitrarily, mix strings and elements, and pass lists of 
elements. Converting an htpy element to a string renders it as HTML. Templates 
can therefore render htpy elements directly by passing them as variables.
+
+The htpy library provides type annotations for HTML elements. It does not 
validate attribute names or values, so you can pass nonsensical attributes 
without error. We plan to fix this by adding stricter types in our `htm` 
wrapper. The main benefit to using `htpy` (via `htm`) is having a clean Python 
API for HTML generation rather than concatenating strings or using templating.
+
+## The htm.Block class
+
+The ATR [`htm`](/ref/atr/htm.py) module extends htpy with a 
[`Block`](/ref/atr/htm.py:Block) class that makes it easier to build complex 
HTML structures incrementally. You create a block, append elements to it, and 
then collect them into a final element. Here is the typical usage pattern:
+
+```python
+import atr.htm as htm
+
+div = htm.Block()
+div.h1["Release Information"]
+div.p["The release was created on ", release.created.isoformat(), "."]
+if release.released:
+    div.p["It was published on ", release.released.isoformat(), "."]
+return div.collect()
+```
+
+The block class provides properties for common HTML elements like `h1`, `h2`, 
`p`, `div`, `ul`, and so on. When you access these properties, you get back a 
[`BlockElementCallable`](/ref/atr/htm.py:BlockElementCallable), which you can 
call to create an element with attributes or use square brackets to add 
grandchildren of the block. The element is automatically appended to the 
block's internal list of children.
+
+The `collect` method assembles all of the elements into a single htpy element. 
If you created the block with an outer element like 
`htm.Block(htpy.div(".container"))`, that element wraps all the children. If 
you created the block with no outer element, `collect` wraps everything in a 
div. You can also pass a `separator` argument to `collect`, which inserts a 
text separator between elements.
+
+The block class is useful when you are building HTML in a loop or when you 
have conditional elements. Instead of managing a list of elements manually, you 
can let the block class do it for you: append elements as you go, and at the 
end call `collect` to get the final result. This is cleaner than concatenating 
strings or maintaining lists yourself.
+
+The block class also adds a `data-src` attribute to elements, which records 
which function created the element. If you see an element in the browser 
inspector with `data-src="atr.routes.keys:selected"`, you know that it came 
from the `selected` function in `routes/keys.py`. The source is extracted 
automatically using [`log.caller_name`](/ref/atr/log.py:caller_name).
+
+## How a route renders UI
+
+A typical route that renders UI first authenticates the user, loads data from 
the database, creates and validates a form if necessary, and renders a template 
with the data and form. Here is a simplified example from 
[`routes/keys.py`](/ref/atr/routes/keys.py:add):
+
+```python
[email protected]("/keys/add", methods=["GET", "POST"])
+async def add(session: route.CommitterSession) -> str:
+    async with storage.write() as write:
+        participant_of_committees = await write.participant_of_committees()
+
+    committee_choices: forms.Choices = [
+        (c.name, c.display_name or c.name)
+        for c in participant_of_committees
+    ]
+
+    form = await AddOpenPGPKeyForm.create_form(
+        data=(await quart.request.form) if (quart.request.method == "POST") 
else None
+    )
+    forms.choices(form.selected_committees, committee_choices)
+
+    if await form.validate_on_submit():
+        # Process the form data
+        # ...
+        await quart.flash(f"OpenPGP key added successfully.", "success")
+        form = await AddOpenPGPKeyForm.create_form()
+        forms.choices(form.selected_committees, committee_choices)
+
+    return await template.render(
+        "keys-add.html",
+        asf_id=session.uid,
+        user_committees=participant_of_committees,
+        form=form,
+    )
+```
+
+The route is decorated with `@route.committer`, which ensures that the route 
fails before the function is even entered if authentication fails. The function 
receives a `session` object, which is an instance of 
[`route.CommitterSession`](/ref/atr/route.py:CommitterSession) with a range of 
useful properties and methods. The function then loads data, creates a form, 
checks if the request is a POST, and either processes the form or displays it. 
After successful processing, it creates a fresh [...]
+
+The template receives the form object and renders it by passing it to one of 
the `forms.render_*` functions. We previously used Jinja2 macros for this, but 
are migrating to the new rendering functions in Python (e.g. in 
[`routes/distribution.py`](/ref/atr/routes/distribution.py) and 
[`routes/ignores.py`](/ref/atr/routes/ignores.py)). The template also receives 
other data like `asf_id` and `user_committees`, which it uses to display 
information or make decisions about what to show.
+
+If you use the programmatic rendering functions from 
[`forms`](/ref/atr/forms.py), you can skip the template entirely. These 
functions return htpy elements, which you can combine with other htpy elements 
and return directly from the route, which is often useful for admin routes, for 
example. You can also use [`template.blank`](/ref/atr/template.py:blank), which 
renders a minimal template with just a title and content area. This is useful 
for simple pages that do not need the full templat [...]
+
+Bootstrap CSS classes are applied automatically by the form rendering 
functions. The functions use classes like `form-control`, `form-select`, 
`btn-primary`, `is-invalid`, and `invalid-feedback`. We currently use Bootstrap 
5. If you generate HTML manually with htpy, you can apply Bootstrap classes 
yourself by using the CSS selector syntax like `htpy.div(".container")` or the 
class attribute like `htpy.div(class_="container")`.
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 0a4bc29..2813adb 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -144,7 +144,7 @@ async def add(session: route.CommitterSession) -> str:
     committee_choices: forms.Choices = [(c.name, c.display_name or c.name) for 
c in participant_of_committees]
 
     form = await AddOpenPGPKeyForm.create_form(
-        data=await quart.request.form if quart.request.method == "POST" else 
None
+        data=(await quart.request.form) if (quart.request.method == "POST") 
else None
     )
     forms.choices(form.selected_committees, committee_choices)
 


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


Reply via email to