diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 91ce075..924b679 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,8 +17,8 @@ jobs:
- uses: actions/checkout@v4
with:
repository: PapyrusReader/server
- # Combined account-deletion (#40) and support (#41) API snapshot revision.
- ref: cc69c75abfb5b48954fcbe0593f247ef32bb5244
+ # Includes the private profile-photo API from server PR #42.
+ ref: 235fd051e2bf72283bcc6da4ecb2d1860b6ef332
path: .server
- uses: astral-sh/setup-uv@v6
with:
diff --git a/_static/openapi.json b/_static/openapi.json
index e04a57d..371715b 100644
--- a/_static/openapi.json
+++ b/_static/openapi.json
@@ -1613,6 +1613,27 @@
"title": "Body_create_support_issue_v1_support_issues_post",
"type": "object"
},
+ "Body_upload_avatar_v1_users_me_avatar_post": {
+ "properties": {
+ "display_name": {
+ "maxLength": 100,
+ "minLength": 1,
+ "title": "Display Name",
+ "type": "string"
+ },
+ "file": {
+ "contentMediaType": "application/octet-stream",
+ "title": "File",
+ "type": "string"
+ }
+ },
+ "required": [
+ "file",
+ "display_name"
+ ],
+ "title": "Body_upload_avatar_v1_users_me_avatar_post",
+ "type": "object"
+ },
"Body_upload_book_cover_v1_books__book_id__cover_put": {
"properties": {
"file": {
@@ -15046,6 +15067,98 @@
]
}
},
+ "/v1/users/me/avatar": {
+ "post": {
+ "operationId": "upload_avatar_v1_users_me_avatar_post",
+ "requestBody": {
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "$ref": "#/components/schemas/Body_upload_avatar_v1_users_me_avatar_post"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "summary": "Save profile with private photo",
+ "tags": [
+ "Users"
+ ]
+ }
+ },
+ "/v1/users/me/avatar/{avatar_id}": {
+ "get": {
+ "operationId": "download_avatar_v1_users_me_avatar__avatar_id__get",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "avatar_id",
+ "required": true,
+ "schema": {
+ "format": "uuid",
+ "title": "Avatar Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "HTTPBearer": []
+ }
+ ],
+ "summary": "Download current private profile photo",
+ "tags": [
+ "Users"
+ ]
+ }
+ },
"/v1/users/me/change-password": {
"post": {
"description": "Change the user's password and revoke existing sessions.",
diff --git a/design/server-architecture.rst b/design/server-architecture.rst
index b7c5858..e1bf0e5 100644
--- a/design/server-architecture.rst
+++ b/design/server-architecture.rst
@@ -56,6 +56,26 @@ switch the active library without transferring guest data. Server profiles and
users also have separate database and media scopes. Becoming connected does not
turn a guest library into a synchronized account library.
+Profile photos
+--------------
+
+``POST /v1/users/me/avatar`` accepts a multipart JPEG/PNG ``file`` and
+``display_name``. The server bounds each photo to 5 MiB, checks its image
+signature, serializes writes on the owner row, and commits the photo reference
+and name together. Photos use a separate fixed allowance from library storage.
+The client resizes photos to at most 512 pixels on the longest side and removes
+source metadata before uploading.
+
+The existing ``avatar_url`` field stores an API-prefix-relative reference such
+as ``/users/me/avatar/{uuid}``. Fetching it requires the owner's access token
+against the configured server. External provider photo URLs remain supported,
+but are fetched without Papyrus credentials. Downloads are private and marked
+``no-store``. Replacing a photo removes the previous file after commit, and
+permanent account deletion removes the entire owner media directory, including
+photos. Failed writes retain the previous reference and file. This increment
+does not change database schemas or PowerSync projections. Deploy the updated
+server before clients that offer photo uploads.
+
Account deletion and recovery
-----------------------------
diff --git a/implementation/accessibility.rst b/implementation/accessibility.rst
new file mode 100644
index 0000000..a102a1e
--- /dev/null
+++ b/implementation/accessibility.rst
@@ -0,0 +1,194 @@
+Accessibility foundation audit
+==============================
+
+Scope and standard
+------------------
+
+This increment targets `WCAG 2.2 Level AA `_,
+with `WCAG2ICT `_ guidance for native software.
+WCAG2ICT interprets WCAG for non-web documents and software. It is not a separate
+conformance certification. Requirement :need:`NFR-5_2` remains open.
+
+The scope is client authentication, primary navigation, library search and
+selection, book details/editing, file import, Settings, and the shared controls,
+menus, dialogs, and sheets those journeys use. Passing the checks below is not
+full-app conformance. Reader-engine remediation, EPUB/PDF content, read-aloud,
+reading guides, and broader pages such as goals, statistics, and online catalogs
+are outside this increment's audit.
+
+Implementation contract
+-----------------------
+
+``PreferencesProvider`` stores device-wide interface enlargement, increased
+contrast, and bold text alongside the existing font and motion settings.
+Unsupported saved enlargement values fall back to System default. No server
+schema, synchronization payload, or database migration changes are involved.
+
+``AppAccessibilityScope`` retains the system text scaler and bold-text flag before
+applying overrides. Enlargement multiplies each result of the original scaler,
+rather than replacing its nonlinear curve with a linear estimate. System bold
+text and reduced motion cannot be disabled by an app preference. Light and dark
+high-contrast themes use Material's maximum contrast setting and build component
+themes from that palette. E-ink retains its separate palette and motion policy.
+OpenDyslexic is bundled with its license and applies to all theme variants.
+
+``ReaderSystemTextScope`` restores system text metrics and bold text around the
+embedded reader and reader panels. Reader preferences, book rendering settings,
+and stored locators are unchanged. Tests of the adapter boundary do not evaluate
+the accessibility of book content.
+
+Audit matrix
+------------
+
+Results refer to local verification on 24 October 2026. **Automated** means the
+specified regression scenario passes, not that every instance of the criterion
+has been evaluated. **Pending** means acceptance evidence is still required.
+Test paths below are relative to ``client/app/test``.
+
+.. list-table:: Criteria, scenarios, and evidence
+ :header-rows: 1
+ :widths: 19 28 35 18
+
+ * - Criteria
+ - Scenario and remediation
+ - Evidence
+ - Result / limit
+ * - 1.3.1, 4.1.2: information, relationships, names, roles, states
+ - Settings toggles expose one labeled state and one action, including
+ disabled state. Picker choices expose radio selection. Search and icon actions
+ have names. Decorative covers do not duplicate book titles.
+ - ``themes/app_accessibility_test.dart``,
+ ``widgets/profile/accessibility_settings_section_test.dart``,
+ ``widgets/accessibility_journeys_test.dart``.
+ - Automated for sampled controls. Native announcements pending.
+ * - 1.3.2, 2.4.3: reading and focus order
+ - Nested page navigators must not remove the primary navigation from the
+ accessibility tree. The shell provides a semantics boundary. Settings pickers use the root
+ modal layer so background navigation is hidden until dismissal.
+ - ``widgets/shell/navigation_counts_test.dart`` checks mobile and desktop
+ navigation remains exposed. Chrome accessibility-tree inspection found
+ the original defect and verified navigation after remediation. The
+ regression also checks picker selection and restoration of focus.
+ - Automated regression. Complete native reading-order review pending.
+ * - 1.4.3, 1.4.11: contrast
+ - Normal, dark, e-ink, and high-contrast palettes. Auth errors pair error
+ background with on-error foreground. Divider labels use readable text
+ color. Cover overlays retain their original translucent styling.
+ - Flutter text-contrast guideline checks for shared settings toggles,
+ book actions, and search in all five themes.
+ - Automated sample. Image content and every color/state combination are
+ not certified by these checks.
+ * - 1.4.4, 1.4.10: text enlargement and reflow
+ - Settings and auth at 320, 390, and 1280 logical pixels. System 200%
+ combined with app +100% retains 400% effective enlargement. Keyboard
+ inset does not hide auth submission. Navigation labels remain scalable.
+ - ``widgets/accessibility_journeys_test.dart``,
+ ``widgets/profile/accessibility_settings_section_test.dart``,
+ ``widgets/accessibility_library_journeys_test.dart``.
+ - Automated scenarios. Device font rendering and browser zoom still
+ require platform acceptance.
+ * - 2.1.1, 2.1.2, 2.4.7, 2.4.11: keyboard and focus
+ - Whole-row toggle activation, keyboard activation, reduced-motion focus
+ retention, explicit library action menus, keyboard-operable star ratings,
+ and dialog dismissal.
+ - ``themes/app_accessibility_test.dart``,
+ ``widgets/app_motion_control_test.dart``,
+ ``themes/eink_input_focus_test.dart``. Chrome keyboard inspection checks
+ dialog Escape dismissal and focus restoration.
+ - Automated shared controls and sampled browser behavior. Exhaustive
+ focus visibility/containment across native dialogs pending.
+ * - 2.5.1, 2.5.7: alternatives to gestures and dragging
+ - Desktop card actions appear on hover or keyboard focus and retain
+ programmatic labels. Compact cover overlays retain the original design.
+ File import offers Browse files alongside desktop drop. Sheets retain
+ explicit actions and dismiss behavior.
+ - ``widgets/library/book_card_test.dart``,
+ ``widgets/library/book_list_item_test.dart``,
+ ``widgets/add_book/book_import_sheet_test.dart``.
+ - Automated sampled actions. Touch card gesture alternatives and
+ compact overlay target sizing remain gaps. No reader gesture audit.
+ * - 2.5.8: target size
+ - Standalone touch controls use at least 48 logical-pixel hit areas even
+ when artwork is smaller. This product target is stricter than WCAG AA's
+ 24 CSS-pixel minimum, which has defined spacing and other exceptions.
+ - Flutter Android (48) and iOS (44) tap-target guidelines on sampled
+ settings, search, and book detail action controls. Compact cover
+ overlays retain their original 30-pixel targets and are an exception
+ to the product target, not covered by those guideline checks.
+ - Automated sample. Flutter logical pixels and CSS pixels/points are
+ platform-specific units, not a universal physical-size equivalence.
+ * - 1.4.1, 3.3.1, 3.3.2, 4.1.3: errors and status
+ - Invalid auth input has text errors and retains focus/input. Import
+ failures have a live region. Selection has checked/selected state.
+ Completion snackbars use Flutter's live-region semantics. Progress
+ updates are not individually announced.
+ - Auth validation tests, import state tests, existing completion/error
+ paths, and settings semantics tests.
+ - Automated behavior and code inspection. Actual announcement timing
+ with VoiceOver/TalkBack pending.
+ * - 3.3.8: accessible authentication
+ - Login permits password paste and provides email/password autofill hints.
+ Registration/recovery provide new-password hints. Visibility buttons
+ are named Show password / Hide password.
+ - ``widgets/accessibility_journeys_test.dart`` exercises paste and
+ visibility and inspects autofill hints.
+ - Automated. External password-manager integration pending.
+ * - System preferences and reader isolation
+ - Live system/app changes, nonlinear scaling, storage reload, malformed
+ preferences, fonts, contrast, bold text, reduced motion, and reader
+ boundary behavior.
+ - ``providers/accessibility_preferences_test.dart``,
+ ``themes/app_accessibility_test.dart``,
+ ``widgets/profile/accessibility_settings_section_test.dart``, existing
+ reader adapter and persistence tests.
+ - Automated. No reader-package or stored-position schema change.
+
+Platform acceptance
+-------------------
+
+Chrome on macOS was used to inspect the actual Flutter web accessibility tree,
+verify exposed primary navigation and filter-button roles, activate Settings
+controls, preview +100% text enlargement and OpenDyslexic, confirm preferences
+survive reload, and verify that the font picker hides background navigation
+and exposes radio choices. Escape dismissal and restoration of focus were checked
+from a confirmation dialog. Accessibility-tree inspection is not equivalent to
+listening to a screen reader.
+
+The following remain pending and must not be reported as passed:
+
+- VoiceOver on macOS/iOS: navigate representative auth, library, details/edit,
+ import, and Settings journeys. Check order, single announcements, live-region
+ timing, rotor access, and restoration after dialogs and sheets.
+- TalkBack on Android: repeat those journeys with system large text, bold text,
+ high contrast where available, and reduced motion. No Android target was
+ available in the local device inventory for this increment.
+- Native keyboard traversal and external password-manager acceptance on each
+ supported platform. Confirm focus is visible and not obscured at every step.
+- Broader app pages and EPUB/PDF content accessibility, including document
+ structure, alternative text, reading order, and reader-engine interactions.
+
+Local validation results
+------------------------
+
+- Full client suite: 1,927 passed, 19 skipped. The skipped tests are not counted
+ as accessibility acceptance evidence.
+- Client quality checks: formatting, analysis, and four web bootstrap tests.
+- Chrome regressions verify saved guest avatars decode after browser-storage
+ reload, Settings scrolling works over empty space, and short desktop sections
+ remain aligned at the top. Account form checks cover 320, 390, and 1280 logical
+ pixels at normal and 200% text size.
+- Release web compilation: passed, including Flutter's Wasm dry-run check.
+- Documentation: warning-as-error Sphinx build passed.
+- Chrome inspection: primary navigation and Settings controls exposed, live
+ enlargement observed, and a confirmation dialog dismissed with Escape with
+ focus restored. VoiceOver and TalkBack are not included in these results.
+
+Reproduction
+------------
+
+Run focused preference, theme, and journey tests, followed by the full client
+suite and ``tools/papyrus check client``. Build the web client with the pinned
+Flutter SDK and local dart defines. Build this documentation with ``make build``
+from ``docs``. Validation used the workspace's existing local reader path override, which was
+preserved. Remote CI was not run. Test success, compilation, browser inspection,
+and assistive technology acceptance are separate forms of evidence.
diff --git a/manual/index.rst b/manual/index.rst
index fdb1d38..30ab659 100644
--- a/manual/index.rst
+++ b/manual/index.rst
@@ -40,12 +40,46 @@ DRM-protected content are not supported. Book-level notes, annotations, and
bookmarks should not be confused with selecting and highlighting text in the
reader, which remains unfinished.
+Accessibility settings
+----------------------
+
+Open **Settings > Accessibility**. These preferences take effect immediately
+and are stored on this device, shared across guest and signed-in profiles.
+
+- **Interface text enlargement** adds 15%, 30%, 50%, or 100% to the system's
+ existing text scaling. **System default** adds no enlargement. The system's
+ nonlinear scaling curve is preserved. A live preview shows the result.
+- **Increase contrast** follows the system when off and forces increased
+ contrast when on, in either light or dark mode. E-ink uses its own contrast
+ treatment, so this control is disabled while that appearance is selected.
+- **Bold interface text** follows the system when off and adds bold interface
+ text when on, including editable fields and navigation.
+- **Interface font** offers **System default** and **OpenDyslexic** with a live
+ preview. This is a personal reading preference, not a claim that a particular
+ font improves reading for every person with dyslexia.
+- **Reduce animations** limits interface motion. System reduced motion is
+ always honored, and e-ink also reduces motion.
+
+Additional interface text enlargement and bold text do not change embedded
+reader text metrics. Reader controls retain system scaling and bold-text
+settings, and book rendering retains the reader's own preferences. These
+settings do not change books, saved positions, or reading preferences.
+
Accounts and offline access
---------------------------
An account connects a library to a Papyrus server for synchronization. Different
users and server profiles have separate libraries and cached media.
+In **Settings > Account**, users can edit their display name and choose
+a profile photo directly. **Save** applies both changes together. JPEG and PNG
+photos up to 5 MB and 25 megapixels are supported. Photos are resized and image
+metadata is removed before saving. In anonymous mode, the name and photo are
+saved on this device without a network connection and survive app restarts.
+They stay separate from signed-in profiles and are removed when the anonymous
+account is deleted. Signed-in profile changes are saved to the account's server.
+Email is shown for reference and cannot be edited here.
+
Books need to be available locally to read without a network connection.
Synchronization and online catalogs require connectivity. Full-library content
search is a product requirement and is not currently an available feature.
diff --git a/requirements/non-functional.rst b/requirements/non-functional.rst
index 5836d63..5adccf2 100644
--- a/requirements/non-functional.rst
+++ b/requirements/non-functional.rst
@@ -348,17 +348,26 @@ missing. See :doc:`index` for status definitions.
:status: open
:priority: P0
- The application shall be accessible to users with disabilities (WCAG 2.1 AA compliance).
+ The application shall target WCAG 2.2 Level AA, using WCAG2ICT guidance
+ to interpret the criteria for native software. Conformance requires a
+ complete evaluation of the declared scope, not only automated checks.
**Acceptance criteria:**
- Screen reader compatibility (TalkBack, VoiceOver)
- - Minimum color contrast ratio 4.5:1
- - All interactive elements keyboard accessible
- - Focus indicators visible
- - Text scalable up to 200%
+ - Text contrast of at least 4.5:1, or 3:1 for qualifying large text
+ - Meaningful control boundaries and state indicators have at least 3:1 contrast
+ - All functionality keyboard accessible, with visible, unobscured focus
+ - Text scalable to at least 200% without loss of content or functionality
- No information conveyed by color alone
- - Touch targets minimum 44x44 points
+ - Programmatic names, roles, states, and important status messages
+ - Accessible authentication permits paste and password-manager assistance
+ - Standalone client touch controls have at least 48 logical-pixel hit areas
+ (a product target exceeding WCAG 2.2 AA target-size minimums)
+
+ The :doc:`/implementation/accessibility` matrix records foundation coverage
+ and outstanding checks. This requirement remains open until the complete
+ application scope has been evaluated.
.. nfr:: RTL and internationalization
:id: NFR-5_3
diff --git a/technical/index.rst b/technical/index.rst
index 3a4f463..4054168 100644
--- a/technical/index.rst
+++ b/technical/index.rst
@@ -187,6 +187,7 @@ Related documents
/implementation/technologies
/implementation/github-workflow
+ /implementation/accessibility
.. toctree::
:maxdepth: 1