Skip to content

notifying changes in computational notebooks¤

blind and low vision jupyter users frequently ask for audible (non-visual) announcements about changes to the state of the document. this demand introduces a new visual and nonvisual feature to computational notebooks that notify any user of an update.

this feature request starts as a nonvisual componment, but quickly we realize that a visual component would be assistive to all users.

toast components are common patterns for these kinds of notifications, but there are accessibility challenges. one specific challenge is that the aria live message could be destroyed it is announced. this demands the need for a more persistent message, but not immortal. the time delay should be configurable on the order of minutes.

another notification pattern to consider would use the notifications api. this approach would be for escalated notifications, or tens of minute notifications. the os will handle the accessibility of these messages.

cf: https://github.com/jupyterlab/jupyterlab/issues/16145

jump to the demo

from nbconvert_a11y.tables import get_table, Config, new
shell.tangle.parser = midgy.language.python.Python()
%%
## creating a scenario that would be announced in a log

our approach maps [logging levels](https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels) - the visual log -
to [aria live](https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-1/) values - the nonvisual log[^log].
ultimately, we create a reference visual and non-visual logging component that will provide updates to assistive technology.

    from logging import FATAL, CRITICAL, ERROR, WARN, INFO, DEBUG
    FATAL / CRITICAL, ERROR, WARN, INFO, DEBUG;
    activities:\
is a list of actions that may be encountered during interactive computing.
the list provides a log level, message, and call to action.\

    =\
1. info
    : starting python kernel
    : - show kernel info
2. info
    : python kernel started
    : - execute cells
3. info
    : cell 2 executed
    : - cancel cell 2 execution
4. info
    : cell 2 finished successfully
    : - jump to cell output
5. info
    : cell 7 executed
    : - cancel cell 7 execution
6. error
    : cell 7 failed with TypeError
    : - jump to cell 7 traceback
7. critical
    : python kernel restarting
    : - queue cell execution
8. critical
    : python kernel restarted
    : - execute 

___

[^log]: `log` is an aria role meant to mimic this pattern. support is generally better for `role=status` or `role=alert`.

creating a scenario that would be announced in a log

our approach maps logging levels - the visual log - to aria live values - the nonvisual log[2]. ultimately, we create a reference visual and non-visual logging component that will provide updates to assistive technology.

from logging import FATAL, CRITICAL, ERROR, WARN, INFO, DEBUG
FATAL / CRITICAL, ERROR, WARN, INFO, DEBUG;
activities:\

is a list of actions that may be encountered during interactive computing. the list provides a log level, message, and call to action.\

=\
  1. info
    starting python kernel
    • show kernel info
  2. info
    python kernel started
    • execute cells
  3. info
    cell 2 executed
    • cancel cell 2 execution
  4. info
    cell 2 finished successfully
    • jump to cell output
  5. info
    cell 7 executed
    • cancel cell 7 execution
  6. error
    cell 7 failed with TypeError
    • jump to cell 7 traceback
  7. critical
    python kernel restarting
    • queue cell execution
  8. critical
    python kernel restarted
    • execute

log is an aria role meant to mimic this pattern. support is generally better for role=status or role=alert.

%%
    logging_roles:\
is a mapping of logging levels to aria live roles.\

    =\
```yaml
ERROR: assertive
CRITICAL: assertive
INFO: polite
WARN: none
DEBUG: none
```

these levels should be configured in the user interface especially to meet the needs of developers using assistive technologies.
logging_roles:\

is a mapping of logging levels to aria live roles.\

=\
ERROR: assertive
CRITICAL: assertive
INFO: polite
WARN: none
DEBUG: none

these levels should be configured in the user interface especially to meet the needs of developers using assistive technologies.

creating a dataframe about our synthetic scenario¤

%%
<details>
<summary>dataframe tidying</summary>

```ipython
df = pipe(
    activities, partial(re.sub, "\s+:", ":"), 
    partial(re.sub, "[0-9].\s+|\s-\s", ""), str.splitlines, Series
).str.partition(": ")[[0, 2]].rename(columns=pipe(
    "level message".split(), partial(zip, range(0, 3, 2)), dict, 
))

df["timestamp"] =df.level.apply(
    lambda x: random.randint(1, 10)
).cumsum().pipe(
    pandas.to_datetime, unit="s"
)
df = df.set_index("timestamp")
df["level"] = df["level"].str.upper()

df["live"] = df["level"].apply(logging_roles.get)
df = df.drop(columns="message").assign(
    **df.message.str.partition(":")[[0, 2]].rename(columns=pipe(
        "message action".split(), partial(zip, range(0, 3, 2)), dict, 
    ))
)
df["id"] = [uuid.uuid4() for _ in range(len(df))]

pipe(df, partial(get_table, config=Config(), caption="information needed to construct an accessible table representation"), display)
```
</details>
information needed to construct an accessible table representation
timestamp level live message action id
1970-01-01 00:00:03 INFO polite starting python kernel show kernel info b2959c31-624c-4804-bbb2-3f4f99592bc9
1970-01-01 00:00:05 INFO polite python kernel started execute cells 0ee445a3-d6fb-4e66-9e75-f8e07fdc2865
1970-01-01 00:00:13 INFO polite cell 2 executed cancel cell 2 execution c37265dd-7e31-4d24-83e3-08a144c60463
1970-01-01 00:00:23 INFO polite cell 2 finished successfully jump to cell output 016d57fd-db18-4e1d-b301-710270df9967
1970-01-01 00:00:29 INFO polite cell 7 executed cancel cell 7 execution 6820c9f3-bd35-4198-b5b7-d7cce12dbdf8
1970-01-01 00:00:35 ERROR assertive cell 7 failed with TypeError jump to cell 7 traceback 7a3417d7-35fa-4dbf-97a9-640edf06cf25
1970-01-01 00:00:36 CRITICAL assertive python kernel restarting queue cell execution 329deeaf-943a-4457-a524-d679d9119117
1970-01-01 00:00:37 CRITICAL assertive python kernel restarted execute f3ed9dc3-ffaf-441f-bfef-2a52b0df4581
min CRITICAL assertive cell 2 executed cancel cell 2 execution 016d57fd-db18-4e1d-b301-710270df9967
max INFO polite starting python kernel show kernel info f3ed9dc3-ffaf-441f-bfef-2a52b0df4581
dataframe tidying
df = pipe(
    activities, partial(re.sub, "\s+:", ":"), 
    partial(re.sub, "[0-9].\s+|\s-\s", ""), str.splitlines, Series
).str.partition(": ")[[0, 2]].rename(columns=pipe(
    "level message".split(), partial(zip, range(0, 3, 2)), dict, 
))

