final
This commit is contained in:
parent
17d42c52a7
commit
87d087b913
29 changed files with 977 additions and 40 deletions
|
|
@ -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
30
ACCESSIBILITY.md
Normal 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
43
DEPLOYMENT.md
Normal 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
28
Dockerfile
Normal 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"]
|
||||||
20
README.md
20
README.md
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
44
app/core/security.py
Normal 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
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() };
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
13
app/templates/core/error.html
Normal file
13
app/templates/core/error.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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
152
calender_design.md
Normal 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.69–0.74 in some analyses, and impairments present in roughly 75–85% 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 3–5 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.69–0.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 (33–50% 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 (86–98%) 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 Developer’s 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
22
compose.yaml
Normal 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
|
||||||
|
|
||||||
11
config.py
11
config.py
|
|
@ -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 = {
|
||||||
|
|
|
||||||
10
design.md
10
design.md
|
|
@ -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
11
gunicorn.conf.py
Normal 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
|
||||||
|
|
||||||
|
|
@ -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 ###
|
||||||
75
progress.md
75
progress.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
173
tests/polish/test_accessibility.py
Normal file
173
tests/polish/test_accessibility.py
Normal 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
|
||||||
|
|
||||||
72
tests/polish/test_performance.py
Normal file
72
tests/polish/test_performance.py
Normal 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}
|
||||||
67
tests/polish/test_production.py
Normal file
67
tests/polish/test_production.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue