Skip to content
Open
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
73b33d1
a11y: #5337 add opendyslexic font
StephDriver Jun 9, 2026
255a5ec
a11y: #5337 add user reading options bar
StephDriver Jun 9, 2026
dfe9545
a11y: #5337 add reading options bar to articles
StephDriver Jun 9, 2026
a104406
a11y: #5337 reading options bar styling
StephDriver Jun 9, 2026
18d5abe
a11y: #5337 remove missing css link
StephDriver Jun 11, 2026
52297fa
a11y #5337 make reading bar generic
StephDriver Jun 12, 2026
ede9a0b
a11y: #5337 add reading bar to cms pages
StephDriver Jun 12, 2026
f8c741e
a11y: #5337 add reading bar to a11y info page
StephDriver Jun 12, 2026
b251240
a11y: #5337 extend reading bar for multiple regions
StephDriver Jun 12, 2026
0f294f6
a11y: #5337 add reading bar to news items
StephDriver Jun 12, 2026
65b7bf5
a11y: #5337 add reading bar to preprints
StephDriver Jun 12, 2026
8508928
fix: preprint html not displaying in OLH
StephDriver Jun 12, 2026
151a379
a11y: #5337 text-formats get colour padding
StephDriver Jun 12, 2026
5402b69
a11y: #5337 create reading options bar dev doc
StephDriver Jun 12, 2026
dafca6f
a11y: #5337 smooth text format colour transitions
StephDriver Jun 12, 2026
e332f4f
a11y #5337 layer reading bar behind nav dropdowns
StephDriver Jun 12, 2026
9a4c295
a11y: #5337 combine with a11y-mode for generic persistence
StephDriver Jun 15, 2026
10c1024
a11y: #5337 single source for text format options
StephDriver Jun 15, 2026
3d4aad3
a11y:#5337 paint custom text colours on page load
StephDriver Jun 15, 2026
947f73a
a11y: #5337 toggle reading bar visibility
StephDriver Jun 16, 2026
efcfdbd
a11y #5337 fix select widths to avoid reflow on change
StephDriver Jun 16, 2026
43eae57
fix: consistent focus visible style on a11y menu
StephDriver Jun 16, 2026
b71942a
a11y: #5337 reading bar focus visible
StephDriver Jun 16, 2026
3f0a61b
a11y: #5337 reading opt drop-down above cms toc
StephDriver Jun 16, 2026
54e77ea
a11y: #5337 show and hide the reading options
StephDriver Jun 16, 2026
6a8ab05
a11y: #5337 hide custom colour picker until future work
StephDriver Jun 16, 2026
72a8177
a11y: #5337 aria to reflect reading options current state
StephDriver Jun 18, 2026
8625192
a11y: #5337 handle focus on show and hide of reading options
StephDriver Jun 18, 2026
c770033
a11y: #5337 reading options show hide on mobile
StephDriver Jun 19, 2026
53e44f8
fix: #5337 olh mobile reading options overlaped other content
StephDriver Jun 19, 2026
069d6e6
a11y: #5337 reading bar dropdowns keyboard accessible
StephDriver Jun 19, 2026
aba1d3b
a11y: #5337 reading bar changes announced to screen reader
StephDriver Jun 19, 2026
1fe38d0
a11y: #5337 reading bar update colours and announcements
StephDriver Jun 19, 2026
b028fce
fix: #5337 reading bar olh mobile style like other themes
StephDriver Jun 19, 2026
48731a6
a11y: #5337 user toggle for draw attention highlighting
StephDriver Jun 22, 2026
0613895
minor: #5337 ruff format
StephDriver Jun 22, 2026
262a2ae
a11y: #5337 replace email logic from before branch
StephDriver Jun 22, 2026
0814d21
a11y: #5337 style tables for reading colour options
StephDriver Jun 22, 2026
c2bf4bb
a11y: #5337 style blockquotes for reading colour options
StephDriver Jun 22, 2026
3f072df
a11y: #5337 style olh mobile reading bar
StephDriver Jun 22, 2026
c1e607c
a11y: #5337 reading options tests to use registry values
StephDriver Jun 22, 2026
b749e27
a11y: #5337 add migration to store text format preferences
StephDriver Jun 23, 2026
07a6b61
a11y: #5337 tidy reading options bar doc
StephDriver Jun 23, 2026
7f91c43
fix: make webkit scrollbars visible
StephDriver Jul 10, 2026
74c71eb
a11y: #5337 readingbar article table styles
StephDriver Jul 10, 2026
e62bf8c
a11y: #5337 table-modal added to readingbar
StephDriver Jul 10, 2026
6cbf468
a11y #5337 wrap clean mobile doi-links
StephDriver Jul 10, 2026
58f5263
a11y #5337 clean mobile text resizing styles
StephDriver Jul 10, 2026
9ed7b69
a11y: #5337 tidy doc at end of readingbar work
StephDriver Jul 10, 2026
6d93c04
a11y: #5337 remove z-index stack notes from during development
StephDriver Jul 10, 2026
0d3fd67
a11y: #5337 consolidate reading bar tests
StephDriver Jul 10, 2026
00fce79
a11y: #5337 resolve migration conflict
StephDriver Jul 10, 2026
1c648d7
Revert "a11y #5337 wrap clean mobile doi-links"
StephDriver Jul 10, 2026
9ed6641
a11y: #5337 wrap long links when reading options cause overflow
StephDriver Jul 10, 2026
b67f290
doc: #5337 user preference storage across login and out
StephDriver Jul 10, 2026
29fb80c
a11y: #5337 set reading-options-bar default to hidden
StephDriver Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions docs/md/reader-preference-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Reader Preference Storage