df["timestamp"] =df.level.apply(
    lambda x: random.randint(1, 10)
).cumsum().pipe(
    pandas.to_datetime, unit="s"
)
df = df.set_index("timestamp")
df["level"] = df["level"].str.upper()

df["live"] = df["level"].apply(logging_roles.get)
df = df.drop(columns="message").assign(
    **df.message.str.partition(":")[[0, 2]].rename(columns=pipe(
        "message action".split(), partial(zip, range(0, 3, 2)), dict, 
    ))
)
df["id"] = [uuid.uuid4() for _ in range(len(df))]

pipe(df, partial(get_table, config=Config(), caption="information needed to construct an accessible table representation"), display)

accessible table components¤

create components that convert the wide data above to a user interface component that can be tested.

# https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time

def get_timestamp(s):
    t = new("time", attrs=dict(datetime=s.name.isoformat()))
    # t.append(s.name.strftime('%Y-%m-%d %X'))
    t.append(s.name.strftime('%X'))
    return t

there are a lot of shenangins to consider when implementing aria live across browsers. we take an html5 forward design approach and restrict ourselves to the nuance around the native output element; we take heavy advice from scott o'hara's html output analysis.

def get_message(s):
    object = new("output", attrs=dict(
        role="status", id=s.loc["id"], 
        onload="alert(11);",  **{"aria-live": s.live, "data-message": s.message}
    ))
    # object.append(s.message)
    return object

aria live seems most reliable when the text is injected directly into the element. to ensure aria-live announces we must create an output element THEN dynamically text content to trigger an announcement.

def get_script(s):
    script = new("script")
    script.append(F""" setTimeout(() =&gt; {{
            var out = document.getElementById("{s.id}");
            out.parentElement.parentElement.classList.remove("nv");
            out.textContent = out.dataset.message}}, {s.name.second * 1000})""")
    return script

get_priority is the visual log level.

def get_priority(s):
    label = new("label", attrs={"for": s.loc["id"]})
    label.append(s.loc["level"])
    return label

a visual convention with toast notifications is to provide a call to action with a notification. we couple these visual concepts with non visual concepts. jupyter has toast notifications, but they would be compliments the activity log. the activity log is the domain of visual and nonvisual actions.

def get_action(s):
    # might return a link or a button
    butt = new("a" if s.action.startswith(("jump",)) else "button")
    butt.append(s.action)
    return butt

an example activity log table that reveals it self and has aria live capabilities. this is a first demonstration of a more robust activity log for visual and nonvisual users. the table operates as a keyboard navigable component on assistive technology.

table = (t := df.apply(
    lambda s: Series(dict(
        priority=get_priority(s),
        message=get_message(s),
        action=get_action(s), 
        timestamp=get_timestamp(s), script=get_script(s)
    )), axis=1
)).reset_index(drop=True).set_index("timestamp").pipe(get_table, id="log", **{"aria-labelledby": "log-nm"})
[x.attrs.update(**{"class": "nv"}) for x in table.select("tr")];

the table doesn't need to be seen, but it can not be removed from the visual domain otherwise announcements will be suppressed. we can use the details tag and the non visual css styling to address this. by wrapping all of the following in a region we expose a landmark for the activity log enhancing discovery.

section = new("section", attrs={"aria-labelledby": "log-nm"})
section.append(details := new("details", attrs=dict(open="")))
details.append(summary := new("summary", attrs=dict(id="log-nm")))
summary.append("activity log")
section.append(table)
section
activity log
timestamp priority message action script
jump to cell output
jump to cell 7 traceback

conclusion¤

it might be possible to create toast like behavior with persistance using a table. the row of a table could have css applied to appear as a toast message before returning back to its table.

the number of messages will grow, and it will be necessary to cull and throttle the messages. it will be important to do research into disabling/enabling aria live by changing attributes; in other words, it might be possible, in some stacks, to suppress a queued live message.

styling¤

%%
<figure>
<figcaption>
<a href="https://www.tpgi.com/the-anatomy-of-visually-hidden/">visually hidden css</a></figcaption>
<blockquote cite="https://www.tpgi.com/the-anatomy-of-visually-hidden/">

    display\
```css
details:not([open]) + table, .nv, .non-visual {
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
/** demo button **/
a[href="#log"] {
    display: block;
    height: 300px;
    width: 400px;
    background: -moz-element(#log);
    background-size: content;
    background-repeat: no-repeat;
    font-size: 4rem;
}
```
</blockquote>
</figure>
visually hidden css
display\
details:not([open]) + table, .nv, .non-visual {
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
/** demo button **/
a[href="#log"] {
    display: block;
    height: 300px;
    width: 400px;
    background: -moz-element(#log);
    background-size: content;
    background-repeat: no-repeat;
    font-size: 4rem;
}