This commit is contained in:
patsy 2026-08-08 05:37:41 +02:00
parent 17d42c52a7
commit 87d087b913
29 changed files with 977 additions and 40 deletions

View file

@ -2,3 +2,8 @@ STEADY_ENV=development
SECRET_KEY=replace-with-a-long-random-value SECRET_KEY=replace-with-a-long-random-value
# DATABASE_URL may override the default absolute instance/steady.db location. # DATABASE_URL may override the default absolute instance/steady.db location.
FEATURE_ADMIN=false FEATURE_ADMIN=false
# TRUSTED_HOSTS=steady.example.com,www.steady.example.com
GUNICORN_BIND=127.0.0.1:8000
GUNICORN_WORKERS=2
GUNICORN_THREADS=2
GUNICORN_TIMEOUT=30

30
ACCESSIBILITY.md Normal file
View file

@ -0,0 +1,30 @@
# Accessibility Review
## Target and Scope
Steady targets WCAG 2.2 Level AA. The current review covers server-rendered structure, keyboard-visible focus, control labels, unique IDs, color contrast, 320 CSS-pixel reflow contracts, target sizing, reduced motion, forced colors, and status announcements. Reference: [W3C WCAG 2.2](https://www.w3.org/TR/WCAG22/).
This is not a certification. The development environment has no browser engine, Lighthouse, pa11y, or screen reader, so rendered layout, zoom, focus order, and assistive-technology behavior still require manual verification.
## Automated Evidence
Run:
```bash
python -m pytest tests/polish/test_accessibility.py
```
The tests render primary authenticated pages and check language, page titles, the main landmark, skip navigation, headings, unique IDs, named buttons, and labeled controls. CSS contract tests cover the mobile breakpoint, 44-pixel primary targets, three-pixel focus outlines, reduced-motion overrides, forced-colors support, and key light/dark contrast pairs.
## Manual Release Checklist
- Navigate every page using only Tab, Shift+Tab, Enter, Space, and Escape.
- Confirm focus is visible and never obscured at 100%, 200%, and 400% zoom.
- Test at 320 CSS pixels without two-dimensional page scrolling.
- Test Light, Dark, Auto, readability typography, reduced motion, and forced colors.
- Verify forms and live feedback with NVDA/Firefox, VoiceOver/Safari, or an equivalent supported pairing.
- Confirm timer completion is understandable with sound disabled.
- Recheck destructive-action wording and error recovery with representative users.
Record browser, operating system, assistive technology, results, and unresolved issues before a public release.

43
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,43 @@
# Deployment Guide
## Production Requirements
Use a supported Python version, Gunicorn, TLS termination, and a reverse proxy or managed platform. Never use `flask run` in production. Flask's official guidance recommends a dedicated WSGI server and commonly a reverse proxy: [Flask deployment documentation](https://flask.palletsprojects.com/en/stable/deploying/).
Set environment variables outside the repository:
```bash
STEADY_ENV=production
SECRET_KEY=<long-random-secret>
DATABASE_URL=sqlite:////srv/steady/instance/steady.db
TRUSTED_HOSTS=steady.example.com
FEATURE_ADMIN=false
```
Generate a secret with `python -c "import secrets; print(secrets.token_urlsafe(48))"`. Restrict the instance directory and environment to the service account. SQLite is suitable for a single-host deployment; use platform-managed storage and a database designed for concurrency before horizontal scaling.
## Release Procedure
```bash
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
flask --app app db upgrade
python -m pytest
gunicorn --config gunicorn.conf.py wsgi:app
```
Gunicorn binds to `127.0.0.1:8000` by default. Put TLS and request buffering in a trusted reverse proxy; do not expose the internal bind when the proxy is intended to be mandatory. Configure forwarded headers only for known proxy addresses rather than applying `ProxyFix` generically.
## Operations
- Probe `/health` for process availability; it intentionally does not test every dependency.
- Back up the SQLite database using a consistent database-aware snapshot. Per-user JSON exports are portability backups, not a replacement for server backups.
- Back up before migrations and test restore procedures regularly.
- Rotate `SECRET_KEY` only with a plan to invalidate existing sessions.
- Review logs without recording passwords, secrets, backup contents, or task descriptions.
- Confirm HTTPS responses include HSTS and all responses include the configured security headers.
- Keep `FEATURE_ADMIN=false` unless the guarded placeholder is explicitly required.
Rollback should restore both the prior application release and its compatible database backup. Do not run destructive schema downgrades without reviewing the generated migration and recovery plan.

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# Base image: matches Python 3.12. Change this if you tested locally on a
# different version — a mismatch can cause subtle bugs that don't show up
# until production (e.g. dependency wheels built for the wrong version).
FROM python:3.12-slim
# All subsequent commands run from here inside the container.
WORKDIR /app
# Copy ONLY requirements.txt first, not the whole app. Docker caches each
# layer — as long as requirements.txt hasn't changed, this layer (and the
# slow pip install below) gets reused on every rebuild, even if you've
# edited your Python files. Copying everything first would bust that cache
# on every single code change.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Now copy the rest of the application code.
COPY . .
# Documents which port the app listens on. This does NOT actually publish
# the port anywhere — that happens in compose.yaml via `expose`.
EXPOSE 5000
# Production entrypoint. Never use `flask run` or `app.run()` in production —
# that's Flask's single-threaded development server, not meant to handle
# real traffic or run unattended. gunicorn is a proper WSGI server.
# "wsgi:app" means: import the `app` object from wsgi.py.
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "wsgi:app"]

View file

@ -73,3 +73,23 @@ The admin feature is absent by default. Setting `FEATURE_ADMIN=true` before the
application starts registers `/admin/`, which remains restricted to the `admin` application starts registers `/admin/`, which remains restricted to the `admin`
role. It is a data-free placeholder only: user management, role assignment, and role. It is a data-free placeholder only: user management, role assignment, and
analytics are intentionally not implemented. analytics are intentionally not implemented.
## Production and quality
Task lists are paginated at 50 records per page and use an indexed visibility,
status, and due-date query path. Dynamic responses are not cached; static assets
use a bounded cache; and responses include CSP, framing, MIME, referrer,
permissions, and cross-origin policies.
Use [DEPLOYMENT.md](DEPLOYMENT.md) for Gunicorn, environment, TLS/reverse-proxy,
migration, backup, health-check, and rollback guidance. Use
[ACCESSIBILITY.md](ACCESSIBILITY.md) for automated evidence and the mandatory
manual browser, keyboard, zoom, forced-colors, and screen-reader release matrix.
Focused quality checks can be run with:
```bash
python -m pytest tests/polish
node --check app/static/js/main.js
SECRET_KEY=configuration-check-only gunicorn --check-config --config gunicorn.conf.py wsgi:app
```

View file