Janeway persists small, reader-chosen preferences (e.g. **accessibility mode**,
the **reading options bar**'s font/colour/size) the same way: on the
`Account` for logged-in readers, in the session for anonymous ones, with the
anonymous choice carried onto the account on login and carried back into the
session on logout. This generic store lives in `src/core/logic.py` and was
generalised from the original accessibility-mode work so a new preference
doesn't have to reinvent that persistence.

> [!WARNING]
> This store is for small, low-stakes reader-facing choices only — never
> secrets. Values are plainly loaded: the reading-options bar template seeds
> its stored value straight into the page as visible JSON (`json_script`),
> and even a preference that's never rendered into a page still sits in
> Django's session and in a plain `Account` field, readable by anyone with
> session or admin/model access. Don't put tokens, keys, or anything
> sensitive through this mechanism.

## How it works

A preference is one `PreferenceDescriptor`: a session key, the matching
`Account` field, the "unset" default, and a `clean` function that sanitises a
raw value (identity if you don't pass one).

```python
ACCESSIBILITY_MODE_DESCRIPTOR = PreferenceDescriptor(
session_key="accessibility_mode",
account_field="accessibility_mode",
default=False,
clean=bool,
)
```

## Login and logout behaviour

### Principles
- **Anonymous default is off** (or whatever `default` is registered as).
- **On login**, if the reader explicitly changed the preference during the
anonymous session, that choice wins and updates the account. If they never
touched it, the account's existing preference is applied instead. This
matters so a reader who switches a preference on to use the site doesn't
lose it just by logging in.
- **The preference is sticky across logout** — logging out never changes the
current value; the account simply keeps whatever it last had saved. That
isn't necessarily the same value the anonymous session held before they
first logged in, since it could have been changed again while logged in.

### State Storage
| Reader state | Storage location | Lifetime |
| --- | --- | --- |
| Anonymous, never touched it | nowhere — reads fall back to `default` | n/a |
| Anonymous, explicitly changed it | `request.session[session_key]` | Until the session expires or the browser is closed, or until the reader logs in (then migrated onto the account and cleared) |
| Logged in | `Account.<account_field>` | Indefinite — follows the reader across devices and sessions until changed again |
| After logout | re-seeded into the fresh session from the account's last value, only if it isn't `default` | Until the session expires/browser closes, or until the reader logs in again |



### Login: value changes

| Anonymous session | Account before | Value after login | Account after login | Rule |
| --- | --- | --- | --- | --- |
| Untouched | Off | Off | Off | Nothing to apply |
| Untouched | On | On | On | Account preference applied |
| Explicit On | Off | On | On ← updated | Explicit action wins and writes back |
| Explicit On | On | On | On | Consistent |
| Explicit Off | Off | Off | Off | Consistent |
| Explicit Off | On | Off | Off ← updated | Explicit Off wins and writes back, even though it *disables* a previously-enabled account preference — this is the case naive "value equals default, so treat it as untouched" logic would get wrong; what matters is that the session *key is present*, not what it's set to |

### Logout: value changes

Simpler than login — no explicit/untouched distinction, since there's no new
choice being made. Whatever the account holds is just carried forward.

| Mode at logout | Account | Value after logout | Account after logout | Rule |
| --- | --- | --- | --- | --- |
| On | On | On | On | Stays on — sticky |
| Off | Off | Off | Off | Stays off |

## Adding a new preference

1. **Add the field** to `Account` (e.g. a `BooleanField`, `CharField`, or
`JSONField`) and its migration.
2. **Write a `clean` function** if the value needs sanitising beyond a plain
type coercion — see `clean_text_format_preferences()` for a dict example
that drops anything unrecognised. A bare bool can just pass `clean=bool`.
3. **Register a descriptor** in `src/core/logic.py` and add it to
`PREFERENCE_DESCRIPTORS`:

```python
MY_PREFERENCE_DESCRIPTOR = PreferenceDescriptor(
session_key="my_preference",
account_field="my_preference",
default=False, # must match the value logout treats as "nothing to reseed"
clean=my_clean_function,
)
PREFERENCE_DESCRIPTORS = [
ACCESSIBILITY_MODE_DESCRIPTOR,
TEXT_FORMAT_PREFERENCES_DESCRIPTOR,
MY_PREFERENCE_DESCRIPTOR,
]
```

4. **Read it** with `get_preference(request, MY_PREFERENCE_DESCRIPTOR)`
5. **Write your own save view.**
6. **Tests.** The full login/logout truth table (untouched vs. explicit
on/off/reset) is already exercised once, generically, against the bool
case in `AccessibilityModePersistenceTests`. Add tests only for what's
actually specific to your value

### Reserved consideration: `default`

`default` isn't just a read fallback — `reseed_session_preferences` only
re-seeds a session key when the account value is `!= default`, so it also
defines what "nothing set" means for logout.

## See also

`docs/md/reading-options-bar.md` is a worked example of a preference (a dict
of font/colour/size choices) built on top of this store, including its own
save endpoint and JS-side seeding of the stored value into the page.
114 changes: 114 additions & 0 deletions docs/md/reading-options-bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Reading Options Bar

The **Reading Options bar** is a control bar that lets a reader adjust how
text is displayed — e.g. **font**, **text size**, **colour scheme**.
On article pages it also carries the article-only options (e.g email author).

## Adding the *reading options bar* to a page template
This bar may be added to pages which extend a `base.html` template.

1. **Include the bar** where you want the controls to appear.

```django
{% block reading_options_bar %}
{% include "common/elements/journal/reading_options_bar.html" %}
{% endblock reading_options_bar %}
```

2. **Mark the elements** you want it to affect with the class `.text-format-region`.
This will affect all child-elements of the marked element.
You may markup multiple elements on the same page.

The reading options are intended to assist with focusing on the main text on a page.
Do not markup the side content, `nav` or other *distractions* without good reason.

## Extending the reading text-format options

### Adding a new font

If the font is a web-safe stack (e.g. system serif/sans), only step 3 is required.

1. Put the font files in `src/static/common/fonts/<family>/` (`.woff2`/`.woff`
preferred; `.otf`/`.ttf` work too). Include the font's licence file.
2. Add an `@font-face` block to `src/static/common/css/text_readability.css`:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this file exist?


```css
@font-face {
font-family: "MyFont";
src: url("../fonts/myfont/MyFont-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
```

Add a second block with `font-weight: bold` for the bold face if you have one.

3. Add an entry to in `src/core/text_format.py`. The key is an arbitrary slug

```python
FONTS = {
# ...existing entries...
"myfont": {"label": _("My Font"), "value": '"MyFont", Verdana, sans-serif'},
}
```

### Adding a new colour scheme

A scheme is a **pair of two colours**. The Light/Dark toggle decides which is
background and which is foreground. So pick two colours with **good contrast against each
other** or they will not be readable!

**For WCAG 2.2AA compliance go for 1:4.5 minimum.**

```python
COLOUR_SCHEMES = {
# ...existing entries...
"pink": {"label": _("Pink"), "light": "#ffd6e8", "dark": "#3a0b22"},

# ... customise should remain as the final entry
"customise": {"label": _("Customise"), "light": None, "dark": None},
}
```

#### Reserved colour scheme keys

- **`default`** is special-cased in `applyPreferences()`: in **Light** it applies
*no* override (the genuine theme colours show); in **Dark** it applies a dark
mode (`#1a1a1a` background / `#ffffff` text). Selecting `default` also resets
the mode to Light and re-shows italics. Your new schemes do **not** get this
special-casing — they always apply their pair.
- **`customise`** reads the two `<input type="color">` values via
`setCustomLight` / `setCustomDark`; the Light/Dark swap applies to it too.

### Adding a new setting
Adding a brand-new *kind* of setting (not just another font or colour) is three
matching edits:
- an update to the `reading_options_bar.html` template
- a field on the JS `state` object,
- a validation branch in`clean_text_format_preferences()`
- tests to cover the new setting. If it's a toggle then it only needs to be added to the `TOGGLE_FLAGS` in `src/core/logic.py` and it will be tested with the other toggles.

## How preferences persist

A reader's choices are stored **server-side** and restored on the next page load
as per the accessibility mode preference:

- **Logged-in readers** — on the `Account.text_format_preferences` JSON field.
- **Anonymous readers** — in the session. On login, a choice the reader made
while anonymous is written back to their account (an untouched session leaves
the account value standing).

The bar template seeds the stored values into the page as JSON
(`{{ text_format_preferences|json_script:"tf-preferences" }}`). The JS reads them
in `loadPreferences()` before the first apply, and `savePreferences()` POSTs the
whole `state` object back with a short wait so that rapid changes collapse into a single request, and failing silently if it doesn't succeed to the `save_text_format_preferences` endpoint.

### Validation
The save endpoint runs every value through
`clean_text_format_preferences()` in `src/core/logic.py`, which **drops anything
it doesn't recognise**. Validation derives its allow-lists from the
`src/core/text_format.py` registry — the single source of truth.

Note: `serialiseState()` already sends every non-function property of `state`
automatically.
42 changes: 41 additions & 1 deletion src/core/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
__license__ = "AGPL v3"
__maintainer__ = "Birkbeck Centre for Technology and Publishing"

from django.utils.encoding import force_str
from django.utils.safestring import mark_safe

from cms import models as cms_models
from core import logic
from core import logic, text_format
from utils.logic import get_janeway_version


Expand Down Expand Up @@ -91,3 +94,40 @@ def version(request):
def accessibility_mode(request):
"""Expose the resolved accessibility-mode flag in template context."""
return {"accessibility_mode_active": logic.accessibility_mode_active(request)}


def text_format_preferences(request):
"""Expose the reader's stored reading-options preferences to templates.

``text_format_initial_style`` is server-rendered CSS that paints the reading
region in a restored colour scheme on first paint, so there is no flash of
the default colour before text_readability.js runs. Safe: the helper only
emits validated hex values.
"""
preferences = logic.text_format_preferences(request)
return {
"text_format_preferences": preferences,
"text_format_initial_style": mark_safe(
text_format.initial_region_colour_css(preferences)
),
}


def text_format_options(request):
"""Expose the reading-options registry (fonts, schemes, size bounds)."""

def resolve(entries):
return {
slug: {**entry, "label": force_str(entry["label"])}
for slug, entry in entries.items()
}

options = {
"fonts": resolve(text_format.FONTS),
"schemes": resolve(text_format.COLOUR_SCHEMES),
"sizeBounds": text_format.DEFAULT_SIZE_BOUNDS,
"strings": {
key: force_str(value) for key, value in text_format.ANNOUNCEMENTS.items()
},
}
return {"text_format_options": options}
5 changes: 5 additions & 0 deletions src/core/include_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,11 @@
core_views.toggle_accessibility_mode,
name="toggle_accessibility_mode",
),
re_path(
r"^reading-options/preferences/$",
core_views.save_text_format_preferences,
name="save_text_format_preferences",
),
re_path(
r"^jsi18n/$",
cache_page(60 * 60, key_prefix="jsi18n_catalog")(JavaScriptCatalog.as_view()),
Expand Down
2 changes: 2 additions & 0 deletions src/core/janeway_global_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@
"core.context_processors.navigation",
"core.context_processors.version",
"core.context_processors.accessibility_mode",
"core.context_processors.text_format_preferences",
"core.context_processors.text_format_options",
"django_settings_export.settings_export",
"django.template.context_processors.i18n",
],
Expand Down
Loading
Loading