Repository: freedomofpress/securedrop

Issue:
 semantic (HTML5/ARIA) page structure in Source Interface
## Description

[Accessibility Lab recommendations](https://docs.google.com/document/d/1jZlKco2YgOrrI4PmTkG_Q8lKuV5VxCds/edit#) include:

- `h1`...`hn` hierarchy
- HTML5 section elements
- ARIA roles ("landmarks"), labels, and descriptions

Our findings include:

- moving decorative `img` elements to CSS styles on semantic elements 

## User Research Evidence

[WCAG 1.3.1](https://www.w3.org/TR/UNDERSTANDING-WCAG20/content-structure-separation-programmatic.html) per #5972.

## Progress

To invert the way things are usually done :-):

1. [x] **First pass:**  Refactor for semantic markup (test in developer tools and screen-readers)
    - [x] decorative `img`/`hr`/etc. elements: mark as `FIXME`s to move to CSS styles on semantic elements 
    - [x] linters pass
    - [x] tests pass
2. [x] **Second pass:**  Audit in validators and accessibility tools ([axe](https://www.deque.com/axe/), [WAVE](https://wave.webaim.org/)), e.g.:
    - `h1`...`hn` hierarchy
    - HTML5 section elements
    - ARIA roles ("landmarks"), labels, and descriptions
3. [x] **Third pass:**  CSS and images broken in first pass (test visually in browser)
    - [x] `FIXME`s
    - [x] visual review

| Path | First Pass | Second Pass | Third Pass |
| --- | --- | --- | --- |
`securedrop/source_templates` | ✓ | ✓ | ✓ |
`├── banner_warning_flashed.html` | ✓ | ✓ | ✓ |
`├── base.html` | ✓ | ✓ | ✓ |
`├── error.html` | ✓ | ✓ | ✓ |
`├── first_submission_flashed_message.html` | ✓ | ✓ | ✓ |
`├── flashed.html` | ✓ | ✓ | ✓ |
`├── footer.html` | ✓ | ✓ | ✓ |
`├── generate.html` | ✓ | ✓ | ✓ |
`├── index.html` | ✓ | ✓ | ✓ |
`├── locales.html` | ✓ | ✓ | ✓ |
`├── login.html` | ✓ | ✓ | ✓ |
`├── logout.html` | ✓ | ✓ | ✓ |
`├── lookup.html` | ✓ | ✓ | ✓ |
`├── next_submission_flashed_message.html` | ✓ | ✓ | ✓|
`├── notfound.html` | ✓ | ✓ | ✓ |
`├── session_timeout.html` | ✓ | ✓ | ✓  |
`├── tor2web-warning.html` | ✓ | ✓ | ✓ |
`├── use-tor-browser.html` | ✓ | ✓ | ✓ |
`└── why-public-key.html` | ✓ | ✓ | ✓ |

## Specific cases (could be filed as separate issues)

- [x] `securedrop/source_templates/generate.html`: `p.explanation` should be associated with `p#codename`



Discussion:
@zenmonkeykstop, I'm starting work on this with the assumption that this and #5987 will each take the form of an omnibus PR consisting of:

1. refactored templates; and
2. stylesheets updated accordingly, with the goal of making it as close as possible to a pixel-perfect markup-only refactoring, with no (visually apparent) UI/UX consequences.

Given this scope, is there a particular sequence or structure you'd like to see the PR follow for ease of review?

Resolving patch:
diff --git a/securedrop/source_app/forms.py b/securedrop/source_app/forms.py
--- a/securedrop/source_app/forms.py
+++ b/securedrop/source_app/forms.py
@@ -24,8 +24,9 @@ class LoginForm(FlaskForm):
 
 
 class SubmissionForm(FlaskForm):
-    msg = TextAreaField("msg", render_kw={"placeholder": gettext("Write a message.")})
-    fh = FileField("fh")
+    msg = TextAreaField("msg", render_kw={"placeholder": gettext("Write a message."),
+                                          "aria-label": gettext("Write a message.")})
+    fh = FileField("fh", render_kw={"aria-label": gettext("Select a file to upload.")})
 
     def validate_msg(self, field: wtforms.Field) -> None:
         if len(field.data) > Submission.MAX_MESSAGE_LEN:
diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py
--- a/securedrop/source_app/main.py
+++ b/securedrop/source_app/main.py
@@ -157,7 +157,7 @@ def submit() -> werkzeug.Response:
             for field, errors in form.errors.items():
                 for error in errors:
                     flash(error, "error")
-            return redirect(url_for('main.lookup'))
+            return redirect(f"{url_for('main.lookup')}#flashed")
 
         msg = request.form['msg']
         fh = None
@@ -172,7 +172,7 @@ def submit() -> werkzeug.Response:
                       "error")
             else:
                 flash(gettext("You must enter a message."), "error")
-            return redirect(url_for('main.lookup'))
+            return redirect(f"{url_for('main.lookup')}#flashed")
 
         fnames = []
         journalist_filename = g.source.journalist_filename
@@ -235,7 +235,7 @@ def submit() -> werkzeug.Response:
 
         normalize_timestamps(g.filesystem_id)
 
-        return redirect(url_for('main.lookup'))
+        return redirect(f"{url_for('main.lookup')}#flashed")
 
     @view.route('/delete', methods=('POST',))
     @login_required


Test patch:
diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py
--- a/securedrop/tests/functional/source_navigation_steps.py
+++ b/securedrop/tests/functional/source_navigation_steps.py
@@ -58,25 +58,27 @@ def _source_chooses_to_submit_documents(self):
         self.source_name = codename.text
 
     def _source_shows_codename(self, verify_source_name=True):
-        content = self.driver.find_element_by_id("codename-hint-content")
-        assert not content.is_displayed()
+        # The DETAILS element will be missing the OPEN attribute if it is
+        # closed, hiding its contents.
+        content = self.driver.find_element_by_css_selector("details#codename-hint")
+        assert content.get_attribute("open") is None
 
-        self.safe_click_by_id("codename-hint-show")
+        self.safe_click_by_id("codename-hint")
 
-        self.wait_for(lambda: content.is_displayed())
-        assert content.is_displayed()
-        content_content = self.driver.find_element_by_css_selector("#codename-hint-content p")
+        assert content.get_attribute("open") is not None
+        content_content = self.driver.find_element_by_css_selector("details#codename-hint mark")
         if verify_source_name:
             assert content_content.text == self.source_name
 
     def _source_hides_codename(self):
-        content = self.driver.find_element_by_id("codename-hint-content")
-        assert content.is_displayed()
+        # The DETAILS element will have the OPEN attribute if it is open,
+        # displaying its contents.
+        content = self.driver.find_element_by_css_selector("details#codename-hint")
+        assert content.get_attribute("open") is not None
 
-        self.safe_click_by_id("codename-hint-hide")
+        self.safe_click_by_id("codename-hint")
 
-        self.wait_for(lambda: not content.is_displayed())
-        assert not content.is_displayed()
+        assert content.get_attribute("open") is None
 
     def _source_sees_no_codename(self):
         codename = self.driver.find_elements_by_css_selector(".code-reminder")
diff --git a/securedrop/tests/functional/test_source.py b/securedrop/tests/functional/test_source.py
--- a/securedrop/tests/functional/test_source.py
+++ b/securedrop/tests/functional/test_source.py
@@ -62,7 +62,7 @@ def get_codename_generate(self):
         return self.driver.find_element_by_css_selector("#codename").text
 
     def get_codename_lookup(self):
-        return self.driver.find_element_by_css_selector("#codename-hint-content p").text
+        return self.driver.find_element_by_css_selector("#codename-hint mark").text
 
     def test_duplicate_generate_pages(self):
         # Test generation of multiple codenames in different browser tabs, ref. issue 4458.
diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py
--- a/securedrop/tests/test_source.py
+++ b/securedrop/tests/test_source.py
@@ -101,8 +101,8 @@ def _find_codename(html):
     """Find a source codename (diceware passphrase) in HTML"""
     # Codenames may contain HTML escape characters, and the wordlist
     # contains various symbols.
-    codename_re = (r'<p [^>]*id="codename"[^>]*>'
-                   r'(?P<codename>[a-z0-9 &#;?:=@_.*+()\'"$%!-]+)</p>')
+    codename_re = (r'<mark [^>]*id="codename"[^>]*>'
+                   r'(?P<codename>[a-z0-9 &#;?:=@_.*+()\'"$%!-]+)</mark>')
     codename_match = re.search(codename_re, html)
     assert codename_match is not None
     return codename_match.group('codename')