This commit is contained in:
patsy 2026-08-08 04:04:24 +02:00
parent 67e35bcffd
commit 543f135ca0
14 changed files with 488 additions and 13 deletions

View file

@ -5,7 +5,8 @@ application foundation, secure authentication, task and subtask management, a
seven-item Today view, status filters, priorities, progress indicators, and a seven-item Today view, status filters, priorities, progress indicators, and a
single-task Focus Mode with countdowns, time blocks, context cues, positive single-task Focus Mode with countdowns, time blocks, context cues, positive
completion feedback, forgiving streaks, bulk text capture, and portable JSON completion feedback, forgiving streaks, bulk text capture, and portable JSON
backups with reversible completed-task clearing. backups with reversible completed-task clearing. Per-user settings control
theme, reading style, completion sound, and future reminder frequency.
## Local setup ## Local setup
@ -53,3 +54,11 @@ After signing in, open `/tasks/transfer` to:
JSON restore validates the complete file before changing tasks. Keep downloaded JSON restore validates the complete file before changing tasks. Keep downloaded
backups private because task descriptions may contain personal information. backups private because task descriptions may contain personal information.
## Personalization
Open `/settings/` after signing in to choose Light, Dark, or system-controlled
color mode; enable a dyslexia-friendly local reading style; turn completion
sound on or off; and save a future reminder-frequency preference. The browser
reminder control checks capability only. It does not request notification
permission, register a service worker, or schedule reminders.

View file

@ -49,8 +49,10 @@ def _register_blueprints(app: Flask) -> None:
# Imports stay local so feature modules do not create circular imports. # Imports stay local so feature modules do not create circular imports.
from .auth import bp as auth_blueprint from .auth import bp as auth_blueprint
from .core import bp as core_blueprint from .core import bp as core_blueprint
from .settings import bp as settings_blueprint
from .tasks import bp as tasks_blueprint from .tasks import bp as tasks_blueprint
app.register_blueprint(core_blueprint) app.register_blueprint(core_blueprint)
app.register_blueprint(auth_blueprint, url_prefix="/auth") app.register_blueprint(auth_blueprint, url_prefix="/auth")
app.register_blueprint(tasks_blueprint, url_prefix="/tasks") app.register_blueprint(tasks_blueprint, url_prefix="/tasks")
app.register_blueprint(settings_blueprint, url_prefix="/settings")

View file

@ -1,2 +1,6 @@
"""User-settings feature package.""" from flask import Blueprint
bp = Blueprint("settings", __name__)
from . import routes # noqa: E402, F401

25
app/settings/forms.py Normal file
View file

@ -0,0 +1,25 @@
from flask_wtf import FlaskForm
from wtforms import BooleanField, SelectField, SubmitField
THEME_CHOICES = (
("auto", "Use system setting"),
("light", "Light"),
("dark", "Dark"),
)
NOTIFICATION_CHOICES = (
("quiet", "Quiet — only essential reminders"),
("moderate", "Moderate — occasional reminders"),
("alert", "Alert — more visible reminders"),
)
class PreferencesForm(FlaskForm):
theme = SelectField("Color theme", choices=THEME_CHOICES)
dyslexia_font = BooleanField("Use a dyslexia-friendly reading style")
completion_chime = BooleanField("Play a gentle completion chime")
notification_frequency = SelectField(
"Future reminder frequency",
choices=NOTIFICATION_CHOICES,
)
submit = SubmitField("Save preferences")

24
app/settings/routes.py Normal file
View file

@ -0,0 +1,24 @@
from flask import flash, redirect, render_template, url_for
from flask_login import current_user, login_required
from . import bp
from .forms import PreferencesForm
from .services import update_preferences
@bp.route("/", methods=["GET", "POST"])
@login_required
def index():
form = PreferencesForm(obj=current_user)
if form.validate_on_submit():
update_preferences(
current_user,
theme=form.theme.data,
dyslexia_font=form.dyslexia_font.data,
completion_chime=form.completion_chime.data,
notification_frequency=form.notification_frequency.data,
)
flash("Preferences saved.", "success")
return redirect(url_for("settings.index"))
return render_template("settings/index.html", form=form)

39
app/settings/services.py Normal file
View file