@ -8,6 +8,7 @@ from flask import Flask
from config import CONFIGS from config import CONFIGS
from .extensions import csrf, db, login_manager, migrate from .extensions import csrf, db, login_manager, migrate
from .core.security import configure_security_headers
def create_app( def create_app(
@ -34,6 +35,7 @@ def create_app(
_validate_config(app) _validate_config(app)
_initialize_extensions(app) _initialize_extensions(app)
_register_blueprints(app) _register_blueprints(app)
configure_security_headers(app)
return app return app

View file

@ -1,5 +1,7 @@
from flask import jsonify, render_template from flask import jsonify, render_template
from app.extensions import db
from . import bp from . import bp
@ -16,3 +18,31 @@ def health():
@bp.app_errorhandler(403) @bp.app_errorhandler(403)
def forbidden(_error): def forbidden(_error):
return render_template("core/403.html"), 403 return render_template("core/403.html"), 403
@bp.app_errorhandler(404)
def not_found(_error):
return render_template(
"core/error.html",
heading="That page is not here",
message="The address may have changed, or the item may be unavailable.",
), 404
@bp.app_errorhandler(413)
def request_too_large(_error):
return render_template(
"core/error.html",
heading="That file is too large",
message="Choose a file smaller than 2 MiB and try again.",
), 413
@bp.app_errorhandler(500)
def internal_error(_error):
db.session.rollback()
return render_template(
"core/error.html",
heading="Steady needs a moment",
message="Nothing more is required from you. Please try again shortly.",
), 500

44
app/core/security.py Normal file
View file

@ -0,0 +1,44 @@
from flask import Flask, request
CONTENT_SECURITY_POLICY = "; ".join(
(
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
)
)
def configure_security_headers(app: Flask) -> None:
@app.after_request
def add_security_headers(response):
response.headers.setdefault("Content-Security-Policy", CONTENT_SECURITY_POLICY)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault(
"Permissions-Policy",
"camera=(), geolocation=(), microphone=()",
)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
if request.endpoint == "static":
response.headers["Cache-Control"] = "public, max-age=3600"
else:
response.headers["Cache-Control"] = "no-store"
if app.config["ENABLE_HSTS"] and request.is_secure:
response.headers.setdefault(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
)
return response

View file

@ -83,6 +83,15 @@ input:focus-visible {
margin: 0; margin: 0;
} }
.nav__actions > a,
.nav__actions button,
.filters a,
.task-card__focus {
display: inline-flex;
align-items: center;
min-height: 2.75rem;
}
.brand { .brand {
color: var(--text); color: var(--text);
font-size: 1.35rem; font-size: 1.35rem;
@ -230,6 +239,15 @@ input:focus-visible {
font-weight: 700; font-weight: 700;
} }
.pagination {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 2rem;
}
.task-list { .task-list {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@ -440,19 +458,24 @@ input:focus-visible {
} }
.timer__progress { .timer__progress {
display: block;
width: 100%;
height: 0.6rem; height: 0.6rem;
overflow: hidden; border: 0;
border-radius: 1rem; border-radius: 1rem;
background: var(--border); background: var(--border);
accent-color: var(--lavender);
}
.timer__progress::-webkit-progress-bar {
border-radius: inherit;
background: var(--border);
} }
.timer__progress span { .timer__progress::-webkit-progress-value,
display: block; .timer__progress::-moz-progress-bar {
width: 100%;
height: 100%;
border-radius: inherit; border-radius: inherit;
background: var(--lavender); background: var(--lavender);
transition: width 0.25s linear;
} }
.timer__actions, .timer__actions,
@ -650,8 +673,8 @@ input:focus-visible {
.preference-row input[role="switch"] { .preference-row input[role="switch"] {
flex: 0 0 auto; flex: 0 0 auto;
width: 1.4rem; width: 2rem;
height: 1.4rem; height: 2rem;
accent-color: var(--lavender); accent-color: var(--lavender);
} }
@ -678,6 +701,11 @@ input:focus-visible {
background: var(--surface); background: var(--surface);
} }
details > summary {
min-height: 2.75rem;
padding-block: 0.55rem;
}
@keyframes gentle-arrival { @keyframes gentle-arrival {
from { from {
opacity: 0; opacity: 0;
@ -743,6 +771,23 @@ input {
} }
} }
@media (forced-colors: active) {
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline-color: Highlight;
}
.badge,
.status,
.task-card,
.message {
border: 1px solid CanvasText;
}
}
@media (max-width: 38rem) { @media (max-width: 38rem) {
.nav { .nav {
align-items: flex-start; align-items: flex-start;

View file

@ -26,7 +26,7 @@ function initializeTimer(timer) {
const taskId = timer.dataset.taskId; const taskId = timer.dataset.taskId;
const storageKey = `steady.timer.${taskId}`; const storageKey = `steady.timer.${taskId}`;
const display = timer.querySelector(".timer__display"); const display = timer.querySelector(".timer__display");
const progress = timer.querySelector(".timer__progress span"); const progress = timer.querySelector(".timer__progress");
const announcement = timer.querySelector(".timer__announcement"); const announcement = timer.querySelector(".timer__announcement");
let intervalId; let intervalId;
@ -61,7 +61,7 @@ function initializeTimer(timer) {
const minutes = Math.floor(remaining / 60); const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60; const seconds = remaining % 60;
display.textContent = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; display.textContent = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
progress.style.width = `${Math.max(0, Math.min(100, (remaining / duration) * 100))}%`; progress.value = remaining;
if (remaining === 0 && state.running) { if (remaining === 0 && state.running) {
state = { remaining: 0, running: false, updatedAt: Date.now() }; state = { remaining: 0, running: false, updatedAt: Date.now() };

View file

@ -1,7 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import CheckConstraint, ForeignKey, JSON, String, Text from sqlalchemy import CheckConstraint, ForeignKey, Index, JSON, String, Text
from sqlalchemy.ext.mutable import MutableList from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -22,6 +22,13 @@ class Task(db.Model):
"priority IN ('urgent', 'important', 'normal')", "priority IN ('urgent', 'important', 'normal')",
name="ck_task_priority", name="ck_task_priority",
), ),
Index(
"ix_task_owner_visibility_status_due",
"user_id",
"cleared_at",
"status",
"due_date",
),
) )
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)

View file

@ -36,8 +36,8 @@ from .services import (
create_task, create_task,
delete_task, delete_task,
get_task, get_task,
list_tasks,
list_today_tasks, list_today_tasks,
paginate_tasks,
toggle_chunk_session, toggle_chunk_session,
toggle_subtask, toggle_subtask,
undo_clear_completed, undo_clear_completed,
@ -64,10 +64,20 @@ def index():
selected_status = request.args.get("status") or None selected_status = request.args.get("status") or None
if selected_status not in {None, *TASK_STATUSES}: if selected_status not in {None, *TASK_STATUSES}:
abort(400) abort(400)
tasks = list_tasks(current_user.id, selected_status) raw_page = request.args.get("page", "1")
if not raw_page.isdigit() or int(raw_page) < 1:
abort(400)
pagination = paginate_tasks(
current_user.id,
status=selected_status,
page=int(raw_page),
)
if pagination.total and pagination.page > pagination.pages:
abort(404)
return render_template( return render_template(
"tasks/list.html", "tasks/list.html",
tasks=tasks, tasks=pagination.items,
pagination=pagination,
selected_status=selected_status, selected_status=selected_status,
status_labels=STATUS_LABELS, status_labels=STATUS_LABELS,
) )

View file

@ -21,6 +21,16 @@ class ForgivingStreak:
freeze_used: bool freeze_used: bool
@dataclass(frozen=True)
class TaskPage:
items: list[Task]
page: int
pages: int
total: int
has_prev: bool
has_next: bool
def _task_query(user_id: int): def _task_query(user_id: int):
return ( return (
select(Task) select(Task)
@ -60,6 +70,35 @@ def list_tasks(user_id: int, status: str | None = None) -> list[Task]:
return list(db.session.scalars(query.order_by(*_task_order()))) return list(db.session.scalars(query.order_by(*_task_order())))
def paginate_tasks(
user_id: int,
*,
status: str | None = None,
page: int = 1,
per_page: int = 50,
) -> TaskPage:
query = _task_query(user_id)
if status is not None:
if status not in TASK_STATUSES:
raise ValueError("Unknown task status.")
query = query.where(Task.status == status)
pagination = db.paginate(
query.order_by(*_task_order()),
page=page,
per_page=per_page,
max_per_page=50,
error_out=False,
)
return TaskPage(
items=list(pagination.items),
page=pagination.page,
pages=pagination.pages,
total=pagination.total,
has_prev=pagination.has_prev,
has_next=pagination.has_next,
)
def list_today_tasks( def list_today_tasks(
user_id: int, user_id: int,
*, *,

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}{{ heading }} · Steady{% endblock %}
{% block content %}
<section class="empty-state">
<p class="eyebrow">A gentle detour</p>
<h1>{{ heading }}</h1>
<p>{{ message }}</p>
<a class="button" href="{{ url_for('core.index') }}">Return home</a>
</section>
{% endblock %}

View file

@ -31,7 +31,7 @@
{% for subtask in task.subtasks %} {% for subtask in task.subtasks %}
<li class="subtask {% if subtask.completed %}subtask--done{% endif %}"> <li class="subtask {% if subtask.completed %}subtask--done{% endif %}">
<form method="post" action="{{ url_for('tasks.toggle_subtask_status', task_id=task.id, subtask_id=subtask.id) }}"> <form method="post" action="{{ url_for('tasks.toggle_subtask_status', task_id=task.id, subtask_id=subtask.id) }}">
{{ action_form.hidden_tag() }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="subtask__toggle" type="submit" aria-label="Mark {{ subtask.title }} as {{ 'not completed' if subtask.completed else 'completed' }}"> <button class="subtask__toggle" type="submit" aria-label="Mark {{ subtask.title }} as {{ 'not completed' if subtask.completed else 'completed' }}">
<span aria-hidden="true">{{ '✓' if subtask.completed else '○' }}</span> <span aria-hidden="true">{{ '✓' if subtask.completed else '○' }}</span>
{{ subtask.title }} {{ subtask.title }}
@ -58,7 +58,7 @@
{% if task.status != 'done' %} {% if task.status != 'done' %}
<a class="button button--complete" href="{{ url_for('tasks.focus_task', task_id=task.id) }}">Focus on this task</a> <a class="button button--complete" href="{{ url_for('tasks.focus_task', task_id=task.id) }}">Focus on this task</a>
<form method="post" action="{{ url_for('tasks.complete', task_id=task.id) }}"> <form method="post" action="{{ url_for('tasks.complete', task_id=task.id) }}">
{{ action_form.hidden_tag() }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="button button--quiet" type="submit">Mark complete</button> <button class="button button--quiet" type="submit">Mark complete</button>
</form> </form>
{% endif %} {% endif %}
@ -70,7 +70,7 @@
<summary>Delete this task</summary> <summary>Delete this task</summary>
<p>This permanently removes the task and its small steps.</p> <p>This permanently removes the task and its small steps.</p>
<form method="post" action="{{ url_for('tasks.delete', task_id=task.id) }}"> <form method="post" action="{{ url_for('tasks.delete', task_id=task.id) }}">
{{ action_form.hidden_tag() }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="button button--danger" type="submit">Delete permanently</button> <button class="button button--danger" type="submit">Delete permanently</button>
</form> </form>
</details> </details>

View file

@ -59,7 +59,7 @@
data-chime="{{ 'true' if current_user.completion_chime else 'false' }}"> data-chime="{{ 'true' if current_user.completion_chime else 'false' }}">
<h3 id="timer-heading">Visual countdown</h3> <h3 id="timer-heading">Visual countdown</h3>
<output class="timer__display" aria-live="off">{{ '%02d:%02d'|format(task.timer_seconds // 60, task.timer_seconds % 60) }}</output> <output class="timer__display" aria-live="off">{{ '%02d:%02d'|format(task.timer_seconds // 60, task.timer_seconds % 60) }}</output>
<div class="timer__progress" aria-hidden="true"><span></span></div> <progress class="timer__progress" max="{{ task.timer_seconds }}" value="{{ task.timer_seconds }}" aria-label="Countdown remaining"></progress>
<p class="timer__announcement visually-hidden" aria-live="polite"></p> <p class="timer__announcement visually-hidden" aria-live="polite"></p>
<div class="timer__actions"> <div class="timer__actions">
<button class="button" type="button" data-timer-start>Start</button> <button class="button" type="button" data-timer-start>Start</button>
@ -76,7 +76,7 @@
{% for session in task.chunk_sessions %} {% for session in task.chunk_sessions %}
<li class="chunk {% if session.completed %}chunk--done{% endif %}"> <li class="chunk {% if session.completed %}chunk--done{% endif %}">
<form method="post" action="{{ url_for('tasks.toggle_focus_chunk', task_id=task.id, chunk_index=loop.index0) }}"> <form method="post" action="{{ url_for('tasks.toggle_focus_chunk', task_id=task.id, chunk_index=loop.index0) }}">
{{ action_form.hidden_tag() }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" aria-label="Mark {{ session.minutes }} minute block as {{ 'not completed' if session.completed else 'completed' }}"> <button type="submit" aria-label="Mark {{ session.minutes }} minute block as {{ 'not completed' if session.completed else 'completed' }}">
<span aria-hidden="true">{{ '✓' if session.completed else loop.index }}</span> <span aria-hidden="true">{{ '✓' if session.completed else loop.index }}</span>
<span>{{ session.minutes }} min</span> <span>{{ session.minutes }} min</span>
@ -90,7 +90,7 @@
<div class="focus-task__actions"> <div class="focus-task__actions">
<form method="post" action="{{ url_for('tasks.complete', task_id=task.id) }}"> <form method="post" action="{{ url_for('tasks.complete', task_id=task.id) }}">
{{ action_form.hidden_tag() }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="button button--complete" type="submit">Mark this task complete</button> <button class="button button--complete" type="submit">Mark this task complete</button>
</form> </form>
<a href="{{ url_for('tasks.detail', task_id=task.id) }}">View all details</a> <a href="{{ url_for('tasks.detail', task_id=task.id) }}">View all details</a>
@ -127,4 +127,3 @@
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -30,5 +30,18 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endblock %}
{% if pagination.pages > 1 %}
<nav class="pagination" aria-label="Task pages">
{% if pagination.has_prev %}
<a class="button button--quiet" rel="prev"
href="{{ url_for('tasks.index', status=selected_status, page=pagination.page - 1) }}">Previous</a>
{% endif %}
<span aria-current="page">Page {{ pagination.page }} of {{ pagination.pages }}</span>
{% if pagination.has_next %}
<a class="button button--quiet" rel="next"
href="{{ url_for('tasks.index', status=selected_status, page=pagination.page + 1) }}">Next</a>
{% endif %}
</nav>
{% endif %}
{% endblock %}

View file

@ -18,7 +18,7 @@
<p>They are safely hidden, not permanently deleted.</p> <p>They are safely hidden, not permanently deleted.</p>
</div> </div>
<form method="post" action="{{ url_for('tasks.undo_completed_clear', batch_id=undo_batch) }}"> <form method="post" action="{{ url_for('tasks.undo_completed_clear', batch_id=undo_batch) }}">
{{ action_form.hidden_tag() }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="button" type="submit">Undo clear</button> <button class="button" type="submit">Undo clear</button>
</form> </form>
</aside> </aside>
@ -40,9 +40,9 @@
{{ markdown_form.markdown_file(accept=".md,.markdown,.txt,text/plain,text/markdown") }} {{ markdown_form.markdown_file(accept=".md,.markdown,.txt,text/plain,text/markdown") }}
</div> </div>
<div class="check-field"> <div class="check-field">
{{ markdown_form.confirm() }} {{ markdown_form.confirm.label }} {{ markdown_form.confirm(id="markdown-confirm") }} {{ markdown_form.confirm.label(for_="markdown-confirm") }}
</div> </div>
{{ markdown_form.submit(class="button") }} {{ markdown_form.submit(class="button", id="markdown-submit") }}
</form> </form>
</section> </section>
@ -63,13 +63,13 @@
{{ backup_form.backup_file(accept=".json,application/json") }} {{ backup_form.backup_file(accept=".json,application/json") }}
</div> </div>
<div class="check-field check-field--warning"> <div class="check-field check-field--warning">
{{ backup_form.replace_existing() }} {{ backup_form.replace_existing.label }} {{ backup_form.replace_existing(id="backup-replace") }} {{ backup_form.replace_existing.label(for_="backup-replace") }}
<small>Replacement removes this account's current tasks only after the backup passes validation.</small> <small>Replacement removes this account's current tasks only after the backup passes validation.</small>
</div> </div>
<div class="check-field"> <div class="check-field">
{{ backup_form.confirm() }} {{ backup_form.confirm.label }} {{ backup_form.confirm(id="backup-confirm") }} {{ backup_form.confirm.label(for_="backup-confirm") }}
</div> </div>
{{ backup_form.submit(class="button") }} {{ backup_form.submit(class="button", id="backup-submit") }}
</form> </form>
</section> </section>
@ -80,11 +80,10 @@
<summary>Show clear action</summary> <summary>Show clear action</summary>
<p>You will receive an undo action immediately afterward.</p> <p>You will receive an undo action immediately afterward.</p>
<form method="post" action="{{ url_for('tasks.clear_completed') }}"> <form method="post" action="{{ url_for('tasks.clear_completed') }}">
{{ action_form.hidden_tag() }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="button button--quiet" type="submit">Clear completed tasks</button> <button class="button button--quiet" type="submit">Clear completed tasks</button>
</form> </form>
</details> </details>
</section> </section>
</div> </div>
{% endblock %} {% endblock %}

152
calender_design.md Normal file
View file

@ -0,0 +1,152 @@
## The Neuropsychological Basis for Design Requirements
ADHD is best understood not as an attention disorder but as a disorder of executive function and self-regulation. Barkley's influential model links ADHD to deficits in behavioral inhibition, which secondarily impairs four executive abilities: working memory, self-regulation of affect/motivation/arousal, internalization of speech, and behavioral synthesis. This reframing matters for app design because it shifts the target problem from "helping people pay attention" to "compensating for weak self-regulation across time," which Barkley describes as a form of "time or future blindness" that keeps ADHD brains oriented toward the present rather than future goals.[^1][^2]
Meta-analytic evidence confirms these deficits are measurable and consistent. A meta-analysis covering 168 comparisons across 13 executive function tasks found significant group differences between ADHD and non-ADHD populations in 65% of comparisons, with a weighted mean effect size of 0.54. Working memory deficits are the most robust and common finding, with effect sizes as high as 0.690.74 in some analyses, and impairments present in roughly 7585% of youth with ADHD. Notably, self-reported ratings of executive function in daily life predict occupational and functional impairment far more strongly than lab-based EF tests, which is why real-world tool design should target daily-life friction points rather than abstract cognitive metrics.[^3][^4][^5][^6]
Time perception deficits are equally well-documented. A systematic review and meta-analysis of 824 effect sizes found a mean effect size of 0.688 for time perception impairment across the ADHD lifespan. A separate meta-analysis of 55 studies with over 2,200 participants found significant impairments in time discrimination and time reproduction, especially for short intervals. A 2021 paper in Medical Science Monitor argued that time perception problems should be considered a "focal symptom" of ADHD in adults rather than a peripheral side effect, since deficit severity correlates directly with symptom severity. These findings justify treating time-blindness countermeasures as core, not optional, features.[^7][^8]
## Feature 1: Radical Reduction of Visible Items ("Show Less, Not More")
The single most consistently cited design principle across sources is limiting how many items compete for attention at once. Working memory in ADHD populations shows some of the largest executive function deficits of any component measured, and classic cognitive science places general working memory capacity at roughly seven items (Miller's Law), a figure ADHD-focused UX guidance explicitly narrows to 35 items given additional executive strain. Practically, this means a good app should default to a "Today" view containing only currently actionable tasks, deferring future-dated items automatically rather than displaying an entire backlog.[^9][^10][^11][^6]
- **Today view capped near 7 items**: matches working-memory capacity limits and prevents an unmanageable list from triggering avoidance.[^12][^9]
- **Defer/snooze dates that hide non-actionable tasks**: removes noise items that cannot be acted on right now, since "35 non-actionable items out of 40 shown are just noise".[^9]
- **One primary action or task surfaced at a time**: eliminates the comparing-and-choosing process that causes decision paralysis in ADHD brains.[^10][^9]
This should be included because reducing simultaneous choices directly targets the documented working-memory bottleneck rather than asking the user to compensate through willpower — an approach several UX researchers explicitly frame as "working with the disorder, not against it".[^13][^11]
## Feature 2: Frictionless, Sub-3-Second Capture
ADHD is associated with impaired behavioral inhibition and internalized speech, meaning fleeting thoughts are easily lost before they can be acted on. Any capture process requiring more than a couple of taps risks losing the task entirely, since ADHD attention is transient by nature.[^2][^12][^1][^9]
- **Voice input and single-tap quick-add**: lets a thought be captured "the instant it occurs" without requiring immediate categorization.[^14][^12]
- **Unstructured inbox first, organization later**: research-backed guidance recommends starting with a capture-everything inbox before any hierarchy is built, because the relief of emptying working memory is what makes the habit stick.[^9]
- **Auto-extraction of tasks from freeform notes**: converts unstructured brain-dumps into actionable items without demanding upfront mental effort.[^14]
This is included because inhibition deficits mean the gap between "having a thought" and "losing a thought" is very short in ADHD; minimizing steps between idea and captured record directly compensates for this specific impairment.[^1][^14]
## Feature 3: Concrete, External Time Structures
Because time perception deficits are described as a possible core (not secondary) feature of ADHD, internal time-tracking cannot be relied upon; the interface must supply an external, visual substitute clock.[^8]
| Time-blindness countermeasure | Why it works |
|---|---|
| Visual/countdown timers, progress bars | Externalizes elapsed and remaining time rather than relying on unreliable internal timing[^10][^8] |
| Task chunking into short time-bound sessions (e.g., "3 x 5-min blocks") | Matches evidence that time estimation errors grow with duration and reduces the ambiguity of open-ended tasks[^10][^7] |
| Location- or context-based reminders instead of fixed-clock alerts | ADHD users routinely dismiss and forget generic timed notifications, but context cues ("remind me near the store") bypass the weak internal clock[^12] |
| Anchoring tasks to fixed external events (meals, commute) | Recommended explicitly as an evidence-backed strategy for replacing unreliable internal timing with external cues[^8] |
These features are necessary because time blindness is not a motivational failing but a measurable perceptual deficit with medium-to-large effect sizes in peer-reviewed meta-analyses; a to-do app that only shows due dates without a temporal visualization ignores the core mechanism it needs to support.[^15][^7][^8]
## Feature 4: Forgiving, Shame-Free Feedback Loops
ADHD-related impairment in self-regulation of affect and motivation means rigid systems that "punish" missed days often cause total abandonment rather than course-correction. Multiple independent design guides converge on this point.[^2][^1]
- **No red overdue warnings or guilt-based streaks**: guidance explicitly calls for "positive reinforcement only: no guilt, no negative red states" and for language that "validates emotions, not punishes performance".[^16][^17]
- **Streak freezes / flexible goals**: lets a broken habit resume without penalty, addressing rejection sensitivity commonly reported alongside ADHD.[^10]
- **Immediate, small positive feedback on completion (visual/audio confirmation)**: exploits ADHD's documented dopamine-seeking tendency to reinforce task completion rather than avoidance.[^16][^10]
This matters because emotional self-regulation is one of Barkley's four core executive-function domains impaired in ADHD; a punitive interface effectively attacks an already-weakened regulatory system, while a forgiving one compensates for it.[^18][^2]
## Feature 5: Task Decomposition and Step-by-Step Scaffolding
Complex, multi-step tasks are disproportionately hard to initiate for ADHD users because planning and problem-solving are metacognitive functions tied to working memory. Breaking work into smaller units reduces the executive load required to even begin.[^18]
- **Automatic sub-tasking of large items into smaller steps**: multiple sources independently recommend this as the standard mitigation for task-initiation problems.[^19][^20][^11][^21]
- **Persistent progress indicators and "recap previous step" prompts**: compensates for working-memory limits when returning to a paused multi-step task.[^11][^21]
- **Sequential (not parallel) task lists showing only the next action**: reduces the cognitive act of choosing among many open items to the single question "will I do this now?".[^9]
This is included because chunking is grounded in general cognitive-load theory (Miller, 1956) and has been specifically validated for ADHD populations as a method to maintain engagement and reduce overload.[^22][^11]
## Feature 6: Visual Simplicity, Consistency, and Controlled Notifications
Because ADHD is associated with heightened distractibility from irrelevant stimuli via weak inhibitory control, the interface itself must minimize competing visual and auditory input.[^23][^1]
- **Minimalist layout with generous white space and no unnecessary animation**: framed as essential rather than aesthetic, since "clutter-free designs aren't just trendy — they're essential for ADHD brains".[^24]
- **Consistent, predictable layout and navigation across screens**: reduces the cognitive effort of re-learning where things are, which is costly given working-memory constraints.[^19][^22]
- **User-controlled notification frequency, default to quiet**: multiple sources stress that notification overload is a top complaint, and defaults should favor fewer interruptions unless the user opts in.[^21][^24][^19]
- **Respect for system-level "reduce motion" accessibility settings**: ensures animations calm down when the user has signaled sensitivity to movement.[^25]
This category is grounded in inhibitory-control deficits, one of the four EF domains Barkley identifies as central to ADHD; visual restraint is a direct compensatory strategy, not a stylistic preference.[^23][^2]
## Feature 7: Personalization and Flexible Structure
An inclusive-design analysis grounded in accessibility literature stresses that ADHD needs differ across individuals, so a single rigid workflow will fail many users even if it helps others.[^22]
- **Adjustable color themes, font size, and layout density**: helps users feel comfortable engaging with the tool long-term and accommodates sensory differences.[^20][^22]
- **Multiple valid organizational schemes (lists, boards, calendar) rather than one enforced hierarchy**: flexibility and adaptability are named as a core inclusive-design principle for ADHD specifically.[^22]
- **"Someday/maybe" lists and energy-based tagging**: acknowledges that ADHD energy and focus are variable day to day, so tasks should be matchable to current capacity rather than a fixed schedule.[^9]
This is included because rigid, one-size-fits-all systems are explicitly flagged in the UX literature as failing neurodivergent users who need to "adjust font size, toggle dark mode, or hide content blocks" to remain engaged.[^20][^22]
## Synthesizing the Feature Set
Every feature above maps to a specific, peer-reviewed or meta-analytically supported ADHD impairment rather than a generic productivity trend, which is the standard a "serious" ADHD-app design should meet. The table below summarizes this mapping.
| ADHD impairment (evidence base) | App feature | Effect targeted |
|---|---|---|
| Working memory deficits, d=0.690.74[^6] | Capped Today view, single-action focus | Reduces items competing for limited working memory[^9][^10] |
| Behavioral inhibition / fleeting thought loss[^1][^2] | Sub-3-second capture, voice input | Prevents task loss before it can be recorded[^12][^14] |
| Time perception deficits, g=0.688[^7][^8] | Visual timers, chunked sessions, context reminders | Substitutes unreliable internal clock with external cues[^8] |
| Emotional self-regulation deficits[^2][^18] | Shame-free, forgiving feedback | Prevents abandonment after lapses[^16][^17] |
| Planning/problem-solving weaknesses[^18] | Automatic sub-tasking, progress recaps | Lowers initiation barrier for complex tasks[^19][^11] |
| Inhibitory control / distractibility[^1][^23] | Minimal visual clutter, controllable notifications | Reduces competing external stimuli[^24][^21] |
| Individual variability in impairment (3350% show EF-test deficits, but up to 98% self-report daily-life EF impairment)[^2][^4] | Personalization and flexible structures | Accommodates heterogeneous presentation of ADHD[^22] |
An important caveat drawn from the evidence: not all ADHD individuals show impairment on formal executive-function tests, yet the overwhelming majority (8698%) report functional EF impairment in daily life. This gap is precisely why design should prioritize real-world friction points identified through user research over purely psychometric assumptions, reinforcing the recommendation in UX literature to involve ADHD users directly in usability testing.[^5][^21][^2]
---
## References
1. [Behavioral inhibition, sustained attention, and executive ...](https://pubmed.ncbi.nlm.nih.gov/9000892/) - von RA Barkley · 1997 · Zitiert von: 13293 — Extended to ADHD, the model predicts that ADHD should b...
2. [Theory of Executive Function Self Regulation - Barkley. ...](https://www.vapsych.org/assets/docs/Theory%20of%20Executive%20Function%20%20Self%20Regulation%20-%20Barkley.pdf)
3. [Validity of the Executive Function Theory of Attention-Deficit ...](https://www.sciencedirect.com/science/article/abs/pii/S000632230500171X)
4. [Psychometric properties of the Barkley Deficits in Executive ... - PMC](https://pmc.ncbi.nlm.nih.gov/articles/PMC4766062/) - Performance-based measures have shown some limitation in the assessment of Executive Functions (EF) ...
5. [Impairment in Occupational Functioning and Adult ADHD - PMC](https://pmc.ncbi.nlm.nih.gov/articles/PMC2858600/) - Attention deficit hyperactivity disorder (ADHD) is associated with deficits in executive functioning...
6. [Executive function deficits in attention-deficit/hyperactivity ...](https://pmc.ncbi.nlm.nih.gov/articles/PMC11485171/) - Executive function deficits have been reported in both autism spectrum disorder (ASD) and attention-...
7. [A Systematic Review and Meta-Analysis](https://pubmed.ncbi.nlm.nih.gov/38145491/) - The present meta-analysis quantified the deficit in time perception in Attention-Deficit/Hyperactivi...
8. [Time Blindness and ADHD: Why You Lose Track of Time | Habi](https://habi.app/insights/time-blindness-adhd/) - Time blindness and ADHD are closely linked. Learn the science behind ADHD time perception, plus prac...
9. [Best Task Manager for ADHD in 2026: The Definitive Guide](https://www.singlefocus.co/adhd-task-manager.html) - Most task apps add to overwhelm. Here's what ADHD-friendly task management actually looks like — and...
10. [ADHD-Friendly Design Expert | Some Skills](https://someclaudeskills.com/docs/skills/adhd_design_expert/) - Designs digital experiences for ADHD brains using neuroscience research and UX principles. Expert in...
11. [Designing for ADHD Users: A Psychology-Informed ...](https://medium.com/design-bootcamp/designing-for-adhd-users-a-psychology-informed-approach-d2fc055d5e33) - In product design, understanding our users cognitive processes is crucial to creating experiences t...
12. [The Best To-Do List App for ADHD | Any.do blog](https://www.any.do/blog/the-best-to-do-list-app-for-adhd-how-to-stay-organized-when-your-brain-fights-structure/) - If you have ADHD, you have probably downloaded, tried, and abandoned more productivity apps than you...
13. [The App Graveyard - MindCanvas](https://mindcanvas.app/article/app-graveyard) - Revolutionary spatial thinking tool with AI research assistant, multicast LLM comparison, and infini...
14. [Applying Dr. Hallowell's ADHD Principles to Productivity ...](https://yaranga.net/articles/2025-12-07-applying-dr-hallowells-adhd-principles-to-productivity-app-design/) - Discover how Dr. Edward Hallowell's ADHD insights can shape productivity app design. Learn to create...
15. [Meta-analysis finds consistent time perception impairments ...](https://www.adhdevidence.org/blog/time-blindness-found-to-be-a-consistent-feature-of-adhd)
16. [Reverse To-Do: ADHD-Friendly Gamified Completion Tracker ... - Blink](https://blink.new/case-studies/reverse-todo-adhd-app-ubphpcvu)
17. [Designing for Users Under Stress: Lessons from Building an ...](https://discoveryleader.medium.com/designing-for-users-under-stress-lessons-from-building-an-adhd-app-bda6da4e61bd) - Why Stress-Aware UX Matters
18. [the role of executive function and self-regulation](https://pubmed.ncbi.nlm.nih.gov/20667287/) - Adult ADHD is conceptualized as a disorder of age-inappropriate behavior that occurs because of mald...
19. [Designing for users with ADHD](https://digitalcommunications.wp.st-andrews.ac.uk/2025/02/12/designing-for-users-with-adhd/) - Learn how to design accessible digital experiences for users with ADHD by simplifying navigation, mi...
20. [UX Design for ADHD: When Focus Becomes a Challenge](https://medium.com/design-bootcamp/ux-design-for-adhd-when-focus-becomes-a-challenge-afe160804d94) - Part 1 of the series: Empathy in Pixels Inclusive UX Beyond the Obvious
21. [How to design for users with ADHD | Nick Babich - LinkedIn](https://www.linkedin.com/posts/nbabich_ux-design-uxdesign-activity-7310972073013600257-vKoH) - 💡How to design for users with ADHD ADHD affects around 5% of children, 2.5% - 4% of adults. People w...
22. [Designing a Personal Knowledge](https://www.diva-portal.org/smash/get/diva2:1777365/FULLTEXT02.pdf)
23. [Executive Dysfunctions: The Role in Attention Deficit ... - PMC](https://pmc.ncbi.nlm.nih.gov/articles/PMC4993788/) - Executive functions (EFs) is an umbrella term for various cognitive processes controlled by a comple...
24. [Building Calm in Chaos: A Developers Guide to ADHD-Friendly Apps & Games](https://medevel.com/a-developers-guide-to-adhd-friendly-apps-games/) - Cut through the clutter and design apps/games that enhance focus and joy for ADHD users. Explore key...
25. [ADHD-Friendly Mobile App UI Guidelines (Calmer for All) - VP0](https://vp0.com/blogs/adhd-friendly-mobile-app-ui-guidelines) - An ADHD-friendly UI (clear focus, fewer choices, small steps, calm motion) helps everyone. Build the...

22
compose.yaml Normal file
View file

@ -0,0 +1,22 @@
services:
steady:
build: /home/patsy/apps/steady
container_name: steady
restart: unless-stopped
environment:
- STEADY_ENV=production
- SECRET_KEY=${SECRET_KEY}
volumes:
- steady_data:/app/instance
networks:
- proxy
expose:
- "8020"
volumes:
steady_data:
a
networks:
proxy:
external: true

View file

@ -12,6 +12,12 @@ def environment_flag(name: str, *, default: bool = False) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"} return value.strip().lower() in {"1", "true", "yes", "on"}
def environment_list(name: str) -> list[str] | None:
values = [value.strip() for value in os.environ.get(name, "").split(",")]
filtered = [value for value in values if value]
return filtered or None
class Config: class Config:
"""Shared settings with secure production-oriented defaults.""" """Shared settings with secure production-oriented defaults."""
@ -29,6 +35,8 @@ class Config:
REMEMBER_COOKIE_SECURE = True REMEMBER_COOKIE_SECURE = True
WTF_CSRF_TIME_LIMIT = 3600 WTF_CSRF_TIME_LIMIT = 3600
MAX_CONTENT_LENGTH = 2 * 1024 * 1024 MAX_CONTENT_LENGTH = 2 * 1024 * 1024
TRUSTED_HOSTS = environment_list("TRUSTED_HOSTS")
ENABLE_HSTS = False
FEATURE_ADMIN = environment_flag("FEATURE_ADMIN") FEATURE_ADMIN = environment_flag("FEATURE_ADMIN")
FEATURE_API_V1 = False FEATURE_API_V1 = False
@ -52,7 +60,8 @@ class TestingConfig(Config):
class ProductionConfig(Config): class ProductionConfig(Config):
pass ENABLE_HSTS = True
PREFERRED_URL_SCHEME = "https"
CONFIGS = { CONFIGS = {

View file

@ -258,11 +258,11 @@ a,
- [x] Role-based Access Control (Admin, User, Viewer, Coach) [3] - [x] Role-based Access Control (Admin, User, Viewer, Coach) [3]
### Phase 7: Polish & Testing (Week 9-10) ### Phase 7: Polish & Testing (Week 9-10)
- [ ] Responsive Design Testing [2] - [x] Responsive Design Testing (automated contracts; manual matrix documented) [2]
- [ ] Reduce Motion Respekt [1] - [x] Reduce Motion Respekt [1]
- [ ] Accessibility Audit (WCAG) [1] - [x] Accessibility Audit (WCAG structural audit; no formal certification) [1]
- [ ] Performance Optimization [3] - [x] Performance Optimization [3]
- [ ] Documentation & Deployment Guide [3] - [x] Documentation & Deployment Guide [3]
--- ---

11
gunicorn.conf.py Normal file
View file

@ -0,0 +1,11 @@
import os
bind = os.environ.get("GUNICORN_BIND", "127.0.0.1:8000")
workers = int(os.environ.get("GUNICORN_WORKERS", "2"))
threads = int(os.environ.get("GUNICORN_THREADS", "2"))
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30"))
accesslog = "-"
errorlog = "-"
capture_output = True

View file

@ -0,0 +1,36 @@
"""Index visible task queries
Revision ID: c141c0794fa0
Revises: c79217aea3e8
Create Date: 2026-08-08 04:16:20.969796
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c141c0794fa0'
down_revision = 'c79217aea3e8'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.create_index(
'ix_task_owner_visibility_status_due',
['user_id', 'cleared_at', 'status', 'due_date'],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.drop_index('ix_task_owner_visibility_status_due')
# ### end Alembic commands ###

View file

@ -4,10 +4,10 @@ This is the living implementation log for Steady. Update it after every meaningf
## Current Status ## Current Status
- Current phase: Phase 6 — Admin Dashboard Placeholder - Current phase: Phase 7 — Polish & Testing
- Status: Complete — placeholder scope - Status: Complete — automated scope
- Last updated: 2026-08-08 - Last updated: 2026-08-08
- Next phase: Phase 7 — Polish & Testing - Next milestone: Manual release audit on target browsers and assistive technology
## Phase 1 — Core Infrastructure ## Phase 1 — Core Infrastructure
@ -344,9 +344,76 @@ No broken Python requirements were found.
Line-length, whitespace, and git diff checks completed successfully. Line-length, whitespace, and git diff checks completed successfully.
``` ```
## Phase 7 — Polish & Testing
### Phase 7.1 — Audit baseline and interaction hardening
Reviewed the application against WCAG 2.2 reflow, focus, target-size, motion, labeling, and page-structure criteria. The environment provides Node but no rendered-browser engine, Lighthouse, or pa11y, so this phase can provide automated structural evidence but not claim formal WCAG certification. Manual browser and assistive-technology review remains required before public release.
Expanded navigation, filter, task-focus, disclosure, and switch targets to at least 44 CSS pixels where practical—exceeding the WCAG 2.2 AA 24-pixel minimum. Existing 3-pixel focus outlines, 320-pixel reflow breakpoint, skip link, native labels, live regions, and reduced-motion override were retained.
Replaced the countdown's JavaScript-written inline width with a native `<progress>` element. This preserves semantics and enables a strict production Content Security Policy without allowing inline styles.
Why: accessibility claims should match available evidence. Structural automation catches regressions, while documenting the lack of a browser audit prevents a false certification. Larger targets and native controls reduce motor and assistive-technology friction.
Next: bound large task lists with pagination and add an index for the primary visibility/status/due-date query path.
### Phase 7.2 — Bounded task-list performance
Added server-side task pagination with a hard maximum of 50 records per page, validated page parameters, preserved status filters, semantic previous/next navigation, and out-of-range handling. Added a composite database index over task owner, cleared visibility, status, and due date—the primary columns used by full-list and Today queries.
Why: the previous full list loaded every matching task and all subtasks, causing response size and memory use to grow without bound. Pagination caps database hydration and rendered HTML while retaining a complete navigable workspace. The composite index narrows user-visible task scans as data grows.
Next: generate the index migration, then add production response headers, friendly errors, and deployment configuration.
The `Index visible task queries` migration was generated, reviewed, applied, and followed by a green 54-test regression run.
### Phase 7.3 — Production hardening
Added a strict Content Security Policy, MIME sniffing prevention, clickjacking protection, referrer and browser-capability restrictions, cross-origin isolation headers, no-store caching for dynamic responses, one-hour static caching, and HTTPS-only HSTS in production. Added optional trusted-host configuration and friendly `403`, `404`, `413`, and `500` pages; server errors roll back the active database session.
Added Gunicorn 26, a bounded worker/thread/timeout configuration, and environment examples. Production continues to require an external `SECRET_KEY`; HSTS is emitted only for secure requests.
Why: response policies constrain the impact of injection and framing attacks, while avoiding HSTS over local HTTP prevents development lockout. A dedicated WSGI server replaces Flask's development server in production, and trusted-host configuration is explicit rather than guessing proxy topology.
Next: add automated structural accessibility, responsive-contract, pagination, header, error, and production-startup tests.
### Phase 7.4 — Automated audit evidence
Added structural audits across primary authenticated pages for language, titles, landmarks, skip navigation, headings, unique IDs, named buttons, and labeled controls. Added CSS contract tests for reflow, 44-pixel targets, focus visibility, reduced motion, forced colors, and key WCAG contrast ratios. The audit found duplicate confirmation/submit IDs on the transfer page; each control now has a unique label target. Reusable action forms also avoid repeated CSRF element IDs.
Added performance tests for the 50-item rendering ceiling, invalid/out-of-range pages, a three-query pagination budget, and index presence. Added production tests for CSP and security headers, static/dynamic cache policy, HTTPS-only HSTS, trusted-host rejection, friendly errors, and upload limits.
Why: these tests convert important non-functional requirements into regression contracts. The initial query-count result included an unrelated expired-user refresh; capturing the ID before measurement isolated the pagination path at count, records, and subtasks.
Added `ACCESSIBILITY.md` with the evidence boundary and required manual matrix, plus `DEPLOYMENT.md` covering environment, migrations, Gunicorn, reverse proxies, TLS, backups, health checks, logging, and rollback.
Next: run all focused audits, smoke-test Gunicorn over HTTP, then execute the complete verification matrix.
### Phase 7.5 — Live deployment and final verification
Started Gunicorn 26 with the production entry point, a temporary database, one worker, and a localhost-only bind. A real HTTP request to `/health` returned `200` and the expected JSON plus CSP, MIME, framing, referrer, permissions, cross-origin, and cache headers. Gunicorn then shut down cleanly and temporary databases were removed.
Completed the full regression, Alembic model comparison, admin-disabled and admin-enabled route discovery, Gunicorn configuration validation, Python compilation, JavaScript syntax validation, dependency consistency, line-length and whitespace scans, and patch-format validation.
Final results:
```text
65 tests passed in 5.97s
All 11 focused polish tests passed.
Gunicorn 26 booted and served /health successfully.
Pagination remained within a three-query budget and 50-record ceiling.
Alembic detected no pending model operations.
Python, JavaScript, and Gunicorn configuration checks passed.
No broken Python requirements were found.
Line-length, whitespace, and git diff checks completed successfully.
```
Phase 7's automated scope is complete. Formal release sign-off still requires the manual browser, zoom, keyboard, forced-colors, and screen-reader matrix in `ACCESSIBILITY.md`; the current environment cannot execute those tools.
## Next Work ## Next Work
Phase 7 will focus on evidence-driven polish: responsive behavior, reduced-motion verification, accessibility auditing, performance checks, and deployment/documentation hardening. Future admin operations should remain separate until their requirements are approved. Run the documented manual accessibility matrix on supported target platforms, deploy first to a TLS-enabled staging environment, verify trusted-host and proxy configuration there, rehearse database backup/restore, and record any findings before public release.
## Local Operational Setup ## Local Operational Setup

View file

@ -4,5 +4,6 @@ Flask-Migrate>=4,<5
Flask-SQLAlchemy>=3.1,<4 Flask-SQLAlchemy>=3.1,<4
Flask-WTF>=1.2,<2 Flask-WTF>=1.2,<2
email-validator>=2.2,<3 email-validator>=2.2,<3
gunicorn>=26,<27
pytest>=8,<10 pytest>=8,<10
python-dotenv>=1.0,<2 python-dotenv>=1.0,<2

View file

@ -0,0 +1,173 @@
from html.parser import HTMLParser
from pathlib import Path
from app.auth.services import create_user
from app.tasks.services import configure_focus, create_task
class PageAuditParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.ids: list[str] = []
self.labels: set[str] = set()
self.controls: list[tuple[str, str | None, str | None, str | None]] = []
self.button_stack: list[list[str]] = []
self.button_names: list[str] = []
self.lang: str | None = None
self.title_parts: list[str] = []
self.in_title = False
self.has_main = False
self.has_skip_link = False
self.h1_count = 0
def handle_starttag(self, tag: str, attrs) -> None:
attributes = dict(attrs)
if element_id := attributes.get("id"):
self.ids.append(element_id)
if tag == "html":
self.lang = attributes.get("lang")
elif tag == "title":
self.in_title = True
elif tag == "main" and attributes.get("id") == "main-content":
self.has_main = True
elif tag == "a" and attributes.get("href") == "#main-content":
self.has_skip_link = True
elif tag == "h1":
self.h1_count += 1
elif tag == "label" and attributes.get("for"):
self.labels.add(attributes["for"])
elif tag in {"input", "select", "textarea"}:
self.controls.append(
(
tag,
attributes.get("id"),
attributes.get("type"),
attributes.get("aria-label"),
)
)
elif tag == "button":
self.button_stack.append([attributes.get("aria-label", "")])
def handle_endtag(self, tag: str) -> None:
if tag == "title":
self.in_title = False
elif tag == "button" and self.button_stack:
parts = self.button_stack.pop()
self.button_names.append(" ".join(parts).strip())
def handle_data(self, data: str) -> None:
if self.in_title:
self.title_parts.append(data)
if self.button_stack:
self.button_stack[-1].append(data)
def login(client, email: str) -> None:
client.post(
"/auth/login",
data={"email": email, "password": "secure password"},
)
def test_primary_pages_have_structural_accessibility(client, db):
user = create_user("audit_user", "audit@example.com", "secure password")
task = create_task(
user.id,
title="Audited task",
description="Readable details",
status="not_started",
priority="important",
due_date=None,
)
configure_focus(
task,
timer_minutes=5,
context="Quiet desk",
chunk_minutes=[5, 10],
)
login(client, user.email)
pages = (
"/",
"/tasks/",
"/tasks/today",
f"/tasks/{task.id}",
f"/tasks/{task.id}/focus",
"/tasks/new",
"/tasks/transfer",
"/settings/",
)
for path in pages:
response = client.get(path)
parser = PageAuditParser()
parser.feed(response.get_data(as_text=True))
assert response.status_code == 200, path
assert parser.lang == "en", path
assert "".join(parser.title_parts).strip(), path
assert parser.has_main, path
assert parser.has_skip_link, path
assert parser.h1_count >= 1, path
assert len(parser.ids) == len(set(parser.ids)), path
assert all(parser.button_names), path
for _tag, element_id, input_type, aria_label in parser.controls:
if input_type in {"hidden", "submit"}:
continue
assert element_id, (path, input_type)
assert element_id in parser.labels or aria_label, (path, element_id)
def test_css_contains_reflow_focus_target_and_motion_contracts():
css = Path("app/static/css/style.css").read_text(encoding="utf-8")
assert "width: min(100% - 2rem, 62rem)" in css
assert "@media (max-width: 38rem)" in css
assert "grid-template-columns: 1fr" in css
assert "min-height: 2.75rem" in css
assert "outline: 3px solid var(--focus)" in css
assert "@media (prefers-reduced-motion: reduce)" in css
reduced_motion = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
assert "transition-duration: 0.01ms !important" in reduced_motion
assert "animation: none" in reduced_motion
assert "@media (forced-colors: active)" in css
def relative_luminance(hex_color: str) -> float:
channels = [int(hex_color[index : index + 2], 16) / 255 for index in (1, 3, 5)]
linear = [
value / 12.92
if value <= 0.04045
else ((value + 0.055) / 1.055) ** 2.4
for value in channels
]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def contrast_ratio(first: str, second: str) -> float:
light, dark = sorted(
(relative_luminance(first), relative_luminance(second)),
reverse=True,
)
return (light + 0.05) / (dark + 0.05)
def test_primary_color_tokens_meet_contrast_contract():
css = Path("app/static/css/style.css").read_text(encoding="utf-8").lower()
text_pairs = (
("#2d2d3a", "#f5f0e8"),
("#2d2d3a", "#fffdf9"),
("#626274", "#fffdf9"),
("#57436c", "#fffdf9"),
("#f5f0e8", "#1e1e24"),
("#c5bdcc", "#292932"),
("#d9c5f1", "#292932"),
)
for foreground, background in text_pairs:
assert foreground in css
assert background in css
assert contrast_ratio(foreground, background) >= 4.5
assert contrast_ratio("#67547d", "#fffdf9") >= 3
assert contrast_ratio("#d4c0ee", "#292932") >= 3

View file

@ -0,0 +1,72 @@
from sqlalchemy import event, text
from app.auth.services import create_user
from app.extensions import db as database
from app.tasks.models import Task
from app.tasks.services import paginate_tasks
def seed_tasks(user_id: int, count: int) -> None:
database.session.add_all(
[
Task(
user_id=user_id,
title=f"Paginated task {number:03d}",
status="not_started",
priority="normal",
)
for number in range(count)
]
)
database.session.commit()
def login(client, email: str) -> None:
client.post(
"/auth/login",
data={"email": email, "password": "secure password"},
)
def test_task_list_paginates_at_fifty_items(client, db):
user = create_user("page_user", "page@example.com", "secure password")
seed_tasks(user.id, 55)
login(client, user.email)
first = client.get("/tasks/")
second = client.get("/tasks/?page=2")
assert first.data.count(b'class="task-card ') == 50
assert b"Page 1 of 2" in first.data
assert second.data.count(b'class="task-card ') == 5
assert b"Page 2 of 2" in second.data
assert client.get("/tasks/?page=0").status_code == 400
assert client.get("/tasks/?page=word").status_code == 400
assert client.get("/tasks/?page=3").status_code == 404
def test_paginated_service_uses_bounded_query_count(app, db):
user = create_user("query_user", "query@example.com", "secure password")
seed_tasks(user.id, 55)
user_id = user.id
query_count = 0
def count_query(*_args):
nonlocal query_count
query_count += 1
event.listen(database.engine, "before_cursor_execute", count_query)
try:
page = paginate_tasks(user_id, page=1)
finally:
event.remove(database.engine, "before_cursor_execute", count_query)
assert len(page.items) == 50
assert page.total == 55
assert query_count <= 3
def test_task_visibility_index_exists(db):
indexes = database.session.execute(text("PRAGMA index_list('task')")).all()
assert "ix_task_owner_visibility_status_due" in {row[1] for row in indexes}

View file

@ -0,0 +1,67 @@
from app import create_app
def test_dynamic_responses_have_security_headers(client):
response = client.get("/")
assert response.headers["Cache-Control"] == "no-store"
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
policy = response.headers["Content-Security-Policy"]
assert "default-src 'self'" in policy
assert "frame-ancestors 'none'" in policy
assert "'unsafe-inline'" not in policy
def test_static_assets_have_bounded_public_cache(client):
response = client.get("/static/css/style.css")
assert response.status_code == 200
assert response.headers["Cache-Control"] == "public, max-age=3600"
def test_production_hsts_is_https_only():
app = create_app(
"production",
{
"SECRET_KEY": "production-test-only",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
},
)
client = app.test_client()
secure = client.get("/", base_url="https://steady.example")
insecure = client.get("/", base_url="http://steady.example")
assert "Strict-Transport-Security" in secure.headers
assert "Strict-Transport-Security" not in insecure.headers
def test_trusted_hosts_reject_unknown_host():
app = create_app(
"testing",
{"TRUSTED_HOSTS": ["steady.example"]},
)
client = app.test_client()
assert client.get("/", base_url="http://steady.example").status_code == 200
assert client.get("/", base_url="http://attacker.example").status_code == 400
def test_friendly_not_found_and_upload_limit(client, app):
missing = client.get("/missing-page")
assert missing.status_code == 404
assert b"That page is not here" in missing.data
original_limit = app.config["MAX_CONTENT_LENGTH"]
app.config["MAX_CONTENT_LENGTH"] = 32
try:
too_large = client.post(
"/auth/register",
data={"username": "x" * 100},
)
finally:
app.config["MAX_CONTENT_LENGTH"] = original_limit
assert too_large.status_code == 413
assert b"That file is too large" in too_large.data