Skip to content

Commit 524f8a9

Browse files
Merge shell partials: end the five-copy drift (#179)
Each rebuilt page had its own copy of the masthead, footer and script block, and the copies had already diverged: every one of them dropped the Slack entry from the primary navigation and stopped loading user_menu.js, so the account menu no longer closed on Escape or on an outside click. The shell now lives in two markup partials that all six pages include, and a drift test asserts they carry the same nine navigation entries and the same script prefix. Also restores what the podcast rebuild lost: artwork no longer depends on a watch link, its alt text names the episode again, the visible unavailable fallback is back, and the hub says where to subscribe -- naming only the platforms the episodes actually link to, because no show-level feed exists. Resolved against the breadcrumb and restoration work. Both sides had edited the same three pinned files with identical rationales, so neither side's digest described the merged content; every ledger row is re-pinned from the file on disk rather than from either branch. Refs #179
2 parents 4040e1f + 4c96896 commit 524f8a9

19 files changed

Lines changed: 817 additions & 1304 deletions

_docs/adoption/course-platform/integration-patched-files.tsv

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ studio_courses/tests/test_datamailer_views.py 10015 a06f3ba8502f18517f58183c8b84
4646
studio_courses/tests/test_homework_submission_edit_views.py 3584 58634d0b33641ccd268f031d1fa15fa284bb969e54467da873bb78f3c316906e Mechanically rename the copied course-operations package or its active references to Studio Courses without changing business behavior.
4747
studio_courses/tests/test_homework_views.py 21269 d0a0d1562af35c40af97f421069817838346f2c6f700021919d9144edb59bb30 Characterize copied homework behavior with canonical Studio Courses route names.
4848
studio_courses/tests/test_impersonation_enrollment_views.py 1139 ea35ae288eb197dcad8a02791d3374e24f51ecfaf1d8d7df11ee0bbc12d1d747 Use canonical Studio Courses route names in copied view-as enrollment characterization.
49-
studio_courses/tests/test_impersonation_stop_views.py 3198 95ff800eac73888b08b5b86bf3da490277cb20d05fc962f67158311c0e6add38 Use the canonical Studio Courses return route in copied view-as restoration characterization, and assert the banner element rather than its class name: the design 5a pages inline a stylesheet that declares .impersonation-banner, so the bare substring is present whether or not the banner renders.
49+
studio_courses/tests/test_impersonation_stop_views.py 3495 62d5699e0cfb0a29e4af923337b67fddbfc02e5d43e872d6958766e029c90941 Use the canonical Studio Courses return route in copied view-as restoration characterization, and assert the banner's absence through its own markup rather than a class name the design 5a courses index now also carries as an inline CSS selector.
5050
studio_courses/tests/test_impersonation_views.py 1474 5909d14e4834208650c827b0de88fea44519c262202a4338d5ed27ba37c93c9a Mechanically rename the copied course-operations package or its active references to Studio Courses without changing business behavior.
5151
studio_courses/tests/test_leaderboard_views.py 6173 6b6fe60acbde65623364bd9dabe4fe3f6b73a7309ba2034110a9327043d1a336 Use canonical Studio Courses route names and its explicit course_operator role in copied leaderboard complaint characterization.
5252
studio_courses/tests/test_project_action_views.py 6687 dc3d9834946e245e094541314983e7b5936838e55038de09b7a7cf1f412763d7 Use canonical Studio Courses route names in copied project action characterization.
@@ -93,7 +93,7 @@ courses/static/courses.css 50656 9cad76824ba9d3438e199dbc707936444cff9c7f3474e2e
9393
courses/static/time_left.js 1921 9e50335b72beea04bdff6f10b2ac989d7e23272bf7d5eb4d51d30efa4b4566a0 Issue #144 replaces deadline palette utility classes with semantic theme-backed status classes without changing thresholds, text, muted past-tone behavior, or update cadence.
9494
courses/static/settings_toggles.js 3770 dbebb8de5ed89725e940820578e15b97a0f498625c60fbd5dd022b5b7131cc22 Retain preference mutations while exposing busy state and polite completion status to assistive technology.
9595
courses/static/user_menu.js 935 59e6fc2f9fc14bfd25eff75cc7e5d819a9683e3a18e9f499f2fc264147b836b4 Retain account-menu behavior while restoring keyboard focus to its trigger after Escape.
96-
courses/templates/courses/course.html 34668 0d148100207a549a6d6e094095a5e2f4ccad1dc53ff2290db57041832070b6db Render the copied course page in the design 5a system: one inline stylesheet, a numbered module accordion, a fact strip and the restored breadcrumb trail, with every copied learner state kept.
96+
courses/templates/courses/course.html 25987 ca68c772f55e1f7ea8e2141bbcad1b24584573b473d06d6f47d5f53cec8db06d Render the copied course page in the design 5a system: one inline stylesheet, a numbered module accordion and a fact strip, with every copied learner state kept, and take the masthead and script block from the shared design 5a shell partials so the page cannot drift out of the site's navigation or script set.
9797
courses/templates/courses/enrollment.html 6126 59e0977dd7e9f77346f4da82a7d82e135f780e157aefabe9a9487cfa89dd5b9d Retain enrollment behavior while associating toggle controls with their full labels and exposing the current breadcrumb state.
9898
courses/templates/courses/leaderboard_score_breakdown.html 11523 dca183fa91e69aca601322802237f9d432c8f319f4058ae0419f68244c40abae Show copied enrollment repair controls only to explicit course roles and point them to Studio Courses.
9999
courses/templates/courses/register.html 8108 d5af022a7f451c41a07b7aa643f2ff22ddb30724894df5cea75c54a09b4cdd7c Retain registration behavior and submitted values while adding shared label, help, required, field-error, error-summary, busy, and current-breadcrumb semantics.
@@ -130,8 +130,8 @@ course_platform_templates/socialaccount/authentication_error.html 1240 4a70aaf5e
130130
course_platform_templates/socialaccount/connections.html 3818 a1e0756bfe763150fddefc5018949eb90f7d90e7f5bd9b9eab890e6ae00c62f5 Use the unified DataTalks.Club name in the copied account-connections title without changing connection behavior.
131131
course_platform_templates/socialaccount/login_cancelled.html 1210 607d2d1d5c5c43aa69632030e144817416732482e04ecb0ac54fdd252de8fd71 Use the unified DataTalks.Club name in the copied cancelled-login title without changing recovery behavior.
132132
course_platform_templates/socialaccount/signup.html 1098 45af69b3a47d0100c0f911b76ec661bd8301d924f4b70251f5bd509ac80fb1d0 Use the unified DataTalks.Club name in the copied welcome title without changing signup completion behavior.
133-
courses/templates/courses/course_list.html 29784 b8845e2501f67b36284ac86b66fec08e9f3a081c38fdc947006669702d2111e1 Rebuild the public courses index in the design 5a visual system from the owner's 6a mockup (issue #179): one inline stylesheet built on the shared design-system partial, no adopted CMP shell, the same course records composed into its sections, and the site's only Wrapped entry point kept on the page in design 5a primitives.
134-
courses/views/course_list.py 9198 0b83443cc4e25e69798a083e0ff253747ca59a5082d9ac4b07df60b07d442581 Supply the design 5a courses index with the facts it renders - the catalogue eyebrow and stat tiles, the cohort dates and length, a learner's own enrolment state, the accepted public registration count, the server-rendered section filter, and the year its Wrapped entry point links to.
133+
courses/templates/courses/course_list.html 20197 a66ab12de94006ccac12ef2af87b1b7a5bffa55679ec3495fd589646b06610d2 Rebuild the public courses index in the design 5a visual system from the owner's 6a mockup (issue #179): one inline stylesheet built on the shared design-system partial, no adopted CMP shell, the same course records composed into its sections, and the masthead and script block taken from the shared design 5a shell partials so the page cannot drift out of the site's navigation or script set.
134+
courses/views/course_list.py 9198 0b83443cc4e25e69798a083e0ff253747ca59a5082d9ac4b07df60b07d442581 Supply the design 5a courses index with the facts it renders - the catalogue eyebrow and stat tiles, the cohort dates and length, a learner's own enrolment state, the accepted public registration count, and the server-rendered section filter.
135135
courses/tests/test_course_list_metadata.py 1263 427aba1af26b3ca4d2ddd6fa93ea288a1a9a95cbb6e2f2251b529b4995147042 Follow the design 5a courses index, which writes a single-year run as one compressed date range.
136136
courses/tests/test_course_list_ordering.py 2900 667347d818e8d17d0842f38e0df4b9540c1e59fb6d185892de8e4a64d3740025 Follow the design 5a courses index, which marks open registration with a mono status pill uppercased in CSS.
137137
courses/templatetags/custom_filters.py 1169 d1852552b0d3463ff541d3568ea23d54c0346a2725b8bbe74670919d46ab6c23 Add the compact deadline filter the design 5a module rows render beside a title, using the existing account-timezone formatter.

_docs/design/design-5a.md

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ The system lives in one place:
99
- **`templates/core/_design_system.html`** — the shared stylesheet partial.
1010
Tokens, reset, masthead, bands, cards and every shared primitive, including
1111
the extended primitives the 6-series pages need.
12+
- **`templates/core/_site_shell_head.html`** and
13+
**`templates/core/_site_shell_foot.html`** — the shared shell *markup*: the
14+
skip link, dark-mode bootstrap, impersonation banner, masthead and
15+
announcement; and the footer, theme toggle and script set. Every page in the
16+
system includes both.
1217
- **`_docs/design/mockups/`** — the decoded mockup source. For the four
1318
6-series pages, `datatalks-pages.source.html` is the authoritative reference
1419
for markup shape and values; the PNG exports under `.tmp/design-mockups/`
@@ -285,8 +290,35 @@ Markup shapes below are the contract; the CSS lives in the partial.
285290

286291
The masthead (`.masthead`, `.brand`, `.site-navigation`, `.masthead-actions`,
287292
`.nav-link`, `.nav-auth`, account menu) and the footer/analytics dialog are
288-
fully styled by the partial — copy the homepage's markup unchanged. The
289-
navigation contract (toggle + `#site-navigation-links`, opened by
293+
fully styled by the stylesheet partial, and their **markup is shared too**:
294+
295+
```html
296+
<body >
297+
{% include "core/_site_shell_head.html" %}
298+
<main id="main-content" tabindex="-1">
299+
…the page…
300+
</main>
301+
{% include "core/_site_shell_foot.html" %}
302+
…the page's own scripts, if it has any…
303+
</body>
304+
```
305+
306+
Do not copy that markup into a page. The first five pages in the system each
307+
carried their own copy, and the copies drifted: `/slack` left the navigation on
308+
all five and `user_menu.js` left the script set on all five, so the account
309+
menu would not close on Escape or on an outside click (issue #179).
310+
`core/tests/test_design_5a_shell.py` now renders every page in the system and
311+
compares their navigation entries and script sets to each other.
312+
313+
The navigation is **nine entries** — Events, Courses, Blog, Podcast, Wiki,
314+
Books, Docs, FAQ, Slack — the same row the rest of the site offers. The
315+
6-series mockups draw eight and put Slack in their own footer; this site's
316+
footer is shared with the pages still on the older shell, and a navigation row
317+
that loses an entry as the reader moves between pages is the worse trade. The
318+
current entry is set from `primary_navigation_current`
319+
(`core/context_processors.site_context`), so a page never marks it by hand.
320+
321+
The navigation contract (toggle + `#site-navigation-links`, opened by
290322
`core/site_navigation.js`, always visible without JavaScript) must not be
291323
altered per page.
292324

@@ -600,11 +632,15 @@ WebP**. The convention:
600632
1. Start from `templates/core/home.html`'s skeleton: `<!DOCTYPE html>`, the
601633
`no-js`/`js` class swap, meta/OG tags, one `<style>` whose first content is
602634
`{% include "core/_design_system.html" %}`, then page rules.
603-
2. Copy the masthead, footer include (`core/_site_footer.html`), skip link,
604-
dark-mode bootstrap scripts and the script includes
605-
(`timezone_preference.js`, `core/site_navigation.js`,
606-
`core/accessibility.js`, `core/analytics_preferences.js`) unchanged. Set
607-
`aria-current="page"` on the page's own nav link.
635+
2. Include `core/_site_shell_head.html` as the first thing in the body and
636+
`core/_site_shell_foot.html` after `</main>`. They carry the skip link, the
637+
dark-mode bootstrap, the masthead, the footer, the theme toggle and the
638+
script set (`timezone_preference.js`, `user_menu.js`,
639+
`core/site_navigation.js`, `core/accessibility.js`,
640+
`core/analytics_preferences.js`). Do not copy any of it into the page, and
641+
do not set `aria-current` by hand — the shell reads it from
642+
`primary_navigation_current`. Add the page to `DESIGN_5A_TEMPLATES` in
643+
`core/tests/test_design_5a_shell.py`.
608644
3. Compose the page as `section.band` elements with `.shell` inside; pick band
609645
colours from the band table; use `.band-head` for every section heading.
610646
4. Reach for the inventory before writing CSS: cards, chips, CTAs, status

content/podcast_content.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
99
Deliberate omissions, because the catalogue has no such field: an episode
1010
duration, a global episode number, and a show-level subscription address. The
11-
mockup shows all three; the pages leave them out rather than guess.
11+
mockup shows all three; the pages leave them out rather than guess. There is no
12+
podcast feed anywhere in this repository either, so the index points a reader who
13+
wants to subscribe at the platforms the episodes themselves link to, named from
14+
the records rather than hard-coded (:func:`listening_platform_phrase`).
1215
"""
1316

1417
from __future__ import annotations
@@ -175,6 +178,30 @@ def episode_view(record: dict[str, Any]) -> Episode:
175178
)
176179

177180

181+
def listening_platform_phrase(episodes: tuple[Episode, ...]) -> str:
182+
"""Name the platforms every one of these episodes can be followed on.
183+
184+
The catalogue has no show-level feed address, so the index cannot offer a
185+
subscribe button and must not invent one. What it can say truthfully is where
186+
the episodes themselves lead, and a platform earns its place in that sentence
187+
only when every episode on the page carries it.
188+
"""
189+
190+
if not episodes:
191+
return ""
192+
shared = set.intersection(
193+
*({link.label for link in episode.platform_links} for episode in episodes)
194+
)
195+
labels = [
196+
PLATFORM_LABELS[key][0] for key in PLATFORM_ORDER if PLATFORM_LABELS[key][0] in shared
197+
]
198+
if not labels:
199+
return ""
200+
if len(labels) == 1:
201+
return labels[0]
202+
return f"{', '.join(labels[:-1])} and {labels[-1]}"
203+
204+
178205
def season_episodes(records: tuple[dict[str, Any], ...]) -> tuple[Episode, ...]:
179206
"""Return one season's episodes in the order the catalogue already ordered them."""
180207

content/public_views.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from events.services import public_registration_total
3636

3737
from .faq_data import faq_courses
38-
from .podcast_content import episode_view, season_episodes
38+
from .podcast_content import episode_view, listening_platform_phrase, season_episodes
3939
from .public_data import (
4040
PROJECTION_ROOT,
4141
event_date_groups,
@@ -485,6 +485,7 @@ def podcast_hub(request: HttpRequest) -> HttpResponse:
485485
return _no_store(HttpResponse("Page not found.", status=404))
486486

487487
selected_season = seasons[season_index]
488+
episodes = season_episodes(selected_season.episodes)
488489
newer_season = seasons[season_index - 1] if season_index > 0 else None
489490
older_season = seasons[season_index + 1] if season_index + 1 < len(seasons) else None
490491
canonical_path = _podcast_season_path(selected_number, latest=latest_season)
@@ -502,8 +503,9 @@ def podcast_hub(request: HttpRequest) -> HttpResponse:
502503
),
503504
context={
504505
"season": selected_season,
505-
"episodes": season_episodes(selected_season.episodes),
506+
"episodes": episodes,
506507
"episode_total": sum(len(available.episodes) for available in seasons),
508+
"listening_platforms": listening_platform_phrase(episodes),
507509
"season_links": tuple(
508510
{
509511
"number": available.number,

content/tests/test_podcast_catalog.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@
22

33
import json
44
import re
5+
from pathlib import Path
56
from unittest.mock import patch
67
from xml.etree import ElementTree
78

89
from django.core.exceptions import ImproperlyConfigured
10+
from django.template.loader import render_to_string
911
from django.test import Client, SimpleTestCase, TestCase
1012
from django.utils.html import conditional_escape
1113

12-
from content.podcast_content import episode_view, published_display, season_episodes
14+
from content.podcast_content import (
15+
episode_view,
16+
listening_platform_phrase,
17+
published_display,
18+
season_episodes,
19+
)
1320
from content.public_data import ordered_podcasts, podcast_seasons, public_projection
1421
from core.seo import validated_canonical_url
1522

23+
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
1624
SITEMAP_NAMESPACE = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
1725

1826

@@ -160,6 +168,73 @@ def test_every_catalogue_record_composes_without_invention(self) -> None:
160168
# Seventeen entries carry no publication date, and the pages simply omit it.
161169
self.assertEqual(sum(1 for view in views if not view.published_display), 17)
162170

171+
def test_the_subscribe_sentence_names_only_platforms_every_episode_carries(self) -> None:
172+
"""There is no show feed to link, so the copy points at what does exist."""
173+
174+
season = podcast_seasons()[0]
175+
episodes = season_episodes(season.episodes)
176+
177+
phrase = listening_platform_phrase(episodes)
178+
179+
for label in phrase.replace(" and ", ", ").split(", "):
180+
for episode in episodes:
181+
self.assertIn(label, [link.label for link in episode.platform_links])
182+
self.assertNotIn("RSS", phrase)
183+
self.assertNotIn("feed", phrase.casefold())
184+
185+
def test_the_subscribe_sentence_disappears_rather_than_naming_a_guess(self) -> None:
186+
record = dict(ordered_podcasts()[0])
187+
record["links"] = {}
188+
189+
self.assertEqual(listening_platform_phrase(()), "")
190+
self.assertEqual(listening_platform_phrase((episode_view(record),)), "")
191+
192+
def test_the_subscribe_sentence_reads_as_a_sentence(self) -> None:
193+
first, second = (dict(record) for record in ordered_podcasts()[:2])
194+
first["links"] = {"spotify": "https://open.spotify.com/episode/one"}
195+
second["links"] = {
196+
"spotify": "https://open.spotify.com/episode/two",
197+
"youtube": "https://www.youtube.com/watch?v=two",
198+
}
199+
200+
self.assertEqual(listening_platform_phrase((episode_view(first),)), "Spotify")
201+
self.assertEqual(
202+
listening_platform_phrase((episode_view(second),)),
203+
"Spotify and YouTube",
204+
)
205+
# A platform only one of the two carries is not offered to either.
206+
self.assertEqual(
207+
listening_platform_phrase((episode_view(first), episode_view(second))),
208+
"Spotify",
209+
)
210+
211+
def test_episode_artwork_is_named_and_never_depends_on_a_listening_link(self) -> None:
212+
"""Artwork and a listening link are independent facts (issue #179)."""
213+
214+
record = dict(ordered_podcasts()[0])
215+
record["links"] = {}
216+
unplayable = episode_view(record)
217+
self.assertEqual(unplayable.watch_url, "")
218+
self.assertTrue(unplayable.media_available)
219+
220+
artwork = render_to_string("public/_episode_artwork.html", {"episode": unplayable})
221+
self.assertIn(f'alt="Artwork for {conditional_escape(unplayable.title)}"', artwork)
222+
self.assertIn(f'src="{unplayable.image_path}"', artwork)
223+
224+
record["media_available"] = False
225+
record["image_path"] = ""
226+
missing = render_to_string(
227+
"public/_episode_artwork.html", {"episode": episode_view(record)}
228+
)
229+
self.assertIn("Artwork unavailable.", missing)
230+
self.assertNotIn("<img", missing)
231+
232+
# Both frames — the linked one and the plain one — draw the same artwork.
233+
source = (REPOSITORY_ROOT / "templates/public/podcast_detail.html").read_text(
234+
encoding="utf-8"
235+
)
236+
self.assertEqual(source.count('{% include "public/_episode_artwork.html" %}'), 2)
237+
163238
def test_publication_dates_are_read_and_never_guessed(self) -> None:
164239
self.assertEqual(published_display("2021-02-23"), "Feb 23, 2021")
165240
self.assertEqual(published_display(""), "")

content/tests/test_public_template_source.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
REPOSITORY_ROOT / "accounts/templates/accounts/login.html",
1515
REPOSITORY_ROOT / "templates/core/base.html",
1616
REPOSITORY_ROOT / "templates/core/home.html",
17+
# The design 5a shell now lives in two partials that every rebuilt page includes
18+
# (issue #179), so the markup those pages used to carry answers here instead.
19+
REPOSITORY_ROOT / "templates/core/_site_shell_head.html",
20+
REPOSITORY_ROOT / "templates/core/_site_shell_foot.html",
1721
# The courses index left the adopted shell with the design 5a rebuild (issue #179)
1822
# and is now a public page in its own right.
1923
REPOSITORY_ROOT / "courses/templates/courses/course_list.html",

0 commit comments

Comments
 (0)