@ -0,0 +1,39 @@
from typing import Protocol
from app.extensions import db
THEMES = {"auto", "light", "dark"}
NOTIFICATION_FREQUENCIES = {"quiet", "moderate", "alert"}
class SettingsUser(Protocol):
theme: str
dyslexia_font: bool
completion_chime: bool
notification_frequency: str
def update_preferences(
user: SettingsUser,
*,
theme: str,
dyslexia_font: bool,
completion_chime: bool,
notification_frequency: str,
) -> None:
if theme not in THEMES:
raise ValueError("Unknown theme.")
if notification_frequency not in NOTIFICATION_FREQUENCIES:
raise ValueError("Unknown notification frequency.")
if not isinstance(dyslexia_font, bool) or not isinstance(
completion_chime, bool
):
raise ValueError("Accessibility preferences must be true or false.")
user.theme = theme
user.dyslexia_font = dyslexia_font
user.completion_chime = completion_chime
user.notification_frequency = notification_frequency
db.session.commit()

View file

@ -1,5 +1,5 @@
:root { :root {
color-scheme: light dark; color-scheme: light;
--lavender: #b8a9c9; --lavender: #b8a9c9;
--blue: #a8d8ea; --blue: #a8d8ea;
--yellow: #ffeaa7; --yellow: #ffeaa7;
@ -13,6 +13,20 @@
--space: clamp(1rem, 3vw, 2rem); --space: clamp(1rem, 3vw, 2rem);
} }
html[data-theme="dark"] {
color-scheme: dark;
--beige: #1e1e24;
--surface: #292932;
--text: #f5f0e8;
--muted: #c5bdcc;
--focus: #d4c0ee;
--border: #4b4b58;
}
html[data-theme="dark"] a {
color: #d9c5f1;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -25,6 +39,13 @@ body {
font: 1rem/1.6 system-ui, sans-serif; font: 1rem/1.6 system-ui, sans-serif;
} }
html[data-dyslexia="true"] body {
font-family: "Atkinson Hyperlegible", Verdana, Arial, sans-serif;
line-height: 1.75;
letter-spacing: 0.02em;
word-spacing: 0.06em;
}
a { a {
color: #57436c; color: #57436c;
} }
@ -592,6 +613,56 @@ input:focus-visible {
color: var(--muted); color: var(--muted);
} }
.settings-form {
max-width: 48rem;
}
.settings-section {
margin: 0 0 1.5rem;
padding: var(--space);
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--surface);
}
.settings-section legend {
padding-inline: 0.4rem;
font-size: 1.2rem;
font-weight: 750;
}
.preference-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
padding-block: 0.75rem;
}
.preference-row label {
font-weight: 700;
}
.preference-row p {
margin-block: 0.2rem 0;
color: var(--muted);
}
.preference-row input[role="switch"] {
flex: 0 0 auto;
width: 1.4rem;
height: 1.4rem;
accent-color: var(--lavender);
}
.reminder-placeholder {
margin-top: 1.25rem;
padding: 1rem;
border-left: 0.35rem solid var(--lavender);
border-radius: 0.6rem;
background: color-mix(in srgb, var(--lavender) 18%, var(--surface));
}
@keyframes gentle-arrival { @keyframes gentle-arrival {
from { from {
opacity: 0; opacity: 0;
@ -626,7 +697,8 @@ input {
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { html[data-theme="auto"] {
color-scheme: dark;
--beige: #1e1e24; --beige: #1e1e24;
--surface: #292932; --surface: #292932;
--text: #f5f0e8; --text: #f5f0e8;
@ -635,7 +707,7 @@ input {
--border: #4b4b58; --border: #4b4b58;
} }
a { html[data-theme="auto"] a {
color: #d9c5f1; color: #d9c5f1;
} }
} }
@ -691,4 +763,9 @@ input {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.preference-row {
align-items: flex-start;
gap: 1rem;
}
} }

View file

@ -113,3 +113,25 @@ const celebration = document.querySelector('[data-celebrate="true"]');
if (celebration && celebration.dataset.chime === "true") { if (celebration && celebration.dataset.chime === "true") {
playCompletionChime(); playCompletionChime();
} }
document.querySelectorAll("[data-reminder-capability]").forEach((panel) => {
const button = panel.querySelector("[data-check-reminders]");
const status = panel.querySelector("[data-reminder-status]");
if (!button || !status) return;
button.addEventListener("click", () => {
const supported =
"Notification" in window &&
"serviceWorker" in navigator &&
"PushManager" in window;
if (supported) {
status.textContent =
"This browser supports the foundations for reminders. " +
"Steady reminders remain disabled and no permission was requested.";
} else {
status.textContent =
"This browser does not currently expose all reminder capabilities. " +
"Your saved preference is unaffected.";
}
});
});

View file

@ -1,5 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en"
data-theme="{{ current_user.theme if current_user.is_authenticated else 'auto' }}"
data-dyslexia="{{ 'true' if current_user.is_authenticated and current_user.dyslexia_font else 'false' }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -19,6 +21,7 @@
<a href="{{ url_for('tasks.today') }}">Today</a> <a href="{{ url_for('tasks.today') }}">Today</a>
<a href="{{ url_for('tasks.index') }}">Tasks</a> <a href="{{ url_for('tasks.index') }}">Tasks</a>
<a href="{{ url_for('tasks.transfer') }}">Data</a> <a href="{{ url_for('tasks.transfer') }}">Data</a>
<a href="{{ url_for('settings.index') }}">Settings</a>
<span>Hi, {{ current_user.username }}</span> <span>Hi, {{ current_user.username }}</span>
<form action="{{ url_for('auth.logout') }}" method="post"> <form action="{{ url_for('auth.logout') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

View file

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Settings · Steady{% endblock %}
{% block content %}
<div class="page-heading">
<div>
<p class="eyebrow">Make Steady feel easier</p>
<h1>Settings</h1>
<p>Choose what supports you. Every preference can be changed again.</p>
</div>
</div>
<form class="settings-form" method="post" novalidate>
{{ form.hidden_tag() }}
<fieldset class="settings-section">
<legend>Appearance and reading</legend>
<div class="field">
{{ form.theme.label }}
{{ form.theme() }}
<p class="field__help">Auto follows your device's light or dark setting.</p>
</div>
<div class="preference-row">
<div>
{{ form.dyslexia_font.label }}
<p>Uses an installed accessible font when available, with a spacious local fallback and no external font request.</p>
</div>
{{ form.dyslexia_font(role="switch") }}
</div>
</fieldset>
<fieldset class="settings-section">
<legend>Feedback</legend>
<div class="preference-row">
<div>
{{ form.completion_chime.label }}
<p>Visual completion feedback remains available when sound is off.</p>
</div>
{{ form.completion_chime(role="switch") }}
</div>
</fieldset>
<fieldset class="settings-section">
<legend>Reminders</legend>
<div class="field">
{{ form.notification_frequency.label }}
{{ form.notification_frequency() }}
<p class="field__help">This prepares your preference for a future reminder service. Quiet is the default.</p>
</div>
<div class="reminder-placeholder"
data-reminder-capability
data-feature-enabled="{{ 'true' if config.FEATURE_BROWSER_REMINDERS else 'false' }}">
<strong>Browser reminders are not active yet</strong>
<p data-reminder-status>No notifications are scheduled, and Steady has not requested browser permission.</p>
<button class="button button--quiet" type="button" data-check-reminders>Check browser support</button>
<p class="field__help">This check does not request permission or subscribe your browser.</p>
</div>
</fieldset>
{{ form.submit(class="button") }}
</form>
{% endblock %}

View file

@ -25,6 +25,7 @@ class Config:
FEATURE_ADMIN = False FEATURE_ADMIN = False
FEATURE_API_V1 = False FEATURE_API_V1 = False
FEATURE_BROWSER_REMINDERS = False
class DevelopmentConfig(Config): class DevelopmentConfig(Config):

View file

@ -246,10 +246,10 @@ a,
- [x] Clear Completed Tasks (Undoable) [2] - [x] Clear Completed Tasks (Undoable) [2]
### Phase 5: Settings & Personalization (Week 8) ### Phase 5: Settings & Personalization (Week 8)
- [ ] Theme Toggle (Light/Dark/Auto) [2] - [x] Theme Toggle (Light/Dark/Auto) [2]
- [ ] Dyslexia-Friendly Font Toggle [2] - [x] Dyslexia-Friendly Font Toggle [2]
- [ ] Notification Frequency Settings [1] - [x] Notification Frequency Settings [1]
- [ ] Browser Reminders (Web Push API Placeholder) [2] - [x] Browser Reminders (Web Push API Placeholder) [2]
### Phase 6: Admin Dashboard (Placeholder - Future) [3] ### Phase 6: Admin Dashboard (Placeholder - Future) [3]
- [ ] Admin Route Placeholder [3] - [ ] Admin Route Placeholder [3]

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 4 — Import, Export & Backup - Current phase: Phase 5 — Settings & Personalization
- Status: Complete - Status: Complete
- Last updated: 2026-08-08 - Last updated: 2026-08-08
- Next phase: Phase 5 — Settings & Personalization - Next phase: Phase 6 — Admin Dashboard Placeholder
## Phase 1 — Core Infrastructure ## Phase 1 — Core Infrastructure
@ -242,6 +242,59 @@ 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 5 — Settings & Personalization
### Phase 5.1 — Settings domain and route
Added an independent settings Blueprint, validated preference form, and service for theme, readability typography, completion chime, and future reminder frequency. The service validates choices again outside the form and commits all preferences together. Browser reminders remain disabled behind a configuration flag.
Why: settings belong to their own feature boundary rather than authentication or task routes. Form validation supports the current web UI, while service-level validation protects future callers such as an API. One commit prevents partially saved preference combinations.
Next: apply preferences during server rendering and build the accessible settings interface.
### Phase 5.2 — Personalized rendering and interface
Added an accessible settings page grouped into appearance, feedback, and reminders. Every page now receives server-rendered `data-theme` and `data-dyslexia` attributes from the authenticated user, with anonymous users defaulting to system theme and standard typography. CSS supports explicit light/dark overrides, automatic system dark mode, and a local readability-focused font stack with increased spacing.
Why: server-rendered preferences work without JavaScript and prevent a flash of the wrong theme. The readability option uses installed system fonts rather than sending reading preferences to an external font provider. Native checkboxes retain keyboard and assistive-technology behavior while receiving switch semantics.
The reminder section clearly states that no service is active, no notifications are scheduled, and no permission has been requested.
Next: add a capability-only browser check that never requests permission or creates a subscription.
### Phase 5.3 — Browser-reminder placeholder
Added a user-triggered JavaScript capability check for the Notification, Service Worker, and Push APIs. It reports whether the browser exposes the necessary foundations while reiterating that reminders remain disabled. It does not call `requestPermission`, register a service worker, create a push subscription, or change the saved notification preference.
Why: notification permission prompts are disruptive and should occur only when a working feature can explain immediate value. Capability detection lets the future integration expose environmental readiness without creating side effects or false expectations.
Next: test preference persistence, user isolation, theme markup, sound behavior, invalid input, authentication, and reminder-placeholder guarantees.
### Phase 5.4 — Automated coverage
Added eight tests for authentication, inactive-reminder disclosure, preference persistence across pages, invalid-select rejection, cross-user isolation, Focus Mode chime suppression, service-level validation, and the absence of permission/subscription calls in JavaScript. The full suite now contains 45 passing tests.
Why: settings must affect consuming pages, not merely save successfully. The tests inspect global HTML attributes and Focus Mode output after persistence. The source-level negative assertion makes the placeholder's no-permission promise enforceable during future development.
Next: run regression, migration, routing, dependency, Python/JavaScript syntax, line-length, whitespace, and patch checks.
### Phase 5.5 — Final verification
Completed the full regression suite, Alembic model comparison, Flask route discovery, Python compilation, JavaScript syntax validation, dependency consistency, line-length and trailing-whitespace scans, and patch-format validation. No migration was required because Phase 1 already established all preference columns.
Why: personalization affects every rendered page and Focus Mode audio behavior, so the full suite is necessary to detect cross-feature regressions. Confirming zero schema drift proves the settings implementation matches the existing database contract.
Final results:
```text
45 tests passed in 4.62s
Alembic detected no pending model operations.
Flask discovered all expected routes, including settings.
Python and JavaScript syntax validation completed successfully.
No broken Python requirements were found.
Line-length, whitespace, and git diff checks completed successfully.
```
## Next Work ## Next Work
Phase 5 will expose the existing theme, dyslexia-font, completion-chime, and notification preferences through an authenticated settings feature. It will also add the browser-reminder placeholder without requesting notification permission prematurely. Phase 6 is intentionally a future-facing admin placeholder. The next decision should be whether to implement only a safely disabled admin Blueprint and role guard as designed, or defer all admin surface area until concrete user-management requirements exist.

View file

@ -0,0 +1,153 @@
from pathlib import Path
import pytest
from app.auth.services import create_user
from app.settings.services import update_preferences
from app.tasks.services import configure_focus, create_task
def login(client, email: str) -> None:
client.post(
"/auth/login",
data={"email": email, "password": "secure password"},
)
def settings_data(**overrides):
data = {
"theme": "auto",
"completion_chime": "y",
"notification_frequency": "quiet",
}
data.update(overrides)
return data
def test_settings_require_authentication(client):
response = client.get("/settings/")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
def test_settings_page_explains_inactive_reminders(client, db):
user = create_user("settings_user", "settings@example.com", "secure password")
login(client, user.email)
response = client.get("/settings/")
assert response.status_code == 200
assert b"Browser reminders are not active yet" in response.data
assert b"Steady has not requested browser permission" in response.data
assert b'data-feature-enabled="false"' in response.data
def test_preferences_persist_and_render_on_every_page(client, db):
user = create_user("theme_user", "theme@example.com", "secure password")
login(client, user.email)
response = client.post(
"/settings/",
data=settings_data(
theme="dark",
dyslexia_font="y",
completion_chime="",
notification_frequency="moderate",
),
follow_redirects=True,
)
db.session.refresh(user)
assert b"Preferences saved" in response.data
assert user.theme == "dark"
assert user.dyslexia_font is True
assert user.completion_chime is False
assert user.notification_frequency == "moderate"
assert b'data-theme="dark"' in response.data
assert b'data-dyslexia="true"' in response.data
home = client.get("/")
assert b'data-theme="dark"' in home.data
assert b'data-dyslexia="true"' in home.data
def test_invalid_select_values_do_not_change_preferences(client, db):
user = create_user("invalid_settings", "invalid-settings@example.com", "secure password")
login(client, user.email)
response = client.post(
"/settings/",
data=settings_data(theme="neon", notification_frequency="constant"),
)
db.session.refresh(user)
assert response.status_code == 200
assert b"Preferences saved" not in response.data
assert user.theme == "auto"
assert user.notification_frequency == "quiet"
def test_preferences_are_isolated_between_users(client, db):
first = create_user("first_settings", "first-settings@example.com", "secure password")
second = create_user("second_settings", "second-settings@example.com", "secure password")
login(client, first.email)
client.post(
"/settings/",
data=settings_data(theme="light", notification_frequency="alert"),
)
db.session.refresh(first)
db.session.refresh(second)
assert first.theme == "light"
assert first.notification_frequency == "alert"
assert second.theme == "auto"
assert second.notification_frequency == "quiet"
def test_disabled_chime_reaches_focus_timer_markup(client, db):
user = create_user("quiet_user", "quiet@example.com", "secure password")
update_preferences(
user,
theme="auto",
dyslexia_font=False,
completion_chime=False,
notification_frequency="quiet",
)
task = create_task(
user.id,
title="Quiet focus task",
description=None,
status="not_started",
priority="normal",
due_date=None,
)
configure_focus(task, timer_minutes=5, context=None, chunk_minutes=[])
login(client, user.email)
response = client.get(f"/tasks/{task.id}/focus")
assert b'data-chime="false"' in response.data
def test_settings_service_rejects_unknown_values_without_commit(db):
user = create_user("service_user", "service@example.com", "secure password")
with pytest.raises(ValueError, match="Unknown theme"):
update_preferences(
user,
theme="neon",
dyslexia_font=False,
completion_chime=True,
notification_frequency="quiet",
)
assert user.theme == "auto"
def test_reminder_placeholder_never_requests_permission():
script = Path("app/static/js/main.js").read_text(encoding="utf-8")
assert "requestPermission" not in script
assert ".subscribe(" not in script