From bd1fc6ee9f3449f318318559be9552f26f9bec06 Mon Sep 17 00:00:00 2001 From: David Arcos Date: Thu, 15 Oct 2026 10:15:41 +0200 Subject: [PATCH 1/7] Count what each row of the three listings holds, in one walk of the site The pages that list the whole archive need the same counts in three places: the total in the h1, the legend above the rows, and the card of counts on each row. partials/listing_counts.html walks site.RegularPages once per build, through partialCached with a constant variant, and returns what the three listings need: how many rows of /events/ and /talks/ carry each type, how many rows /people/ has, and per row what the day, the talk, or the person holds. The rule of what is a row is the rule of the page that draws it, so the legend, the heading, and the rows add up. Around it, the parts a listing page draws and no other page has: - partials/count_card.html, the card of counts of a row, one icon and one number per kind (talk, photo, video, links, event), an item with a count of zero left out, no card at all for a row with nothing to count. The links of a talk are every link of its record that is not its video, under one mark: the slides, the code, a notebook, a site. - partials/listing_total.html, the total of the one page it is on, which partials/hero_header.html prints in the h1: "Events (281)". - partials/jump_bar.html, the bar of links above a long index, one per group, which wraps with no script. From the lg breakpoint the bar is pinned under the navigation as the page scrolls (main.scss says why not below it: on a phone it would hold a quarter of the window), and every anchor it points at clears it with scroll-margin-top. - partials/marker_legend.html, the markers the rows draw with how many rows carry each. - partials/event_type.html and data/event_emoji.yaml, the type of an event page from its folder, its title, and its group, and the glyph the row prints for it, in one table. - listing_table.scss, the stylesheet of the table /events/ and /talks/ share, and the rules of main.scss for the jump bar, the legend, the line of facts under a title, and the card. The photos of a day are counted as zero until the gallery lands; the partial reads them from the gallery and nothing else counts them. --- data/event_emoji.yaml | 123 +++++ .../assets/scss/listing_table.scss | 485 ++++++++++++++++++ themes/pybcn_theme/assets/scss/main.scss | 175 +++++++ themes/pybcn_theme/assets/scss/style.scss | 1 + .../layouts/partials/count_card.html | 91 ++++ .../layouts/partials/event_type.html | 47 ++ .../layouts/partials/hero_header.html | 8 +- .../layouts/partials/jump_bar.html | 49 ++ .../layouts/partials/listing_counts.html | 155 ++++++ .../layouts/partials/listing_total.html | 30 ++ .../layouts/partials/marker_legend.html | 46 ++ 11 files changed, 1209 insertions(+), 1 deletion(-) create mode 100644 data/event_emoji.yaml create mode 100644 themes/pybcn_theme/assets/scss/listing_table.scss create mode 100644 themes/pybcn_theme/layouts/partials/count_card.html create mode 100644 themes/pybcn_theme/layouts/partials/event_type.html create mode 100644 themes/pybcn_theme/layouts/partials/jump_bar.html create mode 100644 themes/pybcn_theme/layouts/partials/listing_counts.html create mode 100644 themes/pybcn_theme/layouts/partials/listing_total.html create mode 100644 themes/pybcn_theme/layouts/partials/marker_legend.html diff --git a/data/event_emoji.yaml b/data/event_emoji.yaml new file mode 100644 index 000000000..f91b4b6f7 --- /dev/null +++ b/data/event_emoji.yaml @@ -0,0 +1,123 @@ +# The emoji that marks each row of the /events/ list. Left side: the type of +# the event. Right side: the emoji, the label the marker is called by, and +# the noun the legend prints after a count, in its singular (one) and its +# plural (many). A type that is not here takes the "*" entry, so a new type +# shows up with a neutral glyph rather than with none, and the row does not +# change shape when it is added. See +# themes/pybcn_theme/layouts/_default/events_index.html. +# +# The type is not a field of the content. partials/event_type.html reads it +# from the page, in this order of precedence: +# +# 1. The series, from the folder of the page first and from its title +# second: a page under content/events/pyday_bcn/ is a pyday, a page +# under content/events/pydatabcn/ is a pydata, and a page in any other +# folder but meetups/ is whichever of the two its title starts with. +# That is what makes "PyData Barcelona (2017)" a pydata although it +# sits in other_events/. The title does not decide inside meetups/, +# where "PyDayBCN 2019 Call for Papers mentoring session" is a meetup +# about a PyDay and not an edition of one. +# 2. The group. A page whose front matter carries group: "PyLadies BCN" +# is a pyladies, whatever its title says: a PyLadies dojo shows the +# PyLadies marker and not the dojo one, because the community that +# organised it is the more distinctive fact. +# 3. The kind, from a word of the title: dojo, practice session, +# barbecue, hacktoberfest or hackathon (one kind), sprint, +# coderetreat, and workshop last. Three titles carry two of those +# words ("October dojo: Pyzmq workshop" and two "Practice Sessions: +# ... workshop"), and the dojo or the practice session is the format +# of the day while the workshop is its content, so the format wins. +# 4. A page under content/events/meetups/ is a meetup, the plain monthly +# one. +# 5. Everything else takes "*". +# +# A field in the edition files would say again what the folder, the title, +# and the group already say, and a field can drift from them. +# +# Why this many types. Before the group and the kinds were read, 266 of the +# 279 rows of the list were meetups, 95 per cent of the page under one +# glyph: a marker that nearly every row shares marks nothing. Everything +# that is not a plain monthly meetup now takes a different glyph, PyLadies +# BCN has its own, and a kind with one row of its own keeps its glyph all +# the same, so that the legend of the page can name it. +# +# The glyphs. Two people and not a party popper, a calendar or a beer for +# the monthly meetup: a meetup is the people who came to it, and 👤 is the +# neutral glyph of data/role_emoji.yaml, which marks one person, so 👥 +# reads as a group next to it rather than against it. Two women for +# PyLadies BCN, for the same reason: the row is a meetup of that +# community, and the glyph says so next to 👥. A martial arts uniform for +# a coding dojo, which is a kata in the sense the word came with. Books +# for a practice session, a laptop for a workshop (the icon the workshop +# talks of a programme carry already, fa-laptop), meat on a bone for a +# barbecue, a wrench for a hackathon, a runner for a sprint, and a person +# in lotus position for a coderetreat, which is a day of retreat. +# +# Every emoji here has emoji presentation by default (Emoji_Presentation in +# the Unicode emoji data), is one code point, needs no U+FE0F, the +# variation selector, and is no ZWJ sequence. The selector and the joiner +# are banned, for the reason data/role_emoji.yaml gives: a selector or a +# joiner is one glyph to one font and two to another, so the marker column +# would depend on the font the visitor has. Keep to that set when adding a +# row, and check a candidate against emoji-data.txt rather than from +# memory: a weight lifter, a hammer and wrench, and a heart all fail it. +pyday: + emoji: 🐍 + label: PyDay BCN edition + one: PyDay BCN edition + many: PyDay BCN editions +pydata: + emoji: 📊 + label: PyDataBCN edition + one: PyDataBCN edition + many: PyDataBCN editions +pyladies: + emoji: 👭 + label: PyLadies BCN meetup + one: PyLadies BCN meetup + many: PyLadies BCN meetups +dojo: + emoji: 🥋 + label: Coding dojo + one: coding dojo + many: coding dojos +practice: + emoji: 📚 + label: Practice session + one: practice session + many: practice sessions +barbecue: + emoji: 🍖 + label: Barbecue + one: barbecue + many: barbecues +hackathon: + emoji: 🔧 + label: Hackathon + one: hackathon + many: hackathons +sprint: + emoji: 🏃 + label: Sprint + one: sprint + many: sprints +coderetreat: + emoji: 🧘 + label: Coderetreat + one: coderetreat + many: coderetreats +workshop: + emoji: 💻 + label: Workshop + one: workshop + many: workshops +meetup: + emoji: 👥 + label: Monthly meetup + one: monthly meetup + many: monthly meetups +"*": + emoji: 🎉 + label: Other event + one: other event + many: other events diff --git a/themes/pybcn_theme/assets/scss/listing_table.scss b/themes/pybcn_theme/assets/scss/listing_table.scss new file mode 100644 index 000000000..86a971fc6 --- /dev/null +++ b/themes/pybcn_theme/assets/scss/listing_table.scss @@ -0,0 +1,485 @@ +// The table of a listing of the archive: /events/ and /talks/, six columns +// each, the year and the month once per group, spanning their rows. The +// shared classes are listing_*, and each page adds its own: event_* in +// layouts/_default/events_index.html, talk_* in layouts/talks/list.html. +// The Events table of a person page (partials/person_appearances.html) is +// the third: the year, the month, and the day of /talks/, then the +// appearance and the card, with the same group markup, so it takes these +// rules and adds person_event_* below and in people_page.scss. +// The header comment of events_index.html says why it is a table and what +// each cell holds, and talks/list.html says what its own columns add. One +// stylesheet for the two pages, so a rule tuned on one holds on the other +// and the pair cannot drift. +// +// Two layouts. From the lg breakpoint (992px, where the container is 960px) +// the table is a table, and below it every row is its own grid: the year +// and the month, when the row carries them, are bands across the row, and +// the row proper is the day at the left of the title, with the venue and +// the card on one line under it (on /events/; on /talks/ the event is a +// third band and the credit goes under the title, in the rules below). A +// six-column table does not fit in the 360px of +// content a 390px screen gives, and the page must not scroll sideways. The +// switch is at lg and not at md (720px) because the five narrow columns +// take about 34rem between them, which at md leaves the title about 170px +// and wraps most of the 281 titles, and at lg leaves it about 400px. +// +// The media queries are written out, not through Bootstrap's +// media-breakpoint-up: the mixins are not in scope in this file, which +// main.scss imports before Bootstrap's own, and people.scss writes its +// queries by hand for the same reason. The breakpoints are Bootstrap 4's +// documented values. + +.listing_table { + width: 100%; + margin-bottom: 2rem; + border-collapse: collapse; + + // The caption reads as a note about the table, in the colour and the + // size of the facts, above the header row where a reader looks first. + .listing_caption { + caption-side: top; + padding: 0 0 0.6rem; + color: #51616e; + font-size: 0.9rem; + text-align: left; + } + + // The glyph of the type, from data/event_emoji.yaml, as the marker of + // the title. Upright and at the normal weight, because a slanted or + // heavy emoji is a font bug on some platforms and never a style, and + // line-height 1 keeps the glyph from opening the line box further than + // its own height. One width whatever the glyph, in rem so the title + // can hang by the same amount, so the titles of two rows start at the + // same place. + .event_emoji { + display: inline-block; + width: 1.75rem; + margin-right: 0.35rem; + font-size: 1.15rem; + font-style: normal; + font-weight: normal; + line-height: 1; + text-align: center; + text-indent: 0; + vertical-align: middle; + } + + // A title that wraps hangs: the second line starts under the first + // word and not under the marker, which is 2.1rem of glyph and margin. + // The padding is the cell padding plus the hang, and the negative + // indent pulls the first line, which starts with the marker, back to + // the cell padding. text-indent is inherited, and the marker is an + // inline-block with a first line of its own, so without the + // text-indent: 0 above the glyph is drawn 2.1rem left of its box, over + // the day. Measured in Chromium: the box 8px into the cell and the + // glyph touching the day. + .event_title { + padding-left: calc(0.5rem + 2.1rem); + text-indent: -2.1rem; + + a { + font-weight: 600; + } + } + + // The venue, in italics, the way the columns were asked for, and the + // short name with the full string as its title. The dotted underline + // is the one a browser draws under an abbr with a title, and it says + // the same thing here: there is more on hover. Without it the hover is + // a secret. + .event_venue_cell { + font-style: italic; + color: #51616e; + } + + .event_venue { + text-decoration: underline dotted; + text-underline-offset: 0.15em; + cursor: help; + } + + // The marker of a talk: the icon of its type, in the box of the + // event emoji above, so the titles of the two tables hang by the + // same 2.1rem. The icon is text, so it takes the colour of the facts. + .talk_marker { + display: inline-block; + width: 1.75rem; + margin-right: 0.35rem; + color: #51616e; + text-align: center; + text-indent: 0; + vertical-align: middle; + } + + // The talk cell hangs like the title of /events/: the padding is the + // cell padding plus the hang, and the negative indent pulls the first + // line, which starts with the marker, back to the cell padding. The + // title link is bold, and the links of the credit are not: a reader + // scans the column by title. + .talk_cell { + padding-left: calc(0.5rem + 2.1rem); + text-indent: -2.1rem; + + > a { + font-weight: 600; + } + } + + // "by" and the speakers. The names are links + // (partials/person_page_link.html) and read as the title link beside + // them does: the link blue at rest, underlined under the pointer. They + // were underlined at rest in the grey of the line, so they did not read + // as links and the pointer changed nothing (David Arcos, 2026-10-15). + // The "by" is italic and a lighter grey, $icon-grey, 4.68:1 on white, + // and the names are upright: the slant is the second mark WCAG 1.4.1 + // asks of a link inside a line of text, in place of the underline, and + // what axe reads as a distinct style for link-in-text-block. + .talk_credit { + color: $icon-grey; + font-style: italic; + + a { + font-style: normal; + } + } + + // The event, in the weight of the month and the colour of the text: + // it is a group header, and it is a link. + .talk_event_cell { + font-weight: 600; + } + + .listing_day { + color: #51616e; + white-space: nowrap; + } + + // The two header cells of a group, in the colour of the facts: they + // are labels in a margin and not titles. + .listing_year, + .listing_month { + font-weight: 600; + color: #51616e; + } +} + +// The year is a link target: a person card points at /events/#2019, and +// the jump bar above the table points at the same ids. The navbar is fixed +// and 105px tall, so without this the year lands under it and the reader +// sees the year before the one they asked for. +.listing_year { + scroll-margin-top: 65px; // the shrunk navigation, 61px, plus a little + + // From lg the bar of years is pinned under the navbar (.jump_bar in + // main.scss, which says why only from lg), 45px tall with its + // padding, so the target has to clear both: 105px, 45px, and 4px of + // air. Get this wrong and every jump lands under the bar and reads + // as a broken link. + @media (min-width: 992px) { + scroll-margin-top: 110px; // the shrunk navigation plus the pinned bar + } +} + +// Up to md: one grid per row. The table elements lose their display, which +// is why the template writes every role out (see its header). +@media (max-width: 991.98px) { + .listing_table, + .listing_table tbody, + .listing_table tr, + .listing_table th, + .listing_table td, + .listing_table .listing_caption { + display: block; + } + + // The header row stays in the tree for the roles to point at and is + // taken off the screen: the bands of the year and the month say what + // those cells are, and the day, the title, the venue, and the card + // read without a label the way they did before the table. + .listing_table thead { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + // The row: four lines of areas, of which the first two are empty on + // every row but the first of its group. An empty area of auto height + // is 0px tall, so the gap between rows is a margin on the cells and + // never a row-gap, which would open a blank line where the year and + // the month are absent. The day column is the width of a two-digit + // day at the least, so the titles of "9 Jun" and "29 Nov" start at one + // place. The venue and + // the card share the last line, the venue at the left and the card at + // the right, which is one line less per row than a line each: the + // page is 281 rows tall, and a line each is 281 lines more. + .listing_table .listing_row { + display: grid; + grid-template-columns: minmax(2.2rem, auto) minmax(0, 1fr) auto; + column-gap: 0.75rem; + margin-bottom: 0.7rem; + } + + // The areas of a row of /events/. The talk_* areas of /talks/ are in + // its own block below: it has one more group level, the event. + .event_table .listing_row { + grid-template-areas: + "year year year" + "month month month" + "day title title" + "day venue gallery"; + } + + // The areas of a row of /talks/. The third line is the event group: + // the day at the left of the event title, on the first row of the + // event and on no other, so it reads as the heading of the talks + // under it. The fourth line is the talk, indented by the day column + // like the title of /events/, with the card at the right. The credit + // is a block inside the talk cell here (below), so the card, aligned + // to the end of the cell, sits beside the credit line the way the + // card of /events/ sits beside the venue. + .talk_table .listing_row { + grid-template-areas: + "year year year" + "month month month" + "day event event" + ". talk gallery"; + } + + // The areas of a row of the Events table of a person page + // (partials/person_appearances.html): the year and the month as bands, + // then the day at the left of the appearance, with the card at the + // right, the line of /events/ with the title and the venue in one + // cell. The cell has no padding of its own in this layout, so the hang + // of the role glyph is the padding alone (people_page.scss). + .person_event_table .listing_row { + grid-template-areas: + "year year year" + "month month month" + "day event gallery"; + } + + .listing_table .person_event_cell { + grid-area: event; + } + + // The year, as the band it was before the table: an h2 in size and in + // rule, a cell in role. + .listing_table .listing_year { + grid-area: year; + margin: 0.6rem 0 0.6rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid #dde3e8; + font-size: 1.5rem; + color: inherit; + } + + // The month, as a small band: 164 of them on the page, so its height + // is paid 164 times, and it is the size of the text with a margin of a + // quarter of a line. + .listing_table .listing_month { + grid-area: month; + margin-bottom: 0.25rem; + font-size: 0.95rem; + } + + .listing_table .listing_day { + grid-area: day; + text-align: right; + font-weight: 600; + } + + // The title hangs here as well, and the cell has no padding of its + // own in this layout, so the padding is the hang alone. + .listing_table .event_title { + grid-area: title; + padding-left: 2.1rem; + font-size: 1.05rem; + } + + .listing_table .event_venue_cell { + grid-area: venue; + font-size: 0.9rem; + } + + .listing_table .listing_gallery { + grid-area: gallery; + align-self: start; + } + + // The event, as the band of its group: on the line of the day, in + // the weight of the month band, with air above it so the group it + // opens stands off the last talk of the group before. 224 of them on + // the page. + .listing_table .talk_event_cell { + grid-area: event; + margin: 0.35rem 0 0.2rem; + font-size: 1rem; + } + + // The talk hangs here as well, and the cell has no padding of its + // own in this layout, so the padding is the hang alone. + .listing_table .talk_cell { + grid-area: talk; + padding-left: 2.1rem; + font-size: 1.05rem; + } + + // The credit on its own line under the title, as the venue of + // /events/ is: a line of 1.05rem title and 0.9rem credit wraps less + // than one of both. text-indent 0, so the line starts under the + // first word of the title and not under the marker. + .listing_table .talk_credit { + display: block; + font-size: 0.9rem; + text-indent: 0; + } + + // The card at the end of the row, beside the credit. + .talk_table .listing_gallery { + align-self: end; + } +} + +// From lg: the table as a table. +@media (min-width: 992px) { + .listing_table { + th, + td { + padding: 0.3rem 0.5rem; + vertical-align: top; + text-align: left; + } + + // The header is small type, because on the three narrow columns + // it is the widest thing: "MONTH" at 0.9rem and 0.03em took 55px + // where the widest month, "May", takes 31px, so the header set the + // column. At 0.8rem and 0.02em it takes 48px. + thead th { + padding-bottom: 0.4rem; + border-bottom: 2px solid #dde3e8; + color: #51616e; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + } + + // A rule between two rows, on the cells of the row and not on the + // row, so the cells a group header spans stay unbroken: with + // border-collapse a border on a td does not cross the th beside + // it. A heavier rule between two years, on the first row of the + // group, which is the row that carries the year. + tbody td, + tbody th { + border-top: 1px solid #eef1f4; + } + + tbody + tbody tr:first-child th, + tbody + tbody tr:first-child td { + border-top: 1px solid #dde3e8; + } + + // Three narrow columns take their content width and the title + // takes the rest. 1% is the trick that makes a column of an auto + // table as narrow as its widest cell and no wider. Their cells + // take less side padding than the rest: a year, a month, and a + // day are short, and the 0.5rem of the other cells made the + // three of them 202px between them at 1280px, measured before + // the Day column stopped printing a range. + .listing_year, + .listing_month, + .listing_day, + .listing_year_col, + .listing_month_col, + .listing_day_col { + width: 1%; + padding-left: 0.35rem; + padding-right: 0.35rem; + white-space: nowrap; + } + + // The gallery is a fixed width: the one that holds the widest + // card of two items, 107px on /events/ and 91px on /talks/, + // measured at 1280px. One card per page holds three items (PyDay + // BCN 2019 on /events/, one talk on /talks/), and at 1% that + // card set the column: 173px on /events/, 149px on /talks/. At + // 8rem the card of three wraps onto two lines and the column is + // 128px on both pages. The items of the card stay whole: each one + // is an inline-block. + .listing_gallery, + .listing_gallery_col { + width: 8rem; + white-space: nowrap; + + .count_card { + flex-wrap: wrap; + } + } + + // The venue is a fixed width and wraps past it. Not 1%: a column + // that wraps shrinks to its longest word under that rule, and + // "UB (Facultat de Matemàtiques)" took three lines while the + // title had room to spare, measured at 1280px. The width is the + // trade between the venue and the title, which take the room the + // four 1% columns leave (about 300px at the paddings above), and + // the title is what the reader reads, so the venue gives way at + // lg: 11rem there, where the container is 960px, and 12rem from + // xl (1200px, container 1140px). It was 15rem from xl, and at + // 1280px the table was shortest at 12rem: 10695px against 10899px + // at 15rem, with 24 titles wrapping instead of 35 and 2 venues + // instead of 1, measured over the 281 rows on 2026-10-15. + .event_venue_cell, + .event_venue_col { + width: 11rem; + } + + // The year and the day align right, and so do their headers: the + // unit digit of "6" sits under the unit digit of "29". + .listing_year, + .listing_year_col, + .listing_day, + .listing_day_col { + text-align: right; + } + + .listing_year { + font-size: 1.15rem; + } + + // The hang of the title, over the cell padding of this layout. + .event_title, + .talk_cell { + padding-left: calc(0.5rem + 2.1rem); + } + + // The event column of /talks/ is a fixed width and wraps past it, + // for the reason the venue column is: 224 event titles with a + // median of 33 characters and a longest of 79, and the talk is + // what the reader reads. 13rem at every width from lg: it was + // 16rem from xl, and at 1280px the table was shortest at 13rem, + // 30542px against 30910px at 16rem, with 254 talk cells wrapping + // instead of 314 and 158 event cells instead of 125, measured + // over the 607 rows on 2026-10-15. An event cell spans the rows + // of its talks, so a wrapped event title costs height only when + // it needs more lines than the talks under it. + .talk_event_cell, + .talk_event_col { + width: 13rem; + } + } +} + +@media (min-width: 1200px) { + .listing_table { + .event_venue_cell, + .event_venue_col { + width: 12rem; + } + } +} diff --git a/themes/pybcn_theme/assets/scss/main.scss b/themes/pybcn_theme/assets/scss/main.scss index dfba25ace..175a49fc8 100644 --- a/themes/pybcn_theme/assets/scss/main.scss +++ b/themes/pybcn_theme/assets/scss/main.scss @@ -258,6 +258,181 @@ button:focus:not(:focus-visible), .modal-title { line-height: 1.2; } +// The paragraph a page opens with, above the list or the cards it is the +// index of: .section_intro on /events/, /people/, /talks/ and +// /events/meetups/, .page-intro on the two monthly meetup pages. A link +// inside a sentence needs a second mark besides the colour, which is WCAG +// 1.4.1 and the rule this repository already applies to .photo_source and +// to the caption of a talk photo. axe reported this one as +// link-in-text-block on 7 links over 4 pages. +.section_intro a, +.page-intro a { + text-decoration: underline; +} + +// The bar of links above a long index, one link per group: the letters of +// /people/ and the years of /events/. It is the only way to reach the far +// end of 169 or 279 rows without scrolling the lot. See +// layouts/partials/jump_bar.html, which both pages draw it with. +// +// The bar wraps, with no script: the list is a flex row with flex-wrap, +// and a link that does not fit goes to the next line. The years of +// /events/ are 20 links, 2007 to 2026, and one more every year, so the +// bar outgrows a narrow screen already. Measured in Chromium: at 1280px +// the 20 years sit on one line, and at 390px they take four lines and the +// document is 390px wide, so nothing overflows sideways. The 24 letters +// of /people/ take three lines at 390px. +.jump_bar { + margin-bottom: 1.8rem; + + // Pinned under the fixed navigation bar as the page scrolls, from the + // lg breakpoint and not below it (David Arcos, 2026-10-15). Measured + // in Chromium: at 1280px the bar is one line of 29px and /people/ + // runs to 28107px, so a reader otherwise scrolls back up through 35 + // screens to jump to another letter. At 390px the bar is three lines + // of letters, 99px, or four lines of years, 134px, and pinned under + // the 105px navigation it would hold 204px or 239px of the window, + // over a quarter of 800px, for a list a thumb scrolls anyway: there + // it stays in the flow. 992px is the lg breakpoint of Bootstrap 4, + // written out because the Bootstrap mixins are not in scope in this + // file. top is the height of the navigation bar, 105px, the figure + // #main_content clears above. The white background covers the rows + // that pass under it, and the padding is the air between the links + // and the rows on both sides; the bar is 45px tall with it. Every + // anchor the bar points at clears the navigation with + // scroll-margin-top (65px, listing_table.scss and people_index.scss) + // and, from lg, the pinned bar as well: see those rules. + @media (min-width: 992px) { + position: sticky; + // 61px, the navigation once it has shrunk, not the 105px it measures + // at the top of the page: navbar.scss gives it the navbar_small class + // on scroll and the logo drops to 40px. A bar pinned at 105 left a + // 44px gap under the navigation through which whole rows showed, + // which is what David Arcos saw on 2026-10-15. Measured in Chromium: + // nav bottom 105px at scroll 0, 63px at 300, 61px from there on. + position: sticky; + top: 61px; + z-index: 10; + padding: 0.5rem 0; + background: #ffffff; + } + + ul { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding-left: 0; + margin-bottom: 0; + list-style: none; + } + + a { + display: inline-block; + min-width: 2rem; + padding: 0.1rem 0.35rem; + border: 1px solid #dde3e8; + border-radius: 3px; + font-weight: 600; + text-align: center; + } + + // /people/ centres its letters (David Arcos asked for it); /events/ + // keeps its years at the left, over the first column of its table. + // The class is the optional argument of partials/jump_bar.html. + &.jump_bar_centered ul { + justify-content: center; + } +} + +// The legend above a long index: each marker the rows draw, with how many +// rows carry it. One line that wraps, in the colour of the facts under a +// row, so it reads as information about the list and not as a row of it. +// See layouts/partials/marker_legend.html, which /events/ and /talks/ draw +// it with. +.marker_legend { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + padding-left: 0; + margin-bottom: 1.2rem; + list-style: none; + color: #51616e; + font-size: 0.9rem; + + li { + white-space: nowrap; + } + + // The glyph as the rows draw it: upright, at the normal weight, and + // with line-height 1, for the reason listing_table.scss gives. + .marker_legend_glyph { + display: inline-block; + font-size: 1.15em; + font-style: normal; + font-weight: normal; + line-height: 1; + vertical-align: middle; + } + + .marker_legend_icon { + display: inline-block; + width: 1.3em; + text-align: center; + } +} + +// The line of facts under the title of a row: the words (the date and +// the venue, the speaker and the day, the roles and the years) and the +// card of counts beside them. A flex line that wraps, so at a width where +// both fit the card follows the words, and at 390px it drops under them. +// Baseline alignment keeps the number of the card on the line of the +// words. See layouts/_default/events_index.html, layouts/talks/list.html, +// and layouts/people/list.html, which print one each. +.row_facts { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.75rem; + row-gap: 0.2rem; + + > p { + margin-bottom: 0; + } +} + +// The card of counts of a row: one icon and one number per kind of thing +// the row holds. See layouts/partials/count_card.html, which the three +// listings of the archive print it with. A bordered pill in the colour of +// the facts, with the border and the radius of a link of .jump_bar, so it +// reads as a box of figures and not as a sentence. The items never wrap +// inside the card: the card wraps as a whole, through .row_facts. +.count_card { + display: inline-flex; + flex-wrap: nowrap; + gap: 0 0.7rem; + padding: 0.05rem 0.5rem; + margin-bottom: 0; + border: 1px solid #dde3e8; + border-radius: 3px; + list-style: none; + color: #51616e; + font-size: 0.9rem; + line-height: 1.5; + white-space: nowrap; + vertical-align: baseline; + + li { + display: inline-block; + } + + // The icon takes one width whatever the glyph, so the numbers of two + // cards in two rows start at the same distance from their icon. + i { + width: 1.3em; + text-align: center; + } +} + // Fallback index for sections and taxonomies with no layout of their own. .section_index { margin: 2rem 0; diff --git a/themes/pybcn_theme/assets/scss/style.scss b/themes/pybcn_theme/assets/scss/style.scss index 7963c42c8..5baaa3466 100644 --- a/themes/pybcn_theme/assets/scss/style.scss +++ b/themes/pybcn_theme/assets/scss/style.scss @@ -13,6 +13,7 @@ @import 'agenda'; @import 'home'; @import 'monthly_events'; +@import 'listing_table'; @import 'talks'; @import 'facts'; @import 'meetup'; diff --git a/themes/pybcn_theme/layouts/partials/count_card.html b/themes/pybcn_theme/layouts/partials/count_card.html new file mode 100644 index 000000000..a27f78f11 --- /dev/null +++ b/themes/pybcn_theme/layouts/partials/count_card.html @@ -0,0 +1,91 @@ +{{- /* + The card of counts a row of a listing prints: what that row holds, + one icon and one number per kind of thing. + + [ (comment) 17 (camera) 20 (video) 3 ] + + Argument: a dict with items, a slice of dicts, each with: + kind a key of the vocabulary below: talk, photo, video, links, + or event + count how many of them the row holds + detail optional, a short text that belongs to the count, printed + after it in parentheses: the years of the events of a + person, "2016 - 2019" + + The three listings pass their own set, and the card does not know + which page it is on. A row of /events/ passes how many talks, photos, + and videos the day holds; a row of /talks/ how many photos, videos, + and other links the talk has; the Events table of a person page what + each talk of the person left behind. The counts come from + partials/listing_counts.html, one walk of the site, so the card and + the legend above the rows cannot disagree. The items print in the + order the caller gives them. + + An item with a count of zero is left out: a card that reads "0 + videos" is noise, and 245 of the 281 rows of /events/ would read it. + A row with nothing to count prints no card at all, not an empty box. + + The icon is the name and the number is the text. Each icon is an + image with the noun of its kind as its accessible name and as its + hover, the way the type markers of /events/ and /talks/ carry theirs + (see event_type_icon.html), so a screen reader hears "Talks 17" and a + mouse gets the same word. A bare aria-hidden icon next to a number + would leave the reader with "17" and nothing to say what it counts. + The noun agrees with the count, "Talk 1" and "Talks 17", because the + name is read with the number beside it. The card is a list, so a + reader who moves by list item gets one count at a time and can skip + the lot. + + Why Font Awesome and not an emoji. The type of a row is an emoji + (data/event_emoji.yaml) and a count is a Font Awesome icon, which is + the one split the site keeps: x.com and YouTube have no emoji, so a + brand mark has to be Font Awesome, and the counts sit next to those + marks. Font Awesome is loaded on every page. + + The vocabulary. Five kinds, each with an icon and the noun in the + singular and the plural. "links" is every link of a talk record that + is not its video, under one mark, the chain link of Font Awesome: the + slides, the code, a notebook, a site. The kind was "slides" and + counted that one type, so a talk with a slides link and a notebooks + link said 1 where its page showed 2 (partials/listing_counts.html has + the figures). The talk and the photo take their icon from + event_types.html, the vocabulary of agenda entry types, by token, so + the comment of a talk and the camera of a photo are the glyphs the + programme grid already draws for them, and a change there reaches + here. The nouns are this table's own: the "photo" entry of that table + is the group photo slot of a programme, and a photo counted here is a + picture of a gallery. The other three kinds are not entry types, + which is why they are not added to that table: a token of + event_types.html is a value a talk's type field may hold, and a talk + typed "video" or "event" has to stay the warning + partials/event_type_icon.html logs for it. Icon classes come from + this table only; a caller selects a kind, it never supplies markup. + An unknown kind prints nothing and logs a build warning, as an + unknown type does. +*/ -}} +{{- $types := partialCached "event_types.html" "event_types" -}} +{{- $kinds := dict + "talk" (dict "icon" (index $types "talk").icon "one" "Talk" "many" "Talks") + "photo" (dict "icon" (index $types "photo").icon "one" "Photo" "many" "Photos") + "video" (dict "icon" "fa-video" "one" "Video" "many" "Videos") + "links" (dict "icon" "fa-link" "one" "Link" "many" "Links") + "event" (dict "icon" "fa-calendar-day" "one" "Event" "many" "Events") +-}} +{{- $items := slice -}} +{{- range .items -}} + {{- $count := int (default 0 .count) -}} + {{- if not $count }}{{ continue }}{{ end -}} + {{- $kind := index $kinds .kind -}} + {{- if not $kind -}} + {{- warnf "count_card: unknown kind %q." .kind -}} + {{- continue -}} + {{- end -}} + {{- $items = $items | append (dict "icon" $kind.icon "name" (cond (eq $count 1) $kind.one $kind.many) "count" $count "detail" (default "" .detail)) -}} +{{- end -}} +{{- with $items }} + +{{- end }} diff --git a/themes/pybcn_theme/layouts/partials/event_type.html b/themes/pybcn_theme/layouts/partials/event_type.html new file mode 100644 index 000000000..873e68827 --- /dev/null +++ b/themes/pybcn_theme/layouts/partials/event_type.html @@ -0,0 +1,47 @@ +{{- /* + The type of an event page, as a key of data/event_emoji.yaml. + + Argument: the event page. Returns the token, "*" when no rule matches. + + The order of precedence is the one the header of data/event_emoji.yaml + explains: the series from the folder and then the title, the PyLadies + BCN group, the kind from a word of the title with workshop last, the + meetups folder, and the fallback. This partial is the one place the + rules live, so the row of /events/ and the legend above it, which + counts the rows by type, cannot disagree about one event. + + The title is lowered once: the archive writes "Dojo", "dojo" and + "Coding Dojo", and "Practice Sessions" with a capital. +*/ -}} +{{- $type := "*" -}} +{{- $folder := path.Base .File.Dir -}} +{{- $title := lower .Title -}} +{{- $group := default "" .Params.group -}} +{{- if eq $folder "pyday_bcn" -}} + {{- $type = "pyday" -}} +{{- else if eq $folder "pydatabcn" -}} + {{- $type = "pydata" -}} +{{- else if and (ne $folder "meetups") (hasPrefix $title "pydata") -}} + {{- $type = "pydata" -}} +{{- else if and (ne $folder "meetups") (hasPrefix $title "pyday") -}} + {{- $type = "pyday" -}} +{{- else if eq $group "PyLadies BCN" -}} + {{- $type = "pyladies" -}} +{{- else if strings.Contains $title "dojo" -}} + {{- $type = "dojo" -}} +{{- else if strings.Contains $title "practice session" -}} + {{- $type = "practice" -}} +{{- else if strings.Contains $title "barbecue" -}} + {{- $type = "barbecue" -}} +{{- else if or (strings.Contains $title "hacktoberfest") (strings.Contains $title "hackathon") -}} + {{- $type = "hackathon" -}} +{{- else if strings.Contains $title "sprint" -}} + {{- $type = "sprint" -}} +{{- else if strings.Contains $title "coderetreat" -}} + {{- $type = "coderetreat" -}} +{{- else if strings.Contains $title "workshop" -}} + {{- $type = "workshop" -}} +{{- else if eq $folder "meetups" -}} + {{- $type = "meetup" -}} +{{- end -}} +{{- return $type -}} diff --git a/themes/pybcn_theme/layouts/partials/hero_header.html b/themes/pybcn_theme/layouts/partials/hero_header.html index 7aa932901..b236a1929 100644 --- a/themes/pybcn_theme/layouts/partials/hero_header.html +++ b/themes/pybcn_theme/layouts/partials/hero_header.html @@ -38,6 +38,12 @@

{{ .Params.title }}

than itself. A page with no title_lang gets the h1 it had. */ -}} {{- $title := or .Params.name .Title -}} {{- $title_lang := partial "title_lang.html" (dict "title_lang" .Params.title_lang "title" $title) -}} -{{ $title }} +{{- /* The three listings of the archive carry their total in the h1, + "Events (281)", counted from the pages this build holds by + partials/listing_total.html, so the heading says how long the list + under it is and cannot say a number the rows do not add up to. Any + other page gets the h1 it had. */ -}} +{{- $total := partial "listing_total.html" . -}} +{{ $title }}{{ with $total }} ({{ . }}){{ end }} {{ end }} {{ end }} diff --git a/themes/pybcn_theme/layouts/partials/jump_bar.html b/themes/pybcn_theme/layouts/partials/jump_bar.html new file mode 100644 index 000000000..1ab67919c --- /dev/null +++ b/themes/pybcn_theme/layouts/partials/jump_bar.html @@ -0,0 +1,49 @@ +{{- /* + A row of links to the groups of a long index, above the groups + themselves. + + Arguments: + label the aria-label of the nav, "Jump to a letter" + items a slice of dicts, each with: + anchor the id of the heading of the group + text what the link reads + class optional, one more class on the nav: /people/ passes + jump_bar_centered, which centres the letters (David Arcos: + "el indice de letras, perfecto, pero ponlo centrado"), and + /events/ keeps its years at the left edge of the table + under them. See .jump_bar in main.scss. + + Two pages of this site are one list of several hundred rows under a + heading per group, and neither of them can be read to the far end without + scrolling the lot: /people/, 378 rows under 24 letters, and /events/, 281 + rows under 20 years. This is the bar that takes a reader to one group, + written once so the two read the same way and stay that way. + + A list of links and not a run of anchors, so a reader who moves by list + item gets them one at a time, and a nav so that the same reader can skip + the lot. The label is an argument because "Jump to a letter" and "Jump to + a year" name what the bar does on each page, and a nav with no label is + one more unnamed landmark. + + Only the groups the page has. A caller passes the groups it is about to + draw, so a bar never sends a reader to a heading that is not there. + + The anchor is the id of the group, which both lists already carry for + a reason of their own: /people/#M and /events/#2019 are addresses other + pages link to. On /people/ it is the h2 of the letter, and on /events/ + the year cell that heads the rows of the year, which is a table. The + element carries scroll-margin-top, so a jump lands under the fixed + navigation bar and not behind it, and from the lg breakpoint under + this bar as well, which is pinned beneath the navigation there (see + .jump_bar in main.scss for why only there). See people_index.scss and + listing_table.scss. +*/ -}} +{{- with .items }} + +{{- end }} diff --git a/themes/pybcn_theme/layouts/partials/listing_counts.html b/themes/pybcn_theme/layouts/partials/listing_counts.html new file mode 100644 index 000000000..114e270d0 --- /dev/null +++ b/themes/pybcn_theme/layouts/partials/listing_counts.html @@ -0,0 +1,155 @@ +{{- /* + What the three listings of the archive hold, counted once per build. + + Returns a dict: + + events a map from event type (a key of data/event_emoji.yaml) + to the number of rows of /events/ that carry it + events_total the number of rows of /events/ + talks a map from talk type (a key of event_types.html) to + the number of rows of /talks/ that carry it + talks_total the number of rows of /talks/ + people_total the number of rows of /people/ + event_rows a map from the RelPermalink of a row of /events/ to + what that day holds: talks, photos, videos + talk_rows a map from the RelPermalink of a row of /talks/ to + what that talk has: photos, videos, links + + The three maps feed partials/count_card.html, the card a row of each + listing prints its counts in, so the card and the legend of a page + come from the same walk, and the rule of what a count counts is here + and in no layout. + + What a count counts. The talks of a day are its talks block, and its + photos are what the gallery of its page renders, read from + partials/gallery_content.html with the key the page itself uses, so a + count on the row of /events/ is the count a reader finds on the page: + a photo the gallery skips is not counted (bin/check-content reports + it). The videos of a day are every video link its page prints: the + links of type video of its talks, the link of type video of a slot of + its agenda that holds no talk (the Welcome Session of PyDay BCN 2020 + is the one case), and the entries of its session_videos block, the + recording of a whole session. They were read from the gallery until + 2026-10-14, and the slot video was never counted then, because the + gallery read only the talks; the gallery of an edition lists the + videos again since 2026-10-15 (see the header of + partials/gallery_content.html), and this count keeps reading the + front matter, so the two agree by reading the same records. The photos of a talk + are the photos of that gallery that name the talk, the videos of a + talk are its links of type video, and the links of a talk are every + other link of its record, whatever the type. The card counted a link + only when its type was slides, and the record of a talk carries six + types: measured over every talk and agenda row of the content on + 2026-10-15, github 26, site 52, slides 18, notebooks 1, link 1, and + video 114, so four of the six were invisible to the card, and the + page of introduction-to-analog-quantum-computing (PyDay BCN 2025) + showed two links while its row said one. One count under one mark, + because a mark per type would put five marks on a row to say very + little: 74 talks have exactly one link that is not the video, 8 have + two, 1 has three, and 1 has five. A slot + video and a session video belong to the day and to no talk, so each + counts once, on the row of the day, and on no row of /talks/. The map + person_talks, the talks of each person counted from the speaker + credits, left with the card of the row of /people/ (2026-10-15): the + row says the number of events in a sentence, and the page of the + person lists the talks themselves, one row each. + + No argument is read: the data comes from site.RegularPages and, for + the people, from site.Pages. Call it through partialCached with a + constant variant, so the site is walked once per build and not once + per legend and once per heading: + + partialCached "listing_counts.html" site "listing-counts" + + The rule of what is a row is the rule of the page that draws it, and + nothing else, so that a legend or a heading counts what the page + renders. A row of /events/ is a regular page of the events section + that carries a year, which is the test of + layouts/_default/events_index.html, and its type comes from + partials/event_type.html, the partial the row reads its marker from. + A row of /talks/ is a talk page with a year to group it under, the + year of its event or else the date of the page, which is the test of + layouts/talks/list.html, and its type is what partials/talk_record.html + resolves for it; that partial is resolved on each call (its header + says why it is not cached). A talk with no type has a row and no + marker, so it counts in the total and in no type. A row of /people/ is + a page of the people section with an id and a name, which is the test + of layouts/people/list.html, read from site.Pages the way that layout + reads them. They are regular pages as well since the person pages + came back (config.toml has the history); the read did not change. + + Drafts are not skipped, because neither page skips them: a build with + -D draws them, and the count has to be the count of that build. +*/ -}} +{{- $events := dict -}} +{{- $events_total := 0 -}} +{{- $talks := dict -}} +{{- $talks_total := 0 -}} +{{- $event_rows := dict -}} +{{- $talk_rows := dict -}} +{{- range $pg := site.RegularPages -}} + {{- if eq $pg.Section "events" -}} + {{- if not $pg.Params.year }}{{ continue }}{{ end -}} + {{- $events_total = add $events_total 1 -}} + {{- $type := partial "event_type.html" $pg -}} + {{- $events = merge $events (dict $type (add 1 (int (default 0 (index $events $type))))) -}} + {{- $day_talks := 0 -}} + {{- with $pg.Params.talks }}{{ $day_talks = len . }}{{ end -}} + {{- /* The video links of the day, as the page prints them: on each + talk, on a slot of the agenda with no talk (a slot with a + talk prints the links of the talk, which are counted once + above), and in the session_videos block. The type is read + through with, as partials/gallery_content.html read it: a + link that declares none is a record no template places. */ -}} + {{- $day_videos := 0 -}} + {{- range $pg.Params.talks -}} + {{- range .links }}{{ with .type }}{{ if eq . "video" }}{{ $day_videos = add $day_videos 1 }}{{ end }}{{ end }}{{ end -}} + {{- end -}} + {{- range $pg.Params.agenda -}} + {{- if .talk }}{{ continue }}{{ end -}} + {{- range .links }}{{ with .type }}{{ if eq . "video" }}{{ $day_videos = add $day_videos 1 }}{{ end }}{{ end }}{{ end -}} + {{- end -}} + {{- range $pg.Params.session_videos -}} + {{- with .url }}{{ $day_videos = add $day_videos 1 }}{{ end -}} + {{- end -}} + {{- $event_rows = merge $event_rows (dict $pg.RelPermalink (dict + "talks" $day_talks + "photos" 0 + "videos" $day_videos)) -}} + {{- else if eq $pg.Section "talks" -}} + {{- $record := partial "talk_record.html" $pg -}} + {{- $year := 0 -}} + {{- with $record.event }}{{ with .Params.year }}{{ $year = int . }}{{ end }}{{ end -}} + {{- if and (not $year) (not $pg.Date.IsZero) }}{{ $year = $pg.Date.Year }}{{ end -}} + {{- if not $year }}{{ continue }}{{ end -}} + {{- $talks_total = add $talks_total 1 -}} + {{- with $record.entry.type -}} + {{- $type := lower (trim (printf "%v" .) " ") -}} + {{- $talks = merge $talks (dict $type (add 1 (int (default 0 (index $talks $type))))) -}} + {{- end -}} + {{- $photos := 0 -}} + {{- $videos := 0 -}} + {{- $links := 0 -}} + {{- with $record.talk -}} + {{- range .links -}} + {{- if not .url }}{{ continue }}{{ end -}} + {{- if eq (default "" .type) "video" }}{{ $videos = add $videos 1 }}{{ else }}{{ $links = add $links 1 }}{{ end -}} + {{- end -}} + {{- end -}} + {{- $talk_rows = merge $talk_rows (dict $pg.RelPermalink (dict "photos" $photos "videos" $videos "links" $links)) -}} + {{- end -}} +{{- end -}} +{{- $people_total := 0 -}} +{{- range where site.Pages "Section" "people" -}} + {{- if not .Params.id }}{{ continue }}{{ end -}} + {{- if not .Params.name }}{{ continue }}{{ end -}} + {{- $people_total = add $people_total 1 -}} +{{- end -}} +{{- return dict + "events" $events + "events_total" $events_total + "talks" $talks + "talks_total" $talks_total + "people_total" $people_total + "event_rows" $event_rows + "talk_rows" $talk_rows -}} diff --git a/themes/pybcn_theme/layouts/partials/listing_total.html b/themes/pybcn_theme/layouts/partials/listing_total.html new file mode 100644 index 000000000..72195e015 --- /dev/null +++ b/themes/pybcn_theme/layouts/partials/listing_total.html @@ -0,0 +1,30 @@ +{{- /* + The number of rows a listing page draws, or 0 for any other page. + + Argument: the page. Returns an int. + + Three pages are a list of the whole archive: /events/, /talks/, and + /people/. Their h1 carries the total, and this is where the page is + told apart from the rest. /events/ is the one page that selects the + events_index layout; the series pages under it are sections of the + same section and keep the fallback list. /talks/ and /people/ are the + section page of a section that holds no other section, so the kind + and the section name are enough. + + The totals come from partials/listing_counts.html, cached once per + build, and are counted by the rule of the page that draws the rows: + the legend of the page, the heading above it, and the rows add up. +*/ -}} +{{- $total := 0 -}} +{{- $listing := "" -}} +{{- if eq .Layout "events_index" -}} + {{- $listing = "events_total" -}} +{{- else if and (eq .Kind "section") (eq .Section "talks") -}} + {{- $listing = "talks_total" -}} +{{- else if and (eq .Kind "section") (eq .Section "people") -}} + {{- $listing = "people_total" -}} +{{- end -}} +{{- with $listing -}} + {{- $total = index (partialCached "listing_counts.html" site "listing-counts") . -}} +{{- end -}} +{{- return $total -}} diff --git a/themes/pybcn_theme/layouts/partials/marker_legend.html b/themes/pybcn_theme/layouts/partials/marker_legend.html new file mode 100644 index 000000000..4940e066d --- /dev/null +++ b/themes/pybcn_theme/layouts/partials/marker_legend.html @@ -0,0 +1,46 @@ +{{- /* + The legend of a listing: each marker the page draws, with how many + rows carry it. + + 👥 155 monthly meetups · 👭 71 PyLadies BCN meetups · 🥋 23 coding dojos + + Arguments: + label the aria-label of the list, "Event types" or "Talk types" + items a slice of dicts, one per entry of the vocabulary, each with: + emoji the glyph of an event type, or + type the token of a talk type, for event_type_icon.html + one the noun after a count of 1, "coding dojo" + many the noun after any other count, "coding dojos" + count how many rows of the page carry the marker + + Only the markers the page has. An item with a count of zero is left + out, so the legend names what the reader can see and nothing the + vocabulary could hold: /talks/ lists no coffee break. The items are + sorted here, by count and then by noun, so the two pages read the same + way and two builds give one order. + + The counts come from partials/listing_counts.html and the markers from + the vocabulary the rows read, so the legend cannot name a marker the + rows do not draw. It is the second way to the name of a marker: the + first is the accessible name and the hover of the marker itself, and a + touch screen has no hover. + + The glyph is decorative here. The words beside it are its meaning, and + a screen reader hears the name on the row itself, so a name here would + be read twice. The whole line is a list, so a reader who moves by list + item gets one marker at a time and can skip the lot. +*/ -}} +{{- $items := slice -}} +{{- range .items -}} + {{- if not .count }}{{ continue }}{{ end -}} + {{- $items = $items | append (merge . (dict "key" (printf "%06d %s" (sub 999999 .count) .many))) -}} +{{- end -}} +{{- with $items }} +
    + {{- range sort . "key" }} +
  • + {{- with .emoji }}{{ end -}} + {{- with .type }}{{ partial "event_type_icon.html" . }}{{ end }} {{ .count }} {{ cond (eq .count 1) .one .many }}
  • + {{- end }} +
+{{- end }} From 55f630d100103a6ac930771a26f59d39b899b859 Mon Sep 17 00:00:00 2001 From: David Arcos Date: Thu, 15 Oct 2026 10:15:41 +0200 Subject: [PATCH 2/7] Set /events/ as a table of six columns, grouped by year and by month layouts/_default/events_index.html lists every event the association has organized, 281 rows in this tree, newest first: the year, the month, the day, the title with its type glyph, the venue, and the card of counts. The year and the month print once per group and span the rows under them. A table and not a grid of list items, because the content is six facts per row that a reader compares down a column, and a table gives a screen reader the header cells for free. The day column prints the first day of an edition that ran more than one day and not its range: the range stays on the page of the edition, through partials/event_date.html. content/events/_index.md takes the layout and opens with a line about what the list is. data/venues.yaml maps the exact venue string of an event to the short name the list prints: 87 venues, 44 of them shortened, 70 short names. The page of the meetup keeps the full string; only the column is short, and it carries the full name as its hover only when that name says more than the short one once case and accents are folded away, because the table restores the accents the source lost and a hover that shows the same name with one accent fewer says nothing. bin/check-content gains check_venues, which reads the table against the pages in both directions: every venue string of an event has a row, and every row is the venue of at least one event. Without the first rule a long name comes back in the list in silence the day an event arrives with a venue the table does not know; without the second a row no page uses is a row nobody checks. The summary line reports the table. AGENTS.md names the two data files and the two new failures. --- AGENTS.md | 4 +- bin/check-content | 107 +++++ content/events/_index.md | 3 + data/venues.yaml | 118 ++++++ .../layouts/_default/events_index.html | 384 ++++++++++++++++++ 5 files changed, 614 insertions(+), 2 deletions(-) create mode 100644 data/venues.yaml create mode 100644 themes/pybcn_theme/layouts/_default/events_index.html diff --git a/AGENTS.md b/AGENTS.md index 1802763dc..c924f1a8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,7 @@ plausible but different result. | `themes/pybcn_theme/` | The theme, **vendored in-tree**, not a submodule | | `themes/pybcn_theme/layouts/partials/` | Most of the interesting template logic | | `static/archives/` | Verbatim mirrors of pre-Hugo sites, copied unprocessed | -| `data/` | Tables the templates read: `roles.yaml` maps a heading to a role, `role_emoji.yaml` a role to a glyph | +| `data/` | Tables the templates read: `roles.yaml` maps a heading to a role, `role_emoji.yaml` a role to a glyph, `event_emoji.yaml` an event to a glyph, `venues.yaml` the exact `venue` string of an event to the short name the `/events/` list prints (the page of the meetup keeps the full string) | | `bin/` | Setup and build scripts, the checks, `bin/list-channel` (names a channel video the inventory lacks; needs `yt-dlp` and the network), the one-time `bin/import-*` scripts, plus the gitignored Hugo binary | | `.hugo-version` | The pinned Hugo version, single source of truth | @@ -206,7 +206,7 @@ The checks, which are what a pull request has to pass: | Script | What it rejects | |---|---| -| `bin/check-content` | A broken reference, a photo that is not square, a source far larger than the build asks for, a field that no longer exists, an `end_date` that is not a date or not later than `date` | +| `bin/check-content` | A broken reference, a photo that is not square, a source far larger than the build asks for, a field that no longer exists, a `venue` string with no row in `data/venues.yaml`, a row of that table no event uses, an `end_date` that is not a date or not later than `date` | | `bin/check-html-safety` | Dangerous raw HTML in content, with an allowlist for the legitimate embeds | | `bin/check-rendered` | The same, on the built pages, plus any `http://` URL. Run it against a directory you built, never against `public/` while a dev server is writing there | diff --git a/bin/check-content b/bin/check-content index ccbd6fb37..3a4e4604b 100755 --- a/bin/check-content +++ b/bin/check-content @@ -6,6 +6,12 @@ match its filename, a duplicate id, a page that lists a person or sponsor id with no page behind it, a declared photo with no file behind it, and a remote photo_url. It warns about a person photo with no colour in it. +For the venues it checks data/venues.yaml, the short name the /events/ list +prints for each venue, against the pages: every venue string of an event +front matter has a row, and every row is the venue of some event. A venue +with no row prints its full string in the list, and a row with no event is +a name the site stopped using. + For the days it checks end_date, which an event carries when it ran more than one day: it is a date, and it is later than date. The template prints one day for an end_date it cannot use, so the mistake is silent on the page. @@ -807,6 +813,104 @@ def check_links(path, label, links): f"else is listed under Repository.") +# The short name of each venue, keyed by the exact string the venue field of +# an event carries. layouts/_default/events_index.html prints the short name +# in the /events/ list and falls back to the full string for a venue with no +# row, which is the gap check_venues reports. +VENUES = ROOT / "data" / "venues.yaml" + +# What check_venues counted, for the summary line. +_venue_summary = None + + +def page_venues(): + """Return {venue string: [path]} for every venue of content/events/. + + The venue field holds the name the way its source wrote it, one string + per page, and the string is the key: two spellings of one place are two + entries here and two rows of data/venues.yaml. A venue that is not a + string is reported and skipped, because the template would print it + as text and the table could never hold a row for it. + """ + found = {} + for path in sorted((CONTENT / "events").rglob("*.md")): + data = front_matter(path) + if data is None or "venue" not in data: + continue + venue = data["venue"] + if not isinstance(venue, str) or not venue.strip(): + error(path, f"venue holds {venue!r}, which is not a name. It is the " + f"place as its source wrote it, one string.") + continue + found.setdefault(venue, []).append(path) + return found + + +def check_venues(): + """Check data/venues.yaml against the venue field of the event pages. + + Two rules, one per direction, so the table cannot rot either way: + + 1. every venue string of an event front matter has a row. The list + prints the full string for a venue with no row, so a long name + comes back in silence the day an event arrives with a venue the + table does not know. + 2. every row is the venue of at least one event. A row no page uses + is a name the site stopped using, or a string somebody edited in + the event and not here, and either way it is a row nobody checks. + + Also reported: a row whose short name is empty or not a string, since + the list would print nothing for that venue. + + Report, never crash: a missing or malformed table is an error with the + reason, and the rest of the check still runs. + """ + global _venue_summary + venues = page_venues() + if not VENUES.is_file(): + error(VENUES, "is missing. It is the short name of each venue, which " + "the /events/ list prints, one row per venue string of " + "the event pages.") + return + try: + table = yaml.safe_load(VENUES.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + error(VENUES, f"is not valid YAML: {exc}") + return + if not isinstance(table, dict): + error(VENUES, f"holds {type(table).__name__}, not a mapping of the " + f"venue string to its short name") + return + + for long_name, short_name in table.items(): + label = f"row {long_name!r}" + if not isinstance(long_name, str): + error(VENUES, f"{label} has a key that is not a string. The key is " + f"the venue string exactly as the event carries it.") + continue + if not isinstance(short_name, str) or not short_name.strip(): + error(VENUES, f"{label} has the short name {short_name!r}. It is the " + f"name the list prints, so it is a non-empty string.") + if long_name not in venues: + error(VENUES, f"{label} is the venue of no event page. Remove the " + f"row, or fix the venue of the event that renamed it.") + + for long_name, paths in sorted(venues.items()): + if long_name in table: + continue + for path in paths: + error(path, f"venue {long_name!r} has no row in data/venues.yaml. " + f"Add the row with the short name the /events/ list " + f"prints; without it the list prints the full string.") + + pages = sum(len(paths) for paths in venues.values()) + shortened = sum(1 for k, v in table.items() + if isinstance(k, str) and isinstance(v, str) and k != v) + _venue_summary = (f"{len(table)} venues in data/venues.yaml, {shortened} " + f"shortened, {len(set(table.values()))} short names; " + f"{len(venues)} distinct venue strings on {pages} pages.") + + def as_date(value): """The date a front matter value holds, or None when it holds none. @@ -1072,6 +1176,7 @@ def main(): listed_people = check_listings(people, sponsors) check_agenda(people) check_dates() + check_venues() check_orphans(people, listed_people) check_security_txt() check_association() @@ -1084,6 +1189,8 @@ def main(): talks = sum(len(ids) for ids in _talk_ids.values()) print(f"\n{len(people)} people, {len(sponsors)} sponsors, " f"{talks} talks on {len(_talk_ids)} pages checked.") + if _venue_summary: + print(_venue_summary) print(f"{len(errors)} errors, {len(warnings)} warnings.") return 1 if errors else 0 diff --git a/content/events/_index.md b/content/events/_index.md index 137709adc..c8350f358 100644 --- a/content/events/_index.md +++ b/content/events/_index.md @@ -3,4 +3,7 @@ title: "Events" description: "" menu: "main" weight: 4 +layout: events_index --- + +The association runs workshops and talks [every month](/events/monthly_events/). The list below is every event it has organized, the most recent first: the monthly meetups since 2007, and the larger days, PyDay BCN and PyDataBCN. diff --git a/data/venues.yaml b/data/venues.yaml new file mode 100644 index 000000000..03951072e --- /dev/null +++ b/data/venues.yaml @@ -0,0 +1,118 @@ +# The short name the /events/ list prints for each venue. Left side: the +# venue string exactly as the front matter of the event carries it, which +# is the name the way its source wrote it, the Meetup export for the +# monthly meetups. Right side: the name a person in Barcelona says out +# loud. A row drops a marketing tagline ("El teu coworking a Gràcia"), an +# address ("Carrer Pallars 193."), a room ("Aula B7", "4ª Planta"), and a +# legal suffix ("and Company"), and it keeps a disambiguator when two +# venues would otherwise collide: three UPF places and three UPC places +# each keep the campus or the building in parentheses. It carries the +# accents of the real name even when the source lost them ("Biomedica", +# "Cibernarium"), because the short name is what the site prints. +# +# Every venue string of the content has a row, even when the short name +# is the long one, so this file is the full inventory and a reader can +# see what the site shows for each. Two rows with one short name are two +# spellings of one place: "Parc de Recerca Biomedica de Barcelona (PRBB)" +# and "Parc de Recerca Biomèdica de Barcelona (PRBB)" differ by one +# accent and the list printed them as two venues. The rows are sorted by +# the short name and then by the long one, so those rows sit together. +# +# Only the /events/ list reads this table, through +# layouts/_default/events_index.html. The page of a meetup keeps the full +# string: that page is about that day, and the full string is the fact its +# source recorded. In the list the short name carries the full one as its +# hover and as its accessible name, so nothing is lost by shortening. +# +# A venue with no row prints its full string in the list, and +# bin/check-content reports it as an error, the way it reports a row +# that no event uses, so the table cannot rot in either direction. +# Add the row here; never shorten the string in the event file. + +"21 buttons": "21 Buttons" +"Adevinta Spain": "Adevinta" +"AlSur Cafe": "AlSur Cafe" +"Arc Cafe": "Arc Cafe" +"Aticco": "Aticco" +"Aticco Bogatell - Coworking en Poblenou": "Aticco Bogatell" +"Aticco Verdaguer - El teu coworking a Gràcia": "Aticco Verdaguer" +"Bar del Pi": "Bar del Pi" +"Belchica": "Belchica" +"BitPhy": "BitPhy" +"Barcelona Supercomputing Center - Centro Nacional de Supercomputación": "BSC" +"bsport Tasso office": "bsport" +"Bynder": "Bynder" +"C/ de Sardenya, 229": "C/ de Sardenya, 229" +"C/ dels Almogàvers, 160": "C/ dels Almogàvers, 160" +"CahootCoworking Barcelona": "Cahoot Coworking" +"Can Gavatxo": "Can Gavatxo" +"Canòdrom": "Canòdrom" +"Canòdrom - Ateneu d'Innovació Digital i Democràtica": "Canòdrom" +"Carrer de Tànger, 122": "UPF (Poblenou)" +"Auditori Cibernàrium - MEDIATIC": "Cibernàrium MediaTIC" +"Cibernarium MediaTIC (Barcelona Activa)": "Cibernàrium MediaTIC" +"CloudBlue": "CloudBlue" +"Cloudworks": "Cloudworks" +"CocoVail Beer Hall - Craft Beer Barcelona": "CocoVail Beer Hall" +"Codely at Spaces22@ coworking, Carrer Pallars 193.": "Codely" +"Codurance": "Codurance" +"ColorSensing": "ColorSensing" +"Diagonal 123": "Diagonal 123" +"Dow Jones": "Dow Jones" +"Dow Jones and Company": "Dow Jones" +"Erni Consulting": "Erni Consulting" +"ESADE": "ESADE" +"espai30": "espai30" +"gaa9": "gaa9" +"Gartner - Center of Excellence": "Gartner" +"Oficinas de Glovo (Yellow Park)": "Glovo" +"Gran Via de les Corts Catalanes, 613": "Gran Via de les Corts Catalanes, 613" +"Happy Scribe - Servicios de transcripcion": "Happy Scribe" +"Holaluz": "Holaluz" +"Ironhack Barcelona Tech bootcamps": "Ironhack" +"itnig": "itnig" +"Kernel Analytics": "Kernel Analytics" +"Kiwi.com": "Kiwi.com" +"La Cervesera Artesana": "La Cervesera Artesana" +"Le Wagon Barcelona": "Le Wagon" +"letgo": "letgo" +"Maik Röder": "Maik Röder" +"Marfeel": "Marfeel" +"MediaTIC Building": "Cibernàrium MediaTIC" +"Michael Collins": "Michael Collins Pub" +"Michael Collins Pub": "Michael Collins Pub" +"Mundimoto": "Mundimoto" +"Ocado Technology": "Ocado Technology" +"Online": "Online" +"Onna HQ": "Onna" +"Pasaje Montoya": "Pasaje Montoya" +"Play Point Barcelona": "Play Point" +"Parc de Recerca Biomedica de Barcelona (PRBB)": "PRBB" +"Parc de Recerca Biomèdica de Barcelona (PRBB)": "PRBB" +"Punt Multimedia": "Punt Multimèdia" +"Punt Multimèdia Espanya Industrial": "Punt Multimèdia" +"Punt Multimèdia La Casa del Mig": "Punt Multimèdia" +"Satellogic": "Satellogic" +"Servizurich - Technology Delivery Center": "Servizurich" +"Skyscanner": "Skyscanner" +"Skyscanner's offices": "Skyscanner" +"Sol Soler": "Sol Soler" +"Tiendeo": "Tiendeo" +"TravelPerk": "TravelPerk" +"Travelperk": "TravelPerk" +"TravelPerk 4ª Planta": "TravelPerk" +"Universitat de Barcelona": "UB" +"Edifici Històric UB (Aula B7)": "UB" +"Edifici Històric UB (Aula t1)": "UB" +"Facultat de Matemàtiques i Informàtica - UB": "UB" +"Facultat de Matemàtiques/ Faculty of Mathematics": "UB" +"Un lugar de gracia": "Un lugar de gracia" +"Campus Nord UPC": "UPC (Campus Nord)" +"Industrials UPC 8a Planta": "UPC (Industrials)" +"Vertex VS206A": "UPC (Campus Nord)" +"Universitat Pompeu Fabra - Campus de França": "UPF (Campus de França)" +"UPF Music Technology Group": "UPF (MTG)" +"UPF, Campus de la Comunicación - Poblenou": "UPF (Poblenou)" +"Verse": "Verse" +"Wojo Coworking Barcelona Poblenou - Alquiler oficinas y salas de reuniones": "Wojo Coworking" +"Worldsensing": "Worldsensing" diff --git a/themes/pybcn_theme/layouts/_default/events_index.html b/themes/pybcn_theme/layouts/_default/events_index.html new file mode 100644 index 000000000..7219e5de0 --- /dev/null +++ b/themes/pybcn_theme/layouts/_default/events_index.html @@ -0,0 +1,384 @@ +{{- /* + The /events/ page: every event the association has organized, in one + table, grouped by year and by month, newest first. + + Year Month Day Title Venue Gallery + 2025 Nov 29 🐍 PyDay BCN (2025) Canòdrom [ 17 talks 20 photos ] + Jun 11 👥 Python Meetup June ... Codurance [ 2 talks ] + 2024 Sep 26 👥 Python Meetup September Codurance [ 2 talks 2 videos ] + 2017 May 19 📊 PyData Barcelona 2017 UPC [ 36 talks ] + + Six columns, in the order David Arcos named them: the year, the month, + the day, the title, the venue, and the gallery. The date comes first, + split in three, because the page is a sequence of days and the reader + runs down the date to find one. The year and the month print once, on + the first row of their group, and span the rows below them, so a column + of 281 repeated years is not printed. The day is on every row. + + Why a table. The content is tabular: David named six columns, every row + holds the same six facts, and a reader compares rows down a column. + A table gives that for free, and gives three things a grid of list + items would have to carry by hand: a header cell that spans its group, + which is what rowspan is for; a header row, so a screen reader hears + "Venue: Canòdrom" and not "Canòdrom"; and the table navigation of a + screen reader, which moves by column and row and announces the year and + the month of any cell from the header cells that cover it. A list of + grids would reflow more easily at 390px, and a table reflows too, at a + cost paid once in the stylesheet, below. + + The 390px problem. Six columns do not fit in 360px, and the page must + not scroll sideways. Under the lg breakpoint (992px) every row becomes + its own grid, in listing_table.scss, which says why lg and not md: the + year, when the row carries it, is a band across the row, the month + under it is a smaller band, and the row proper is the day at the left + of the title, with the venue and the card on one line under it. The year and the month are in the first row of their group and + in no other, which is what rowspan means, so at 390px they print once + there as well and read as the headings of a timeline. Changing the + display of a table element drops its table semantics in Chromium and + WebKit (the element becomes generic), which is why every table element + carries its role written out: table, rowgroup, row, columnheader, + rowheader, and cell. Redundant at 1280px, and the thing that keeps the + table a table at 390px. The header row is visually hidden there (the + bands say what the cells are) and stays in the tree for the roles to + point at. The reflow and the roles are the pattern Adrian Roselli + documents in "A Responsive Accessible Table". What a screen reader says + at 390px could not be checked here, only the roles that axe-core sees. + + Headers. Each year is a th of scope rowgroup in its own tbody, and each + month is a th of scope row, spanning its rows: a header cell with + rowspan heads every row it spans. The year carries its id, so + /events/#2019 links to a year from anywhere, which is what a person + page needs to point at the year somebody spoke, and the jump bar above + the table reads the same ids. The year used to be an h2 and the title + an h3: a table has its own outline, the headers, and a heading inside + a cell is a second one, so the page has one heading, the h1 of the + hero, which reads "Events (281)" from partials/listing_total.html. The + caption says the order and the one thing a rowspan table needs said. + + The day of an edition that ran more than one day. PyData Barcelona 2017 + ran 19 to 21 May 2017 and PyDay BCN 2016 ran 15 to 17 September 2016. + The day column prints the first day only, "19", for those two as for + every row: the column is where the reader runs down to find a day, + and a range there made the column as wide as "19 to 21" on all 281 + rows for the sake of two (David Arcos, 2026-10-15). The range stays + on the page of the edition, through partials/event_date.html, which + is where the dates of a day belong. The row is filed under the month + it started, so an edition that crossed a month would sit under its + first month with its first day. + + The marker belongs with the title: the glyph of the type, from + data/event_emoji.yaml through partials/event_type.html, sits at the + front of the title cell, as an image with its label as its accessible + name and its hover, both from the same field, so the two cannot say + different things. It used to be aria-hidden, on the ground that the + title says the type in words next to it, and that holds for the series + and for nothing else: "Python Meetup June" does not say that it was a + PyLadies BCN meetup, and "March Dojo: Coins kata" says dojo to a reader + who knows the word. The legend above the table says the same names with + their counts, for a touch screen, which has no hover. + + The venue prints as its short name, in italics, from data/venues.yaml, + keyed by the exact string the venue field carries: "Canòdrom" for + "Canòdrom - Ateneu d'Innovació Digital i Democràtica", "PRBB" for both + spellings of "Parc de Recerca Biomèdica de Barcelona (PRBB)". The field + holds the name the way its source wrote it, 74 characters for the + longest, and a cell has no room for a tagline and an address. The page + of the meetup still prints the full string: that page is about that + day, and the full string is the fact its source recorded. Nothing is + lost here either: the short name carries the full one as its hover + (title) and as its accessible name, through a visually hidden span + with the full string beside the short one, which is aria-hidden. Not + aria-label on the span: a span is a generic element, which ARIA does + not allow a name on, so Chromium ignores the attribute and axe-core + flags it, measured on the page before this markup was chosen. The same + holds for an abbr. A venue with no row prints its full string, and + bin/check-content reports it. The italics are a style and not an em + element: the venue is not stressed, it is set apart. + + The gallery is partials/count_card.html: the talks of the programme of + the day, the photos its gallery renders, and the videos its page prints, + an icon and a number each. The counts come from + partials/listing_counts.html, the one walk of the site that also feeds + the legend above the rows, so the card of a row and the legend cannot + disagree, and the rule of what a count counts (the videos of a day + include the recording of a whole session, which belongs to no talk) is + written there once for the three listings. + + Reached through layout: events_index in content/events/_index.md, and not + through a layouts/events/list.html, because Page.Section of a nested + section is its top level: an events/list.html would also draw + /events/pyday_bcn/, /events/pydatabcn/ and /events/other_events/, which + are index pages of a series and want the fallback list they have. + monthly_events.md selects its own layout the same way. + + What is a row. A page under content/events/ that carries a year in its + front matter. That is the test partials/person_appearances.html already + uses to tell an event from a role, and it keeps out the five pages of + this section that are not an edition: + + - the five _index.md files of the section and of the four series. + They are containers and hold no event of their own, and every event + they index has a row here. Hugo keeps them out of + RegularPagesRecursive as well, so the rule and the data agree. + - monthly_events.md. It is the page that sends a reader to Meetup for + what is coming next, not a day that happened, so there is no year to + file it under. The intro of this page links it, and it stays in the + main menu. + + Four of those five are in the main menu under Events. The fifth, + meetups/_index.md, is not: Events already carries Monthly Events, and + the meetups it indexes each have a row here, so the section page is + reachable without a second entry that says the same word twice. + + The year is .Params.year, which every row carries. All but one also + carry a date, and Hacktoberfest 2018 does not, because no source gives + its day. Its row prints the year and an empty month and day rather than + a guess: the two cells are td and not th, because an empty header cell + heads nothing (axe-core: empty-table-header). The list groups by + .Params.year and not by .GroupByDate so that the one edition with no + date keeps a row. + + The link is .RelPermalink, or the url of the menu entry of the page when + it declares one. One page does: hacktoberfest_2018.md, whose own page is + a heading and nothing else, and whose menu entry already sends a reader + to the copy of the 2018 site under /archives/. A row that linked the + empty page would be a dead end. + + No excerpt. The description of an edition that came from Meetup opens + with the logistics of the day, so the first words of an automatic + excerpt would be "Only register if you can attend". +*/ -}} +{{ define "main" }} +
+ + {{ with .Content }}
{{ . }}
{{ end }} + + {{- /* The counts of the legend and of the card of every row, from one + cached walk of the site: see partials/listing_counts.html. */ -}} + {{- $counts := partialCached "listing_counts.html" site "listing-counts" -}} + + {{- $byYear := dict -}} + {{- range .RegularPagesRecursive -}} + {{- if not .Params.year }}{{ continue }}{{ end -}} + {{- $year := int .Params.year -}} + + {{- /* The type, from the folder, the title, and the group, through + the one partial that holds the rules. See the header of + data/event_emoji.yaml. */ -}} + {{- $type := partial "event_type.html" . -}} + {{- $marker := default (index site.Data.event_emoji "*") (index site.Data.event_emoji $type) -}} + + {{- /* The date, in the parts the three date columns print, from + partials/event_date.html, which reads date and end_date. An + edition with no date leaves every part empty, and the row + prints empty cells. month_key is the month as yyyymm, the + key the rows of one year are grouped by: the name alone + would file "May 2017" and "May 2016" together if a year + ever held two, and it cannot, but the key costs nothing. */ -}} + {{- $when := partial "event_date.html" (dict "date" .Params.date "end_date" .Params.end_date) -}} + {{- $iso := "2006-01-02" -}} + {{- $date := dict "month" "" "month_key" "" "day" "" "start_iso" "" -}} + {{- with $when.start -}} + {{- $date = dict + "month" $when.month + "month_key" (time.Format "200601" .) + "day" $when.day + "start_iso" (time.Format $iso .) -}} + {{- end -}} + + {{- /* The venue: the short name from data/venues.yaml, with the + full string behind it, or the full string alone when the + table has no row for it. venue is the place, and it comes + from the Meetup export, which records one per event. It is + not rooms: that lists the rooms inside the venue, so its + first entry is the first column of the programme, which + printed "B6" where the reader expects "Universitat de + Barcelona". Each piece goes through the escaping of the + template and never through safeHTML: a venue is content, + and AGENTS.md says to assume any content field can inject + HTML. */ -}} + {{- $venue := dict -}} + {{- with .Params.venue -}} + {{- $short := index site.Data.venues . -}} + {{- /* The hover and the hidden full name are printed only when + the full string says more than the short name. The two + are compared with the case and the accents folded away: + data/venues.yaml restores the accents the source lost + ("Punt Multimedia" prints as "Punt Multimèdia") and the + capitals ("21 buttons"), and a hover that shows the same + name with one accent fewer says nothing (David Arcos, + 2026-10-15). Measured in the build of that day: 149 rows + with a hover, 3 of them this kind. */ -}} + {{- $short_key := "" -}} + {{- with $short }}{{ $short_key = lower . }}{{ end -}} + {{- $full_key := lower . -}} + {{- range $from, $to := (dict "[àáâä]" "a" "[èéêë]" "e" "[ìíîï]" "i" "[òóôö]" "o" "[ùúûü]" "u" "ç" "c" "ñ" "n") -}} + {{- $short_key = replaceRE $from $to $short_key -}} + {{- $full_key = replaceRE $from $to $full_key -}} + {{- end -}} + {{- if and $short (ne $short_key $full_key) -}} + {{- $venue = dict "text" $short "full" . -}} + {{- else -}} + {{- $venue = dict "text" (default . $short) -}} + {{- end -}} + {{- end -}} + + {{- /* What the day holds, for the card: the talks of its programme, + the photos its gallery renders, and the videos it prints. The card + leaves out a count of zero, so no row reads "0 photos", and + a day with nothing to count gets no card. */ -}} + {{- $facts := index $counts.event_rows .RelPermalink -}} + {{- $card := slice -}} + {{- with $facts -}} + {{- $card = slice + (dict "kind" "talk" "count" .talks) + (dict "kind" "photo" "count" .photos) + (dict "kind" "video" "count" .videos) -}} + {{- end -}} + + {{- $url := .RelPermalink -}} + {{- with .Params.menu }}{{ with .main }}{{ with .url }}{{ $url = . }}{{ end }}{{ end }}{{ end -}} + + {{- /* Within one year the rows read newest first, by date, the way + the years themselves do, and the months fall out of that + order as runs of rows: the grouping below cuts a run where + the month changes. The order used to be the title and then + the URL, borrowed from person_appearances.html, and it + stopped being readable the moment a year held more than one + or two rows: with the 266 monthly meetups, 2026 read 30 Apr, + 25 Feb, 30 Jun, 21 May, 16 Sep. A year of this list is a + sequence of days, and the day is on every row, so an order + that ignores it contradicts what the reader can see. + + The key is 99999999 minus the date as yyyymmdd, so an + ascending sort reads newest first, the way the groups use + 9999 minus the year. A row with no date gets the ordinal 0, + whose key is 99999999, and therefore sorts after every dated + row of its year. That is Hacktoberfest 2018, the one edition + of the archive that no source gives a day for: it keeps its + row, at the end of its year, rather than being guessed into + a place between two dated rows. The title and the URL stay + on the key as the tiebreak for two events on one day, so two + builds give one order. */ -}} + {{- $ordinal := 0 -}} + {{- with .Params.date }}{{ $ordinal = int (time.Format "20060102" .) }}{{ end -}} + {{- $row := merge $date (dict + "title" .Title + "url" $url + "emoji" $marker.emoji + "type_label" $marker.label + "venue" $venue + "card" $card + "key" (printf "%08d %s %s" (sub 99999999 $ordinal) .Title .RelPermalink)) -}} + {{- $key := string $year -}} + {{- $byYear = merge $byYear (dict $key (append $row (default slice (index $byYear $key)))) -}} + {{- end -}} + + {{- /* The groups: one per year, newest first, each holding its months, + newest first, each holding its rows. 9999 minus the year, so an + ascending sort reads newest first, the way + person_appearances.html orders the events of one person. + + The months are cut from the sorted rows of the year: a row + whose month_key differs from the one before it opens a month. + The sort by date puts the rows of one month together, so one + pass is enough. The row with no date has the empty key, sorts + last, and opens a month of its own with no name. */ -}} + {{- $groups := slice -}} + {{- range $year, $rows := $byYear -}} + {{- $sorted := sort $rows "key" -}} + {{- $months := slice -}} + {{- $open := "" -}} + {{- range $i, $row := $sorted -}} + {{- if or (not $i) (ne $row.month_key $open) -}} + {{- $months = $months | append (dict "month" $row.month "rows" (slice $row)) -}} + {{- $open = $row.month_key -}} + {{- else -}} + {{- $last := index $months (sub (len $months) 1) -}} + {{- $months = $months | first (sub (len $months) 1) | append (dict "month" $last.month "rows" ($last.rows | append $row)) -}} + {{- end -}} + {{- end -}} + {{- $groups = $groups | append (dict "year" $year "months" $months "count" (len $sorted) "key" (printf "%04d" (sub 9999 (int $year)))) -}} + {{- end -}} + + {{- $groups = sort $groups "key" -}} + + {{- /* The years, as links to the groups below, through the bar + partials/jump_bar.html draws for this page and for /people/. The + far end of 281 rows under 20 years is many screens of scrolling + away. Only the years that have a row are here, in the order the + groups are drawn, so the bar reads newest first the way the page + does. */}} + {{- $jump := slice -}} + {{- range $groups }}{{ $jump = $jump | append (dict "anchor" .year "text" .year) }}{{ end }} + {{ partial "jump_bar.html" (dict "label" "Jump to a year" "items" $jump) }} + + {{- /* The legend: each marker the table draws, with how many rows carry + it, through partials/marker_legend.html. The counts are the + ones read above, which come from the type of every row through + the same partial the rows do, so the legend and the rows cannot + disagree. It sits between the bar and the table, right above + the rows it explains. */ -}} + {{- $legend := slice -}} + {{- range $token, $entry := site.Data.event_emoji -}} + {{- $legend = $legend | append (dict "emoji" $entry.emoji "one" $entry.one "many" $entry.many "count" (int (default 0 (index $counts.events $token)))) -}} + {{- end }} + {{ partial "marker_legend.html" (dict "label" "Event types" "items" $legend) }} + + + + + + + + + + + + + + {{- range $groups }} + {{- $year := . }} + + {{- range $mi, $month := .months }} + {{- range $ri, $row := $month.rows }} + + {{- /* The year, on the first row of the year, spanning the + lot; the month, on the first row of the month, + spanning its own. Both as rowheader, and both with + aria-rowspan as well as rowspan, for the reflow + at 390px, where no table layout computes the span + for the tree. */ -}} + {{- if and (not $mi) (not $ri) }} + + {{- end }} + {{- if not $ri }} + {{- with $month.month }} + + {{- else }} + + {{- end }} + {{- end }} + + + + + + {{- end }} + {{- end }} + + {{- end }} +
Newest first. The year and the month print once, on the first row of their group.
YearMonthDayTitleVenueGallery
{{ $year.year }}{{ . }} + {{- with $row.day }}{{ end -}} + {{ $row.emoji }}{{ $row.title }} + {{- with $row.venue -}} + {{- with .full -}} + {{ . }} + {{- else -}} + {{ .text }} + {{- end -}} + {{- end -}} +
+ +
+{{ end }} From 4f5afae7f3545c675bc0a9c746ef3891eb327e96 Mon Sep 17 00:00:00 2001 From: David Arcos Date: Thu, 15 Oct 2026 10:15:41 +0200 Subject: [PATCH 3/7] Set /talks/ as a table of six columns, grouped by year, month, and event layouts/talks/list.html lists every talk of every programme, 607 rows under 224 events, newest first: the year, the month, the day, the event, the talk with its type glyph and its speakers, each a link styled as one, and the card of counts (photos, videos, and the other links of the record under one mark). It is the table of /events/ with one more group level, the event, which prints once on the first row of its talks and spans the rows below. The structure, the roles, the caption, the column headers, and the stylesheet are the ones events_index.html uses, so the two pages cannot drift. partials/person_page_link.html is the one place that decides where the name of a speaker points. It links to the page of the person when the build holds one, and to the anchor of the id on /people/ otherwise, so a row of this table and a credit on a talk page send a name to the same place, and the page per person can arrive on its own. content/talks/_index.md is the section page the layout draws, with a menu entry and a line about what the list is. The fallback list of layouts/_default/list.html, which the section drew until now, goes. --- content/talks/_index.md | 8 + .../layouts/partials/person_page_link.html | 60 ++++ themes/pybcn_theme/layouts/talks/list.html | 287 ++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 content/talks/_index.md create mode 100644 themes/pybcn_theme/layouts/partials/person_page_link.html create mode 100644 themes/pybcn_theme/layouts/talks/list.html diff --git a/content/talks/_index.md b/content/talks/_index.md new file mode 100644 index 000000000..0a255dbdd --- /dev/null +++ b/content/talks/_index.md @@ -0,0 +1,8 @@ +--- +title: "Talks" +description: "Every talk of every PyBCN event, with its speakers, its photo, its video, and its links." +menu: "main" +weight: 6 +--- + +Every talk the association has put on a programme, newest first. Each one has a page of its own with its speakers, the day it was given, its description, its photo, and whatever the speaker published: the video, the slides, and the code. diff --git a/themes/pybcn_theme/layouts/partials/person_page_link.html b/themes/pybcn_theme/layouts/partials/person_page_link.html new file mode 100644 index 000000000..2c1c5a5cb --- /dev/null +++ b/themes/pybcn_theme/layouts/partials/person_page_link.html @@ -0,0 +1,60 @@ +{{- /* + The link to the page of a person, by id: the one place that decides + where a name goes when a listing links it. + + The file was partials/person_link.html until 2026-10-14. The branch + work/archive-structure (six commits of 2026-09-30, never merged) has a + partials/person_link.html of its own that renders one social link of a + person, the job partials/person_links.html does here. Two jobs under + one name would collide in silence at a merge, so this one carries the + word page. That branch also has a layouts/people/single.html, read + before ours was written; see the header of ours for what was kept. + + Arguments: + id the person id, a key of content/people/ + names the dict partials/people_names.html returns, id to name, + passed in by the caller so the 378 person files are read + once per build and not once per credit + + Prints the name as a link when the id has a person record, and the + name as plain text when it has none, in which case the text is the id + with its hyphens taken out, which is what partials/agenda_entry.html + and partials/speaker_names.html print for the same case. + + THE TARGET IS THE PAGE OF THE PERSON, /people//. David Arcos + decided on 2026-10-13: a page per person, with its own URL, the same + way the site has a page per event and a page per talk. Until then the + person files built no page (see the cascade history in config.toml), + and the link went to the row of the person on /people/, the anchor + /people/#, which this partial called provisional. The row keeps + its anchor, and its name now links to the same page as this does. + + Why the href is read from the page and not written out. The + RelPermalink of the person page is what Hugo built, so a baseURL with + a path, or a rename of the section, moves the link with it. The page + is found with site.GetPage by its content path, /people/, which is + a map read and not a walk: bin/check-content requires the id of a + record to match its file name, so the path is the id. A record whose + page is not built, which no record is today, falls back to the anchor + on /people/, so the name still goes somewhere a reader can find it. + + Why a record is the condition. AGENTS.md: only a human-verified record + may link a speaker name. An id reaches a speakers list only when + bin/import-talks resolved an announced name to a person record, and a + name it could not resolve stays in speaker_names, which no template + reads. So an id with a record is the verified case, and an id with none + is a broken reference bin/check-content fails the build for: the name + still prints, as text, so the page reads while the build is red. +*/ -}} +{{- $id := .id -}} +{{- $name := index .names $id -}} +{{- if $name -}} + {{- $href := "" -}} + {{- with site.GetPage (printf "/people/%s" $id) }}{{ $href = .RelPermalink }}{{ end -}} + {{- if not $href -}} + {{- $href = printf "%s#%s" (site.GetPage "/people").RelPermalink $id -}} + {{- end -}} + {{ $name }} +{{- else -}} + {{- $id | replaceRE "[-_]+" " " | title -}} +{{- end -}} diff --git a/themes/pybcn_theme/layouts/talks/list.html b/themes/pybcn_theme/layouts/talks/list.html new file mode 100644 index 000000000..a50b3f6b9 --- /dev/null +++ b/themes/pybcn_theme/layouts/talks/list.html @@ -0,0 +1,287 @@ +{{- /* + The /talks/ page: every talk of every programme the association has + published, in one table, grouped by year, by month, and by event, + newest first. + + Year Month Day Event Talk Gallery + 2025 Nov 29 PyDay BCN (2025) 💻 Build advanced Agentic LLM Apps ... by Enric Domingo [ 1 photo ] + 💻 Introduction to Analog Quantum ... by Natàlia Padilla, Amir Azam and ... [ 1 photo 1 slide deck ] + Jun 11 Python Meetup ... 💬 Shorter testing feedback by ... + 2017 May 19 PyData Barcelona 2017 💬 ... + + Six columns, in the order David Arcos sketched the row: the year, the + month, the day, the event, the talk with its speakers, and the gallery + card. It is the table layouts/_default/events_index.html draws for + /events/, with the same structure, the same roles written out, the + same rowspan group headers, the same caption and column headers, and + the same stylesheet, listing_table.scss, so the two pages cannot + drift. The header comment of that template says why a table, why the + roles are written out, why the header row is hidden and not removed + under the lg breakpoint, and why the day column prints the first day + of an edition and not its range. None of that is repeated here; what + follows is what this page adds. + + One more group level. /events/ has one row per day, so the year and + the month are its groups. Here a row is a talk, and the 607 rows sit + under 224 events: the event is a third group, between the month and + the row, and it prints once, on the first row of its talks, spanning + them with rowspan the way the month spans its rows. The event is a + column of its own, as in the sketch, and a rowheader of scope row: a + reader who moves down the Talk column of a screen reader hears the + event of any talk from the header cell that covers it. + + The day spans the event too. The rows of one event share one day, so + a day on every row would print "29" seventeen times under PyDay BCN + (2025) and say nothing the event cell does not say. On /events/ the + day is on every row because every row is a day. So the day cell takes + the rowspan of its event, and holds the first day of an edition that + ran more than one day, with the rule and the time element of + events_index.html. + + The talk cell. The marker is the icon of its type from + partials/event_types.html, named, so its label is the accessible name + and the hover (see partials/event_type_icon.html): nothing else on the + row says that a talk was a workshop. The title links to the page of + the talk. After it, "by" and the speakers, each name a link through + partials/person_page_link.html, which is the one place that decides + where a speaker name goes: the page of that person, /people//, + since David Arcos decided on a page per person. A talk that credits + nobody, 87 of the 607, prints the title alone: a piece with no data + behind it is left out rather than printed as unknown. Under lg the + credit drops to its own line under the title, as the venue of + /events/ does. + + The gallery is partials/count_card.html: a photo of the day that + names the talk, its video, its other links. The counts come from + partials/listing_counts.html, the one walk of the site that also + feeds the legend above the table, so the card and the legend cannot + disagree, and the card leaves out a count of zero. + + Headers and outline. The year is a th of scope rowgroup in its own + tbody, with the year as its id, so /talks/#2019 links to a year from + anywhere and the jump bar above the table reads the same ids. The + month is a th of scope row, the event is a th of scope row. The table + has its own outline, so the page has one heading, the h1 of the hero, + which reads "Talks (607)" from partials/listing_total.html. + + Where the rows come from. Every talk page is added by + content/talks/_content.gotmpl, and partials/talk_record.html joins + each one back to the event that gave it. The year, the date, and the + title of the row's event are the event's, read once per event through + partialCached. The year is the year of that event, which every + edition carries, and not the date of the page: one edition of the + archive has no date, and a year is what the group needs. An event + with no date would print an empty month and day, as on /events/; no + event with talks is in that state today. + + The order. Years newest first, and inside a year the rows by the + date of their event newest first, then the event title and its URL as + the tiebreak for two events on one day, then the place of the talk in + the programme of its event: a day is a sequence, and the first talk of + the morning is not the one whose title starts with an A. The months + and the events fall out of that order as runs of rows, and the render + loop opens a group where the key of a row differs from the row before + it. The rowspans are counted first, in one pass over the rows. +*/ -}} +{{ define "main" }} +
+ + {{ with .Content }}
{{ . }}
{{ end }} + + {{- /* The counts of the legend and of the card of every row, from one + cached walk of the site, and the names of the people, from one + cached read of the person files. See partials/listing_counts.html + and partials/people_names.html. */ -}} + {{- $counts := partialCached "listing_counts.html" site "listing-counts" -}} + {{- $people := partialCached "people_names.html" site "people-names" -}} + + {{- $byYear := dict -}} + {{- $month_count := dict -}} + {{- $event_count := dict -}} + {{- range .RegularPagesRecursive -}} + {{- $record := partial "talk_record.html" . -}} + {{- $event := $record.event -}} + {{- $entry := $record.entry -}} + {{- $talk := $record.talk -}} + + {{- /* The year of the event, with the date of the page as the + fallback for a programme whose event carries no year. */ -}} + {{- $year := 0 -}} + {{- with $event }}{{ with .Params.year }}{{ $year = int . }}{{ end }}{{ end -}} + {{- if and (not $year) (not .Date.IsZero) }}{{ $year = .Date.Year }}{{ end -}} + {{- if not $year -}} + {{- warnf "talks index: %q has no year to group it under." .RelPermalink -}} + {{- continue -}} + {{- end -}} + + {{- /* The event: its title, its URL, and its date in the parts the + three date columns print, from partials/event_date.html, + cached by event so 224 events are parsed and not 607 rows. + month_key is the month as yyyymm, the key the rows of one + year are grouped by, as on /events/. */ -}} + {{- $event_title := "" -}} + {{- $event_url := "" -}} + {{- $date := dict "month" "" "month_key" "" "day" "" "start_iso" "" -}} + {{- $ordinal := 0 -}} + {{- with $event -}} + {{- $event_title = .Title -}} + {{- $event_url = .RelPermalink -}} + {{- $when := partialCached "event_date.html" (dict "date" .Params.date "end_date" .Params.end_date) .RelPermalink -}} + {{- $iso := "2006-01-02" -}} + {{- with $when.start -}} + {{- $ordinal = int (time.Format "20060102" .) -}} + {{- $date = dict + "month" $when.month + "month_key" (time.Format "200601" .) + "day" $when.day + "start_iso" (time.Format $iso .) -}} + {{- end -}} + {{- end -}} + + {{- /* Who gave it: the person ids of the record, which + partials/person_page_link.html turns into names and links, or + the credit of something that is not a person (see + partials/agenda_entry.html), which prints as text. */ -}} + {{- $speakers := slice -}} + {{- $credited_as := "" -}} + {{- with $talk -}} + {{- with .speakers }}{{ $speakers = . }}{{ end -}} + {{- if not $speakers }}{{ with .credited_as }}{{ $credited_as = . }}{{ end }}{{ end -}} + {{- end -}} + + {{- /* What the talk has, for the card: the photos of the gallery of + its day that name it, its video links, and every other + link of its record under one mark. */ -}} + {{- $facts := index $counts.talk_rows .RelPermalink -}} + {{- $card := slice -}} + {{- with $facts -}} + {{- $card = slice + (dict "kind" "photo" "count" .photos) + (dict "kind" "video" "count" .videos) + (dict "kind" "links" "count" .links) -}} + {{- end -}} + + {{- /* The keys of the two inner groups, and their counts, which + are the rowspans. The month key carries the year so the + dict holds one count per month of one year. The event key + is its URL, which one event has and no other. */ -}} + {{- $month_group := printf "%d|%s" $year $date.month_key -}} + {{- $event_group := printf "%d|%s" $year $event_url -}} + {{- $month_count = merge $month_count (dict $month_group (add 1 (int (default 0 (index $month_count $month_group))))) -}} + {{- $event_count = merge $event_count (dict $event_group (add 1 (int (default 0 (index $event_count $event_group))))) -}} + + {{- $row := merge $date (dict + "title" .Title + "title_lang" (partial "title_lang.html" (dict "title_lang" .Params.title_lang "title" .Title)) + "url" .RelPermalink + "type" $entry.type + "speakers" $speakers + "credited_as" $credited_as + "card" $card + "event_title" $event_title + "event_url" $event_url + "month_group" $month_group + "event_group" $event_group + "key" (printf "%08d %s %s %04d" (sub 99999999 $ordinal) $event_title $event_url $record.position)) -}} + {{- $key := string $year -}} + {{- $byYear = merge $byYear (dict $key (append $row (default slice (index $byYear $key)))) -}} + {{- end -}} + + {{- /* 9999 minus the year, so an ascending sort reads newest first, as + in events_index.html. */ -}} + {{- $groups := slice -}} + {{- range $year, $rows := $byYear -}} + {{- $groups = $groups | append (dict "year" $year "rows" (sort $rows "key") "key" (printf "%04d" (sub 9999 (int $year)))) -}} + {{- end -}} + {{- $groups = sort $groups "key" -}} + + {{- /* The years, as links to the groups below, through the bar + partials/jump_bar.html draws for /events/ and /people/. The far + end of 607 rows is many screens of scrolling away. */}} + {{- $jump := slice -}} + {{- range $groups }}{{ $jump = $jump | append (dict "anchor" .year "text" .year) }}{{ end }} + {{ partial "jump_bar.html" (dict "label" "Jump to a year" "items" $jump) }} + + {{- /* The legend: each icon the table draws, with how many rows carry + it, through partials/marker_legend.html. The counts come from + partials/listing_counts.html, which resolves the type of every + row through the same partial the rows do, so the legend and the + rows cannot disagree. */ -}} + {{- $legend := slice -}} + {{- range $token, $entry := partialCached "event_types.html" "event_types" -}} + {{- $legend = $legend | append (dict "type" $token "one" $entry.one "many" $entry.many "count" (int (default 0 (index $counts.talks $token)))) -}} + {{- end }} + {{ partial "marker_legend.html" (dict "label" "Talk types" "items" $legend) }} + + + + + + + + + + + + + + {{- range $groups }} + {{- $year := . }} + + {{- range $i, $row := .rows }} + {{- /* A row opens a month, or an event, when its key differs + from the row before it; the first row of the year opens + both. The rows are sorted by date, so the rows of one + month and of one event are together, and one comparison + is enough. */ -}} + {{- $prev := dict -}} + {{- if $i }}{{ $prev = index $year.rows (sub $i 1) }}{{ end -}} + {{- $opens_month := or (not $i) (ne $row.month_group $prev.month_group) -}} + {{- $opens_event := or (not $i) (ne $row.event_group $prev.event_group) -}} + + {{- /* The year, the month, and the event, each on the + first row of its group and spanning the group, as + rowheaders, with aria-rowspan as well as rowspan + for the reflow under lg, where no table layout + computes the span for the tree. The day takes the + rowspan of its event. */ -}} + {{- if not $i }} + + {{- end }} + {{- if $opens_month }} + {{- $span := index $month_count $row.month_group }} + {{- with $row.month }} + + {{- else }} + + {{- end }} + {{- end }} + {{- if $opens_event }} + {{- $span := index $event_count $row.event_group }} + + {{- with $row.event_url }} + + {{- else }} + + {{- end }} + {{- end }} + + + + {{- end }} + + {{- end }} +
Newest first. The year, the month, and the event print once, on the first row of their group.
YearMonthDayEventTalkGallery
{{ $year.year }}{{ . }} + {{- with $row.day }}{{ end -}} + {{ $row.event_title }} + {{- with $row.type }}{{ partial "event_type_icon.html" (dict "type" . "named" true) }}{{ end -}} + {{ $row.title }} + {{- /* The credit: "by A", "by A and B", "by A, B and + C", the join partials/agenda_entry.html prints, + with each name through person_page_link.html. */ -}} + {{- with $row.speakers }} by {{ range $si, $sid := . }}{{ if $si }}{{ if eq $si (sub (len $row.speakers) 1) }} and {{ else }}, {{ end }}{{ end }}{{ partial "person_page_link.html" (dict "id" $sid "names" $people) }}{{ end }} + {{- else }}{{ with $row.credited_as }} by {{ . }}{{ end }}{{ end -}} +
+ +
+{{ end }} From 94e1b9a67a5ca9c4c0f50c03c5d5b5810d0ebc84 Mon Sep 17 00:00:00 2001 From: David Arcos Date: Thu, 15 Oct 2026 10:15:41 +0200 Subject: [PATCH 4/7] Index every person a talk credits, with the type of the event partials/appearances_index.html used to index the people listed on /pybcn_association/organizers/ and nobody else, because every other person was a speaker or a volunteer of one event, whose modal would have listed that one event and nothing else. The talks ended that: every speaker of every talks block is indexed under the heading "Speakers", the heading the curated level of an event page already carries, so a person a page lists by hand and credits with a talk is kept once. 353 people gave at least one talk, over 544 pairs of a person and an event. Each appearance now carries the type of its event, resolved in the one walk through partials/event_type.html, so the row of /people/ and the page of a person print the glyph of the event next to its name without a second walk of the site per row. A page with no year carries an empty type: its marker is the glyph of the role. It also carries the date and the end_date of the page as the front matter writes them, so the Events table of a person page can set the month and the day in columns through partials/event_date.html without reading the page again. --- .../layouts/partials/appearances_index.html | 106 +++++++++++++----- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/themes/pybcn_theme/layouts/partials/appearances_index.html b/themes/pybcn_theme/layouts/partials/appearances_index.html index d546de9c7..bd5c2c318 100644 --- a/themes/pybcn_theme/layouts/partials/appearances_index.html +++ b/themes/pybcn_theme/layouts/partials/appearances_index.html @@ -1,21 +1,46 @@ {{- /* - The pages each organizer appears on, inverted from the pages themselves. + The pages each person appears on, inverted from the pages themselves. Returns a map from person id to a list of appearances, each a dict with - label, title, url, year and ord. No argument is read: the data comes from - site.RegularPages and from data/roles.yaml. + label, title, url, year, type, ord, date, and end_date. No argument is + read: the data comes from site.RegularPages and from data/roles.yaml. + + date and end_date are the two fields of the page as the front matter + writes them, unparsed, so the Events table of a person page can set + the month and the day of an event in columns through + partials/event_date.html (cached by the URL of the page, the key the + listings use) without reading the page again. Empty on a page with no + year, and on the one edition that carries no date. + + type is the event type of the page, a key of data/event_emoji.yaml + through partials/event_type.html, and it is set only on a page with a + year, which is what the rows of /events/ and /people/ call an event. A + page with no year (the organizers pages) carries an empty type: its + marker is the glyph of the role, not of an event. The type is resolved + here, in the one walk, because the row of /people/ reads the index and + not the page, and the walk already holds the page; resolving it from + the URL again in the layout would be a second walk of the site per row. Call it through partialCached with a constant variant, so the site is - walked once per build and not once per modal: + walked once per build and not once per person page: partialCached "appearances_index.html" site "appearances-index" - Two front matter shapes list people. An event page has people_sections, - each with a title and a list of levels. An association page has - people_levels, each with a name. A level entry is a bare id, or a map - with an id when the page overrides the role for that entry (see + Two front matter shapes list people by hand. An event page has + people_sections, each with a title and a list of levels. An association + page has people_levels, each with a name. A level entry is a bare id, or + a map with an id when the page overrides the role for that entry (see people-grid.html). + A third shape credits people without a list of its own. Every talk of + the talks block carries speakers, a list of person ids, and whoever gave + a talk was at that event. A talk is a record of the day itself, so its + speakers index like a curated entry, under the heading "Speakers". The + curated lists are the only source for the two pages with no year and the + only source of the other roles; the talks are the only source for the + three editions that carry a programme and no people_sections, PyData + Barcelona 2017, PyDay BCN 2018, and PyDay BCN 2019. + The heading is the level name when the level has one, and the section title when it does not. The Django page has one section titled "Mentors, speakers & volunteers" with three named levels, Mentors, @@ -24,36 +49,35 @@ The Speakers and Organizers sections of the event pages have one level each and no level name, so they fall back to the section title. - Only the people listed on /pybcn_association/organizers/ are indexed. - Every other person is a speaker or a volunteer of one event, whose modal - would list that one event and nothing else. The Archive will widen this. + Every person a page lists, and every person a talk credits, is indexed. + The index used to cover the people listed on + /pybcn_association/organizers/ and nobody else, because every other + person was a speaker or a volunteer of one event, whose page would list + that one event and nothing else. The talks ended that: 353 people gave + at least one talk, over 544 pairs of a person and an event, so the page + of a speaker now reads as the history of the days they spoke at. The + meetups of the Archive widened it again, from 134 people and 160 pairs. The heading goes through data/roles.yaml, which turns "Speakers" into "Speaker". The heading itself is the fallback, so a new heading shows up unmapped rather than not at all. An appearance is one (heading, page) pair. The same person can be - listed twice on a page under one heading, by two entries or by a level - with no name that repeats the section title, and the modal would then - link that page twice under one label. Two different headings on one - page stay two appearances: Ferran Jovell is a Mentor and a Speaker of - the Django page, and the modal lists that page once with both roles - (see person_appearances.html). + listed twice on a page under one heading, by two entries, by a level + with no name that repeats the section title, by two talks of the same + day, or by a curated Speakers level and a talk of the same page, and the + page would then link that page twice under one label. The talk heading + is "Speakers", the heading the curated level of an event page already + carries, so a speaker the page lists by hand and credits with a talk + reaches one label and is kept once. Two different headings on one page + stay two appearances: Ferran Jovell is a Mentor and a Speaker of the + Django page, and an organizer who gave a talk is an Organizer and a + Speaker of that edition, and the page lists the event once with both + roles (see person_appearances.html). Drafts are skipped by name as well: site.RegularPages drops them in a normal build, but a server run with -D would index content/canary.md. */ -}} -{{- $scope := dict -}} -{{- with site.GetPage "/pybcn_association/organizers" -}} - {{- range .Params.people_levels -}} - {{- range .people -}} - {{- $id := . -}} - {{- if reflect.IsMap . }}{{ $id = .id }}{{ end -}} - {{- $scope = merge $scope (dict $id true) -}} - {{- end -}} - {{- end -}} -{{- end -}} - {{- $index := dict -}} {{- range $pg := site.RegularPages -}} {{- if $pg.Draft }}{{ continue }}{{ end -}} @@ -68,8 +92,31 @@ {{- range $pg.Params.people_levels -}} {{- $headings = $headings | append (dict "heading" .name "ids" .people) -}} {{- end -}} + {{- /* The speakers of every talk of the page, as one more heading, and + last: the curated headings keep the ord they have today, and the + lists of a page with no year keep the order that page gives + them. A talk with no speaker, which is eleven of the fifteen + talks of PyDay BCN 2019, adds nothing, and a page with talks and + no speaker at all adds no heading. */ -}} + {{- $speakers := slice -}} + {{- range $pg.Params.talks -}} + {{- range .speakers -}} + {{- $speakers = $speakers | append . -}} + {{- end -}} + {{- end -}} + {{- with $speakers -}} + {{- $headings = $headings | append (dict "heading" "Speakers" "ids" .) -}} + {{- end -}} {{- if not $headings }}{{ continue }}{{ end -}} + {{- /* The event type of the page, once per page and not once per + person it credits. event_type.html reads the folder of the + file, so a page with no file (none today) gets no type. */ -}} + {{- $type := "" -}} + {{- if and $pg.Params.year $pg.File -}} + {{- $type = partial "event_type.html" $pg -}} + {{- end -}} + {{- $seen := dict -}} {{- range $ord, $h := $headings -}} {{- $heading := $h.heading -}} @@ -77,7 +124,6 @@ {{- range $h.ids -}} {{- $id := . -}} {{- if reflect.IsMap . }}{{ $id = .id }}{{ end -}} - {{- if not (index $scope $id) }}{{ continue }}{{ end -}} {{- $key := printf "%s|%s" $label $id -}} {{- if index $seen $key }}{{ continue }}{{ end -}} {{- $seen = merge $seen (dict $key true) -}} @@ -94,7 +140,7 @@ is titled "PyLadies BCN Organizers", and the line reads "Organizer at PyLadies BCN". Without it the line repeats the role in the name of the page it links to. */ -}} - {{- $entry := dict "label" $label "title" (default $pg.Title $pg.Params.appearances_title) "url" $pg.RelPermalink "year" (int (default 0 $pg.Params.year)) "ord" $ord -}} + {{- $entry := dict "label" $label "title" (default $pg.Title $pg.Params.appearances_title) "url" $pg.RelPermalink "year" (int (default 0 $pg.Params.year)) "type" $type "ord" $ord "date" (default "" $pg.Params.date) "end_date" (default "" $pg.Params.end_date) -}} {{- $index = merge $index (dict $id (append $entry (default slice (index $index $id)))) -}} {{- end -}} {{- end -}} From b67d1c03bdf9d8151fe98df5b693c670ab43bd8c Mon Sep 17 00:00:00 2001 From: David Arcos Date: Thu, 15 Oct 2026 10:15:41 +0200 Subject: [PATCH 5/7] Build a page per person, /people//, and make the card a link to it The cascade in config.toml that stopped the person pages (#202) stops the sponsor pages alone now. #201 set the condition for the return of the person pages: that they come back with the Archive, when every person has one worth linking to. The Archive met it, and the content of a person page is what the Archive says about the person. The header is the portrait, or the placeholder the row of /people/ draws, with the social links beside it as the mark of the host and the handle, and under them the roles and the number of events in one sentence. Then the Bio of the record; the Roles, one line per level of the association; the Events, one row per appearance, a talk given with the talk linked to its page or a role held, grouped by year with the month and the day in columns as on /talks/, and the card of what each talk left behind; the Photos of those talks, from the galleries, once the galleries exist; and a last block that says how the page is made and how to ask for a change. Every heading over a list carries its count. Six records are credited by nothing and get a page with what their file carries, until the last commit of this series takes them out. layouts/people/single.html is the page, and partials/person_talks_index.html the index of talk pages by person it reads. partials/person_appearances.html and partials/person_links.html, which fed the modal of a person card, now feed the page and nothing else, and the modal-only fields and the thumbnail index are gone. partials/person_placeholder.html draws the mark of a record with no photo, the initials on a colour derived from the name, where the portrait would be; the row of /people/ takes it too, in the commit that follows, with the stylesheet of its colours. The card itself becomes a link to the page. partials/people_summary.html drew a card with a modal behind it: the photo and the name were two buttons that opened the same dialog, and the dialog held a heading tree of its own that bin/check-rendered had to cut out of the outline of the page. The card is now one anchor around the photo and the name, to /people//, which holds everything the modal showed and more. One tab stop per card instead of two, no JavaScript to read about a person, and the 22 dialogs of /organizers/ are gone. partials/people-grid.html no longer hands each card a key for the id of its modal, and people.scss drops the 500 lines of modal rules with it. README.md indexes the decision and corrects the list of column counts a level may use (2, not a second 4). partials/role_order.html sorts the roles of a person by importance in the association (Management Board, Permanent Committee, Organizer, Collaborator, Speaker, Mentor, Volunteer), for the header of this page, the rows of its Events table, and the row of /people/. people_page.scss styles the page. The person pages are in the sitemap and not in the home feed: layouts/_default/rss.xml leaves the people section out by name, as it does the talks, because hundreds of pages that arrive in one build are not that many publications. The feed is ordered by the day the event happened, .Date, and no longer by the commit date: the archive import put every page it added on one lastmod, and 268 meetup pages took the whole window of a reader. config.toml caps the feed at 20 items. index.llms.txt says where the pages are. --- README.md | 4 +- config.toml | 71 ++- layouts/_default/rss.xml | 49 +- themes/pybcn_theme/assets/scss/people.scss | 568 +----------------- .../pybcn_theme/assets/scss/people_page.scss | 528 ++++++++++++++++ themes/pybcn_theme/layouts/index.llms.txt | 2 +- .../layouts/partials/image_square.html | 22 +- .../layouts/partials/people-grid.html | 18 +- .../layouts/partials/people_summary.html | 167 ++--- .../layouts/partials/person_appearances.html | 303 ++++++---- .../layouts/partials/person_links.html | 56 +- .../layouts/partials/person_placeholder.html | 61 ++ .../layouts/partials/person_talks_index.html | 105 ++++ .../layouts/partials/role_order.html | 48 ++ themes/pybcn_theme/layouts/people/single.html | 251 ++++++++ themes/pybcn_theme/layouts/talks/single.html | 2 +- 16 files changed, 1427 insertions(+), 828 deletions(-) create mode 100644 themes/pybcn_theme/assets/scss/people_page.scss create mode 100644 themes/pybcn_theme/layouts/partials/person_placeholder.html create mode 100644 themes/pybcn_theme/layouts/partials/person_talks_index.html create mode 100644 themes/pybcn_theme/layouts/partials/role_order.html create mode 100644 themes/pybcn_theme/layouts/people/single.html diff --git a/README.md b/README.md index 76ae96335..bd1cd96b9 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ All pages rely on the same template: `themes/pybcn_theme/layouts/_default/people The template iterates over the defined levels, and for each level: - displays the level name, if 'name' is provided -- displays a summary of all the people listed, limiting the number of people per line to the provided number 'people_per_line' (which must be in [12, 6, 4, 3, 4, 1]) +- displays a summary of all the people listed, limiting the number of people per line to the provided number 'people_per_line' (which must be in [12, 6, 4, 3, 2, 1]) To add new people to the site, there is a Hugo archetype defined in `themes/pybcn_theme/archetypes/people.md` that can be used by running the command: `hugo new people/my-new-person.md`. @@ -429,7 +429,7 @@ it. The cost is that the reasons are scattered, so this is the index. |---|---| | A person file builds no page, and `/people/` is a 404 | The `[[cascade]]` comment in `config.toml`, and issue #201 | | An appearance line calls a page by `appearances_title`, not by its title | `themes/pybcn_theme/layouts/partials/appearances_index.html` | -| The modal is on its way to being a page per person, so its content cannot depend on the page it was opened from | `themes/pybcn_theme/layouts/partials/person_appearances.html` | +| A card of a people grid is a link to the page of the person, not a modal that shows a subset of it | `themes/pybcn_theme/layouts/partials/people_summary.html` | | The menu sections are a disclosure, not an ARIA menu | `themes/pybcn_theme/layouts/partials/nav.html` | | MIT for the code, CC BY-SA 4.0 for the content, and nothing for the photographs | [LICENSING.md](LICENSING.md) | | `edition` is the source branch and `master` is the built site | [AGENTS.md](AGENTS.md) | diff --git a/config.toml b/config.toml index 6e8016991..df748bb2f 100644 --- a/config.toml +++ b/config.toml @@ -33,26 +33,54 @@ timeout = "300s" # the modals through its front matter. Hugo also built a page from each one, # with no inbound link anywhere on the site, and they were 206 of the 247 # entries in the sitemap (#201). The sponsor pages are empty; 73 of the 138 -# person files carry a bio that did render. They all stop, bio or not: -# publishing only the people who happen to have a paragraph written about -# them would sort the organizers and speakers of this association into two -# classes. The pages come back with the Archive, when every person has one -# worth linking to. +# person files carried a bio that did render. #202 stopped them all, bio or +# not: publishing only the people who happen to have a paragraph written +# about them would sort the organizers and speakers of this association into +# two classes. The condition set then for their return was that the pages +# come back with the Archive, when every person has one worth linking to. # -# render = "never" stops the page and keeps the default list = "always", so -# the file stays in site.Pages, where people-grid.html and sponsors-grid.html -# look it up by id. list = "local" would take it out of site.Pages and empty -# every grid. A page that is not rendered has no permalink, which is how the -# feed template leaves it out (layouts/_default/rss.xml). +# The Archive met it, and the person pages are back: /people//, from +# themes/pybcn_theme/layouts/people/single.html, one per record, the way +# the site has a page per event and a page per talk (David Arcos, +# 2026-10-13). Measured in this tree when the pages came back: 378 files +# under content/people/, one of them the draft canary fixture of +# bin/check-rendered, so 377 records. 353 are credited by at least one +# talk, 124 are listed in at least one page block (the board, a committee, +# a team, a speaker list), and 371 by one or the other. The content of a +# person page is what the Archive says about the person: the talks, each +# linking to its talk page, the photos of those talks, the events, and the +# roles, plus the photo, the bio, and the links of the record itself. So a +# page is no longer a heading with nothing under it, and every one has an +# inbound link: the speaker name of every talk row of /talks/ goes to it +# through partials/person_link.html, and so does the name on its row of +# /people/. # -# /people/ had nothing but a heading and goes with them. /sponsors/ is a real -# page and stays. -[[cascade]] - [cascade._build] - render = "never" - [cascade._target] - path = "{/people,/people/**}" - +# Six records are credited by nothing: david-blazquez-garcia, marc-pomar, +# patricio-reyes-valenzuela, peter-krupa, ricardo-martinez, and +# thomas-alisi (bin/check-content names them in a note). They get a page +# all the same, with the photo, the bio, the position, or the links their +# file carries, and the two-classes argument of #201 is the reason: taking +# the page away from the six whose programme has not been credited yet +# would sort the people of the archive into the ones with a page and the +# ones without, on a fact about the import and not about them. The +# figure that used to be the point here, that 240 of the 378 records hold +# an id and a name and nothing else, is still true and no longer matters: +# the content of a person page comes from the Archive, not from the front +# matter. +# +# The sponsor pages stay stopped. render = "never" stops the page and +# keeps the default list = "always", so the file stays in site.Pages, +# where sponsors-grid.html looks it up by id. list = "local" would take it +# out of site.Pages and empty the grid. A page that is not rendered has no +# permalink, which is how the feed template leaves it out +# (layouts/_default/rss.xml). /sponsors/ is a real page and stays. +# +# The person pages are in the sitemap and not in the home feed: +# layouts/_default/rss.xml leaves the people section out by name, as it +# does the talks, because 377 pages that arrive in one build are not 377 +# publications and would push every item a reader could use out of the +# window their reader keeps. /people/ builds no feed of its own: section = +# ["HTML"] above stops every section feed (#201). [[cascade]] [cascade._build] render = "never" @@ -136,3 +164,10 @@ unsafe=true link = "https://github.com/pyladies-bcn" icon = "fab fa-github fa-2x" + +[services] + [services.rss] + # A feed is a window, not an archive. The site has 282 event pages and + # one page per talk and per person, so an unlimited feed would be a few + # hundred items of which a reader would see the newest twenty anyway. + limit = 20 diff --git a/layouts/_default/rss.xml b/layouts/_default/rss.xml index 376fe1893..1299c8609 100644 --- a/layouts/_default/rss.xml +++ b/layouts/_default/rss.xml @@ -26,15 +26,48 @@ {{- else }} {{- $pages = $pctx.Pages }} {{- end }} -{{- /* No content file carries a date, so .Date and .PublishDate are zero and the - default order is by title. .Lastmod comes from git (enableGitInfo), the same - source as the sitemap's lastmod: last change first, and the item date is the - date of the last commit that touched the file. */ -}} -{{- /* A page with _build.render = "never" stays in the collections (the people - and sponsor files feed the grids that way) but has no permalink. The feed +{{- /* The order was .ByLastmod, the commit date from enableGitInfo, with + the reason that the day an event happened is not a publication date and + would put a PyDay of 2016 at the end of the feed for good. That held + while the site carried a handful of events. It broke when the archive + import added 282 of them: they all arrived in one commit, so they all + carried one lastmod, and 268 meetup pages took the top of the feed and + pushed the 34 real items out of the window a reader keeps. + The order is now .ByDate, and a PyDay of 2016 at the end of the feed is + the right answer: a reader asks a feed what is new, a page about an + evening in 2014 is not, and a meetup held next month surfaces on its + own with no rule to maintain. */ -}} +{{- /* A page with _build.render = "never" stays in the collections (the + sponsor files feed the grid that way) but has no permalink. The feed lists only pages a person can visit. */ -}} {{- $pages = where $pages "Permalink" "ne" "" }} -{{- $pages = $pages.ByLastmod.Reverse }} +{{- /* The person pages are not in the feed either, for the reason the talk + pages below are not: /people// is one page per record of + content/people/, 377 of them, and the whole set arrived in one build when + the cascade in config.toml stopped hiding them. None of them is a + publication, and 377 items with a lastmod of one day would be the whole + feed. They are in the sitemap, where a page of the site belongs. */ -}} +{{- $pages = where $pages "Section" "ne" "people" }} +{{- /* The talk pages are not in the feed. There is one per talk of every + programme the association has published (bin/check-content prints the + count), and they are built from the front matter of the events, which + the feed already carries (see content/talks/_content.gotmpl). None of + them is a publication: the date of a talk is the day it was given, as + the date of an event page is the day the event happened, and the whole + set arrives in one commit. A feed that takes hundreds of items in one + build pushes every item a reader could use out of the window their + reader keeps. They are in the sitemap, where a page of the site + belongs. */ -}} +{{- $pages = where $pages "Section" "ne" "talks" }} +{{- /* By the day the thing happened, not by the day it was committed. + ByLastmod sorts by the commit date, because enableGitInfo is on, so a + set imported in one go arrives with one timestamp and takes the whole + feed: the archive import put 268 meetup pages on top at once and left + 34 real items below them. The date of an event page is the evening it + happened, which is what a reader of a feed is asking about, so a + meetup of 2014 sinks on its own and the next one surfaces when it is + held. The limit is what keeps the window small enough to read. */ -}} +{{- $pages = $pages.ByDate.Reverse }} {{- $limit := .Site.Config.Services.RSS.Limit }} {{- if ge $limit 1 }} {{- $pages = $pages | first $limit }} @@ -60,7 +93,7 @@ {{ .Title }} {{ .Permalink }} - {{ .Lastmod.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} + {{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} {{- with $authorEmail }}{{ . }}{{ with $authorName }} ({{ . }}){{ end }}{{ end }} {{ .Permalink }} {{ .Summary | transform.XMLEscape | safeHTML }} diff --git a/themes/pybcn_theme/assets/scss/people.scss b/themes/pybcn_theme/assets/scss/people.scss index 86d676a2b..5920bb335 100644 --- a/themes/pybcn_theme/assets/scss/people.scss +++ b/themes/pybcn_theme/assets/scss/people.scss @@ -121,6 +121,27 @@ flex-direction: column; height: 100%; + // The photo, the name and the role are one link to the page of + // the person (see people_summary.html). A block, so the two + // boxes inside it stack as they did when each was a button. It + // keeps the colours of the card: the name is black at rest and + // the PyBCN blue under the pointer or the keyboard, which the + // rules of the card below set on the h3, and the link must not + // paint it the link blue over them or underline it. The border + // of the card and the colour photo are the hover and the focus + // signal, so the whole card reads as the target, as it did. + .person_card_link { + display: block; + color: inherit; + text-decoration: none; + + &:hover, + &:focus { + color: inherit; + text-decoration: none; + } + } + .img-wrap { position: relative; // Keeps the zoom on hover inside the frame. @@ -137,10 +158,6 @@ // display: block on the photo matters. An inline image sits on // the text baseline, which leaves a few pixels of space under // it inside the frame. - .modal_trigger { - display: block; - } - img { display: block; width: 100%; @@ -168,8 +185,7 @@ padding: 5px 10px 0; // Centred, like the icon row under it. The name and the role // used to sit on the left edge while the icons sat in the - // middle, so one card showed two alignments. The modal keeps - // its own alignment: this block is the card face only. + // middle, so one card showed two alignments. text-align: center; h3 { @@ -192,18 +208,14 @@ margin-bottom: 3px; // A name wider than the card is cut rather than wrapped. - // The whole name is in the title of the button and in the - // modal the button opens, so nothing is lost. The button - // has to be a block of the full width: the ellipsis needs - // a box with a width to overflow, and an inline button - // takes the width of its text. - button { - display: block; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + // The whole name is in the title of the link and on the + // page the link opens, so nothing is lost. The heading + // is a block of the full width, which the ellipsis needs: + // a box with a width to overflow. The rules sat on a + // button inside the heading when the card opened a modal. + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } // Bootstrap gives a paragraph 1rem below it, which opened a @@ -281,9 +293,9 @@ font-size: clamp(18px, 16cqi, 32px); // Grey at rest and the PyBCN blue under the pointer, // the same treatment the glyph of an appearance row - // takes in the modal. They were solid black here and - // the link blue in the modal, which are two answers to - // the same question on one page. 4.68:1 on white, so + // takes on the page of the person. They were solid black + // here and the link blue in the lists, which are two + // answers to the same question. 4.68:1 on white, so // the grey still passes the 3:1 WCAG asks of a graphic. color: $icon-grey; transition: color 0.15s linear; @@ -321,520 +333,6 @@ } } - .modal { - .modal-dialog { - // 580px, from the content and not from the window: - // the 548px text column, which is now the whole body, - // and the 16px of body padding each side. The cap was - // 900px while the photo was a 300px column with a - // 20px gap beside that text column. With the photo in - // the header row the column and the gap are gone, and - // 900px put 866px of bio on one line: 122 characters - // of the 16px body font on the first line of Jordi - // Bosch's bio, measured at 1440px, about twice a - // comfortable measure. The 548px holds the longest - // line the modal renders in one line, the 498px event - // line "Mentor and Organizer at Hands-on Django with - // PyLadiesBCN (2022)" behind its 38px marker column, and - // is about 76 characters of the body font at the 7.15px - // the average glyph of that bio measures. The header - // row leaves 422px for the name after the 40px that - // clears the close button, the 20px gap, and the 64px - // photo, and "Elisabeth Ortega Carrasco", the longest - // name on the organizers page, sits on one line there - // at 28px. The cap before 900px was 70% of the window: - // 1006px at 1440px, and a percentage of a 2560px - // screen is wider still. Under 580px the dialog fits - // the window, as a max-width does. - max-width: 580px; - - // One column: a header row, then the sections at the - // full width of the body. The body used to be two - // columns, the photo on the left at 300px and the text - // on the right, so that nothing ran under the photo. - // That put a blank strip under the photo instead, as - // tall as the text beside it, which for a person with - // a bio is most of the modal. The photo is small and - // round now and takes the corner of the header, so the - // text has nothing to clear and the columns are gone. - // The body is a block again, the Bootstrap default, and - // the close button stays where the two-column layout - // put it: absolute in the corner, at the body padding. - // Bootstrap floats it, and a float beside the header - // row would narrow the row by the width of the button - // through the block formatting context a flex - // container opens, which is true but not something a - // reader of this file should have to know. - .modal-body { - .close { - position: absolute; - top: 1rem; - right: 1rem; - } - } - - // The name and the role on the left, the photo on the - // right, and only those two share the row: a flex row - // and not a float, because a float would let the bio - // run up beside the photo and collide with the section - // headings and their rules. The row is as tall as the - // taller of the two, and the photo is sized so that - // the two are nearly the same height (see .person_photo - // below), so the row ends where the text ends and the - // first section starts right under the role. With the - // 120px photo the row was 120px tall against 60px of - // name and role, and the Bio heading sat 80px under - // the role: a blank band that read as a missing - // section. Centred, not top-aligned: the two differ by - // 4px on one line, so centring moves the name 2px, - // and when a long name wraps to two lines the photo - // sits on the middle of the block instead of hanging - // off its first line. Top alignment was chosen when - // the photo was 120px and centring would have floated - // the name 30px under the close button; at 64px that - // reason is gone. - // - // The right padding is what keeps the photo off the - // close button, which sits in the rightmost 14px of the - // body, 24px tall, at the top right corner the photo - // now takes too. 2.5rem is 40px: the 14px of the button - // and 26px of air, so the ring of the photo does not - // touch the button and the pointer has a clear target. - // The padding is on the row and not on the title, so - // the photo clears the button too, not only the text. - .person_header { - display: flex; - align-items: center; - padding-right: 2.5rem; - // Padding and no rule. A rule under this row put a - // line right above the one the BIO heading carries, - // so the first section opened with two hairlines a - // few pixels apart. - padding-bottom: 16px; - } - - // min-width: 0 lets a long name wrap instead of - // pushing the row wider than the modal: a flex item - // is as wide as its longest word by default. The 20px - // to the photo is the gap the two columns had. - .person_heading { - flex: 1 1 auto; - min-width: 0; - margin-right: 20px; - } - - // The photo: a round frame that clips, with a hairline - // ring, after the avatar on davidarcos.net. 160px is - // the size the markup gives the image too (see - // people_summary.html). This block is on its way to - // becoming the header of a page per person, and a - // portrait is what a person page opens with, so the - // face is shown at a size a reader can read a face at. - // It was 64px for one build, which is the height of - // the name and the role beside it, and at that size it - // is a mark, not a portrait. - // - // The band that costs is under the role: the row is as - // tall as the photo, so a name that fits on one line - // leaves (160 - 60) / 2 of air above the name and the - // same under the role. The heading grows to take a - // share of it, and what is left reads as the padding - // of a header band rather than as a gap, because the - // rule under the row closes it. - // - // The 160px image is the 400px card file scaled down - // by the browser, no new variant and no new request: - // the card has already loaded it when the modal opens. - // - // The ring is a box-shadow and not a border: a border - // takes part in the layout and shifts the image by its - // width, and a shadow is drawn outside the box. 15% - // black on the white of the modal, a hairline, so the - // frame has an edge where the photo is light at the rim. - // The frame clips the zoom below, so the photo never - // spills out of the circle. - .person_photo { - flex: 0 0 auto; - border-radius: 50%; - overflow: hidden; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15); - - // display: block, because an inline image sits on - // the text baseline and leaves a few pixels of - // space under it inside the frame, which the ring - // would show. object-fit covers the fallback image, - // which is not square; a person photo is already - // square from the pipeline. - img { - display: block; - width: 160px; - height: 160px; - border-radius: 50%; - object-fit: cover; - } - - // On hover the photo comes closer inside the frame, - // the same move the card makes. Only for a reader - // who has not asked for less motion: the card - // moves for everyone, and this one is the rule to - // follow from now on. - @media (prefers-reduced-motion: no-preference) { - img { - transition: transform 0.2s linear; - } - - &:hover img { - transform: scale(1.2); - } - } - } - - // No right padding on the title any more: the header - // row above carries it, for the photo as well. - // Larger than Bootstrap's 28px, because this row is - // on its way to being the header of a page per person - // and the name is its title. It also takes a share of - // the air the 160px photo opens beside a one line - // name: the heading block goes from 60px to 66px. - .modal-title { - font-size: 2rem; - margin-bottom: 2px; - } - - // Italic and plain, as on the card, so the pair - // beside the photo reads the same in both places. No - // margin under it: the first section brings its own - // 20px, the gap every section keeps from the block - // above it, and the 8px that used to be here put the - // Bio heading 28px under the role, 8px further than - // Roles sits under the bio. - .person_role { - font-style: italic; - margin-bottom: 0; - } - - // Every block of the modal below the name is a section - // with a heading: Bio, Description, Roles, - // Events, Social Links. The modal used to be a stack of - // blocks with no label, so a reader had to guess that a bold word was - // a role and a list of years was a history. A section - // is left out when it is empty, heading and all, so the - // rule sits on the section and not on the block inside. - .modal_section { - margin-top: 20px; - - // Small and grey, so the heading labels the block - // without competing with the name above or the - // text below. A rule under it closes the gap to - // the previous block: without it the four sections - // read as one list. - h4 { - font-size: 0.8rem; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.06em; - color: #51616e; - margin-bottom: 8px; - padding-bottom: 4px; - border-bottom: 1px solid #dee2e6; - } - - // The last block of a section sets no space of its - // own: the next section's margin-top is the gap, - // and the last section ends at the body padding. - > :last-child { - margin-bottom: 0; - } - } - - // The roles the person holds, one line per role, built - // and styled exactly as an event line: the glyph, the - // role in bold, an italic "at", and the page. It was a - // definition list in a two column grid, which put the - // same kind of statement in a different shape from the - // list right under it, and on a phone the label column - // was as wide as "Management Board (2018 - 2025)" and - // left 80px of a 390px screen for the page beside it. - // Nothing is set here: ul.events below carries it, and - // the selector lists both. See person_appearances.html. - - // The two lists of the modal, the events and the - // social links, with one treatment: a marker and the - // indent that goes with it, so each reads as a list. - // Both used to drop the marker and the padding, and - // five events were five loose lines with nothing to - // say they were one list, under a heading styled like - // the one over a table. The indent is the width of - // the marker and a space, no more: the browser default - // of 40px pushed a list in from the headings. - .modal_section ul { - list-style: disc; - padding-left: 1.25em; - // The section above sets the gap. - margin: 0; - - li { - margin-bottom: 4px; - } - } - - // The roles and the events, one line each, the role - // before the linked page or title. See - // person_appearances.html. The marker is the role - // glyph that starts the row, not the disc: the row - // carries it as the social rows carry the network - // icon. The padding is the glyph's column, so the - // text starts where it starts under a disc, at the - // padding edge, and a wrapped line starts there too. - // The selector repeats the ul of the rule above, - // because a class alone loses to that class and - // element pair, and the list kept the disc and the - // 1.25em indent under the glyph. - .modal_section ul.appearances, - .modal_section ul.events { - list-style: none; - padding-left: calc(1.875em + 8px); - - // The whole row answers the pointer, not only the - // link in it: the glyph, the role and the link are - // one statement, and a reader aiming at any of the - // three is after the same page. focus-within does - // the same for the keyboard, where the link is what - // takes focus. The glyph drops its grey and comes - // back in colour, which is the only place on the - // page a role shows its own colour. - li:hover, - li:focus-within { - .role_emoji { - filter: none; - } - - strong { - color: $blue-pybcn; - } - - a { - text-decoration: underline; - } - } - - // The whole row is the link, not only its text. The - // link stretches over the row with a pseudo element - // rather than by wrapping the row in an anchor, - // which would put the glyph and the role inside the - // link text and read them out with it. - // - // :only-of-type is the guard. A role held on two - // pages prints two links on one row, and there is - // no one row to stretch: those rows keep their own - // link areas. No row has two links today, out of - // 135 role rows and every event row, but the - // template allows it. - li { - position: relative; - } - - li > a:only-of-type::after { - content: ""; - position: absolute; - inset: 0; - } - - // A colour change, not a move, so there is nothing - // for prefers-reduced-motion to turn off. - .role_emoji, - strong { - transition: color 0.15s linear, filter 0.15s linear; - } - } - - // The role glyph as the marker of a Roles or an Events - // row, from data/role_emoji.yaml. Sized as the network - // icon of .modal_links below: 1.25em in a 1.5em column - // with 8px to the text, so a glyph of any width leaves - // the text on one line down the list. The column is - // 1.875em of the body font plus 8px, which is the - // padding the two lists above set, and the negative - // margin pulls the glyph back into that padding, where - // a disc sits. Pulled back and not placed with - // position, so it stays on the line of its row when - // the row wraps. - // - // Desaturated, so the glyph sits with the black icons - // of Social Links rather than being the one spot of - // colour in the modal. grayscale and nothing else: a - // colour emoji is a shaded picture, and in grey it is - // a light one, which is what this list wants. The - // marker labels the row; the text beside it is what - // the reader is here for, and a grey mark does not - // compete with it. - // - // Measured in Chromium with Noto Color Emoji, over - // the ink of a whole modal of glyphs: grayscale alone - // gives a mean luminance of 142 (0 is black) with 2% - // of the ink almost black. Darkening first and then - // stretching the contrast, which this rule did for one - // build, gives a mean of 53 with 64% of the ink almost - // black: the shaded faces of the compass and the - // calendar fill in and the glyph reads as a blot. A - // flat black glyph like the icons is out of reach for - // a filter either way, because the emoji keeps its - // shading. - // - // Upright and at the normal weight, because the dt is - // bold and a slanted or heavy emoji is a font bug on - // some platforms and never a style. line-height: 1 - // keeps the glyph from opening the line box of its - // row further than its own height, and middle-aligned - // the text sits on the centre of the glyph instead of - // on its bottom edge. - .role_emoji { - display: inline-block; - font-size: 1.25em; - font-style: normal; - font-weight: normal; - line-height: 1; - width: 1.5em; - margin-left: calc(-1.5em - 8px); - margin-right: 8px; - text-align: center; - vertical-align: middle; - filter: grayscale(1); - } - - // The links of the card, listed with their text at the - // foot of the modal, so a reader can see the handle or - // the host without leaving the page. This is the only - // place the modal shows them: a bare icon row next to - // the name said the same thing with less. See - // person_links.html. - // The selector repeats the ul, as .events above does: - // a class alone loses to the class and element pair of - // .modal_section ul, and the list kept the disc and the - // 1.25em indent that rule sets. - .modal_section ul.modal_links { - // The network icon is this list's marker, so the - // disc goes: with both, the row carried two - // markers and its text started 20px to the right - // of the text in the two lists above, which now - // start with a glyph of their own. The padding is - // the one those two set, so the three lists share - // one column. The negative margin on the icon is - // what pulls it back into the padding; it is set - // on i below, where the column width is. - list-style: none; - padding-left: calc(1.875em + 8px); - - // A link text is one unbroken string, the path of - // a LinkedIn profile or a site address, and one of - // them is 600px of percent-encoded emoji. A string - // with no space does not wrap on its own, and at - // the 548px of the text column it ran out of the - // modal. It breaks where it has to instead. - a { - overflow-wrap: anywhere; - } - - // The whole row answers the pointer and is the - // link, as a row of the two lists above is: the - // icon colours, the address takes an underline, - // and a click anywhere on the row follows it. The - // network name is bold, so it lights like the role - // of a role row. - li { - position: relative; - } - - li > a::after { - content: ""; - position: absolute; - inset: 0; - } - - li:hover, - li:focus-within { - i { - color: $blue-pybcn; - } - - strong { - color: $blue-pybcn; - } - - a { - text-decoration: underline; - } - } - - // One column for the icon whatever its width, so - // the text starts on one line down the list. The - // LinkedIn glyph is narrower than the globe, and - // the X mark is a box of its own (see .icon_x in - // main.scss) that sets neither width nor gap. - // Grey at rest and the PyBCN blue under the - // pointer, the same as the card above and as the - // role glyph of the two lists above. The icon took - // the link colour, so it was blue at rest and the - // row said nothing when the pointer reached it. - strong { - transition: color 0.15s linear; - } - - i { - display: inline-block; - color: $icon-grey; - transition: color 0.15s linear; - // Larger than the text beside it, because these - // are now the only icons in the modal. - font-size: 1.25em; - width: 1.5em; - margin-right: 8px; - // Into the padding above, where the disc used - // to sit and where the role glyph of the two - // lists above sits. The margin is in em of the - // icon; the 8px is the gap it sets above, so the - // pull is the column plus the gap. - margin-left: calc(-1.5em - 8px); - text-align: center; - vertical-align: -2px; - } - } - } - } - - } - } -} - -@media (max-width: 575.98px) { - .people_grid { - .person_wrapper { - .person_item { - .modal { - .modal-dialog { - max-width: 100%; - - // A 360px screen gives the modal body 310px, and - // 40px of that clears the close button and 20px is - // the gap to the photo. At the desktop 160px the - // name is left 90px and "Laura Perez Mayos" takes - // three lines of it. 112px leaves 138px and two. - .person_photo img { - width: 112px; - height: 112px; - } - - // The title comes down too: at 32px the same name - // takes four lines of that 138px, and two at - // 1.4rem. - .modal-title { - font-size: 1.4rem; - } - - } - } - - } } } } diff --git a/themes/pybcn_theme/assets/scss/people_page.scss b/themes/pybcn_theme/assets/scss/people_page.scss new file mode 100644 index 000000000..78e77e1df --- /dev/null +++ b/themes/pybcn_theme/assets/scss/people_page.scss @@ -0,0 +1,528 @@ +// The page of one person. See layouts/people/single.html. +// +// The shape is the one the page of one talk and the page of one meetup use: +// a header under the h1, then a block per section with an h2 the size of +// every other h2 of the site. The Roles list and the Events table have +// their row rules at the end of this file; they were a mixin of people.scss +// while the modal of a person card printed the same lists, and the modal is +// gone (see people_summary.html). +.person_page { + + // The portrait and the links beside it. A flex row and not a float: a + // float would let the bio flow around the photo and collide with the + // headings under it. The row wraps, so on a narrow screen the links + // drop under the portrait instead of squeezing beside it. + .person_page_header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 24px 32px; + margin-bottom: 1.6rem; + } + + // A round frame at 240px: a portrait is what a person page opens with, + // and the links sit beside it now, so it has the room it did not have + // over a role line and a card. The markup gives the image the same + // width and height, so the box is laid out before the file arrives. + // The pipeline cuts the square; the radius cuts the circle, and a + // hairline ring gives it an edge where the photo is light at the rim. + .person_portrait { + display: block; + flex: 0 0 auto; + width: 240px; + height: 240px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12); + } + + // The zoom of the portrait: the photograph grows inside its own frame, + // which is clipped, so nothing on the page moves. It is the zoom the + // cards of /pybcn_association/organizers/ have had for years, in + // people.scss, and David Arcos asked for that one on 2026-10-15 after + // a first try hung a 400px copy of the picture under the portrait and + // read as a popup. + // + // The portrait stays a link, so the keyboard has something to focus + // and a touch screen, which has no hover, has somewhere to land: it + // goes to the 800px file, the largest square the page builds. + // prefers-reduced-motion turns the movement off and keeps the zoom. + .person_portrait_link { + // Clips the zoom, the same frame the organizers cards use. The + // circle is here, on the box that clips, and not on the + // photograph: with the radius on the image the square frame cut + // the corners off the circle as it grew. + overflow: hidden; + border-radius: 50%; + position: relative; + display: block; + flex: 0 0 auto; + width: 240px; + height: 240px; + } + + .person_portrait { + transition: transform 0.2s linear; + } + + .person_portrait_link:hover .person_portrait, + .person_portrait_link:focus-visible .person_portrait { + transform: scale(1.2); + } + + @media (prefers-reduced-motion: reduce) { + .person_portrait { + transition: none; + } + } + + // The placeholder of a person with no photo, at the size of the + // portrait: the initials on a colour, the shared rule of + // people_index.scss, five times the size of the row. + .person_initials { + flex: 0 0 auto; + width: 240px; + height: 240px; + font-size: 5rem; + } + + .person_page_facts { + flex: 1 1 16rem; + min-width: 0; + } + + // The social links beside the portrait (partials/person_links.html): + // one link per line, each the mark of the host and the handle, in the + // size of body text. No marker: the mark is the marker. They were one + // line that wrapped: at 1280px three handles sat on the line and the + // site dropped under them, which read as misaligned, so David Arcos + // asked for one per line (2026-10-15), and the marks line up down the + // left. The handle of a site is a free URL, and one of them is 600px + // of percent-encoded characters; it breaks where it has to. + .person_links { + display: flex; + flex-direction: column; + row-gap: 0.4rem; + list-style: none; + padding-left: 0; + margin: 0 0 0.8rem; + + li { + position: relative; + min-width: 0; + } + + a { + overflow-wrap: anywhere; + } + + // The whole item is the link, the mark included, through a pseudo + // element rather than by wrapping the mark in the anchor, which + // would read the mark out with the handle. + li > a::after { + content: ""; + position: absolute; + inset: 0; + } + + // Grey at rest and the PyBCN blue under the pointer, as the brand + // marks of the row of /people/ and the role glyph below. + i { + display: inline-block; + color: $icon-grey; + font-size: 1.15em; + width: 1.5em; + text-align: center; + margin-right: 4px; + vertical-align: -2px; + transition: color 0.15s linear; + } + + li:hover, + li:focus-within { + i { + color: $blue-pybcn; + } + + a { + text-decoration: underline; + } + } + } + + // The sentence under the links: the roles, "at", and the number of + // events, the words of the row of /people/, muted like the credit + // line of a talk page and one step larger than body text, because it + // carries most of what a reader came for on a page that may hold + // little else. The count is in the colour of the text, so the number + // stands out of the grey line. + .person_role_line { + font-size: 1.15rem; + color: #51616e; + margin-bottom: 0; + + strong { + color: #212529; + } + } + + // Each block gets the same air under its heading, and the h2 is the + // size of the h2 of the rest of the site, as on the talk and meetup + // pages. Both kinds of block, the page's own and the three lists. + .person_block, + .person_section { + margin-bottom: 2rem; + + h2 { + font-size: 1.5rem; + margin-bottom: 0.8rem; + } + } + + // The roles and the events: the rules at the end of this file. They + // set the list under .person_section, which is why the photos and the + // videos sit in a block of another class: .person_section ul would + // give their lists a disc and a 1.25em indent on top of their own. + + // The day under a video is a grey line with a link in it, and a link + // inside a line of the same colour needs a second mark (WCAG 1.4.1), + // the rule talks.scss applies to a caption and meetup.scss to the + // source line. axe reported link-in-text-block on every talk row. + .talk_meta a { + text-decoration: underline; + } + + // The videos of the talks: one player each (partials/video_frame.html, + // the .video_frame box of talks.scss), the link under it, and the day + // under that, a block of air between one video and the next. + .person_video_list { + list-style: none; + padding-left: 0; + margin: 0; + + li { + margin-bottom: 1.6rem; + } + + li:last-child { + margin-bottom: 0; + } + + .person_video_link { + margin-bottom: 0.2rem; + + a { + text-decoration: underline; + } + } + + .talk_meta { + color: #51616e; + font-size: 0.9rem; + margin-bottom: 0; + } + } + + // The last block, how the page is made and how to correct it: smaller + // than the content it follows, and it reads as a note, as the source + // line of a meetup page does. A link inside a sentence needs a second mark besides the + // colour (WCAG 1.4.1), the rule meetup.scss and talks.scss apply. + .person_correction p { + color: #51616e; + font-size: 0.9rem; + margin-bottom: 0; + + a { + text-decoration: underline; + } + } + + // The photos of the talks, as a grid of squares that fills the column: + // 240px each on a desktop, two across on a phone of 390px. A figure + // per photo, the image as the link and the day under it. + .person_photo_grid { + list-style: none; + padding-left: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 240px)); + gap: 16px; + + figure { + margin: 0; + } + + img { + display: block; + width: 100%; + height: auto; + border-radius: 4px; + } + + figcaption { + color: #51616e; + font-size: 0.9rem; + margin-top: 0.3rem; + } + + // A picture does not underline on hover, so the photo answers the + // pointer and the keyboard with a ring. + a:hover img, + a:focus-visible img { + outline: 2px solid $blue-pybcn; + outline-offset: 2px; + } + } + // The roles the person holds, one line per role, built + // and styled exactly as an event row: the glyph, the + // role in bold, an italic "at", and the page. It was a + // definition list in a two column grid, which put the + // same kind of statement in a different shape from the + // list right under it, and on a phone the label column + // was as wide as "Management Board (2018 - 2025)" and + // left 80px of a 390px screen for the page beside it. + // The events are a table grouped by year since the + // review of 2026-10-14 (see person_appearances.html, + // and .person_event_cell below for the row rules the + // two still share). + + // The list of the page, the roles, with the treatment + // the social links had while they were a list under a + // heading: a marker and the indent that goes with it, + // so it reads as a list. + // Both used to drop the marker and the padding, and + // five events were five loose lines with nothing to + // say they were one list, under a heading styled like + // the one over a table. The indent is the width of + // the marker and a space, no more: the browser default + // of 40px pushed a list in from the headings. + .person_section ul { + list-style: disc; + padding-left: 1.25em; + // The section above sets the gap. + margin: 0; + + li { + margin-bottom: 4px; + } + } + + // The roles, one line each, and the events, one cell + // each, the role before the linked page or title. See + // person_appearances.html. The marker is the role + // glyph that starts the row, not the disc: the row + // carries it as the social rows carry the network + // icon. The padding is the glyph's column, so the + // text starts where it starts under a disc, at the + // padding edge, and a wrapped line starts there too. + // The selector repeats the ul of the rule above, + // because a class alone loses to that class and + // element pair, and the list kept the disc and the + // 1.25em indent under the glyph. The event cell has + // no list style to drop and takes the same padding, + // under lg, where a cell of the reflowed table has no + // padding of its own; from lg the cell padding of + // listing_table.scss is added to it, below. + .person_section ul.appearances, + .person_section .person_event_cell { + list-style: none; + padding-left: calc(1.875em + 8px); + + // The whole row answers the pointer, not only the + // link in it: the glyph, the role and the link are + // one statement, and a reader aiming at any of the + // three is after the same page. focus-within does + // the same for the keyboard, where the link is what + // takes focus. The glyph drops its grey and comes + // back in colour, which is the only place on the + // page a role shows its own colour. + li:hover, + li:focus-within { + .role_emoji { + filter: none; + } + + strong { + color: $blue-pybcn; + } + + a { + text-decoration: underline; + } + } + + // The whole row is the link, not only its text. The + // link stretches over the row with a pseudo element + // rather than by wrapping the row in an anchor, + // which would put the glyph and the role inside the + // link text and read them out with it. + // + // :only-of-type is the guard. A role held on two + // pages prints two links on one row, and there is + // no one row to stretch: those rows keep their own + // link areas. No row has two links today, out of + // 135 role rows and every event row, but the + // template allows it. An event row had a second + // anchor, the thumbnail of the talk photo, while + // the modal of a person card printed this list; + // the page prints the photos as a block of their + // own, so every event row has one link. + // + // The event row is a table cell, and the box that + // positions the link is the cell: position on a + // tr is not laid out the same in every browser, + // and the cell is the whole line of text anyway. + li, + &.person_event_cell { + position: relative; + } + + li > a:only-of-type::after, + &.person_event_cell > a:only-of-type::after { + content: ""; + position: absolute; + inset: 0; + } + + + // A colour change, not a move, so there is nothing + // for prefers-reduced-motion to turn off. + .role_emoji, + strong { + transition: color 0.15s linear, filter 0.15s linear; + } + } + + // The events table: the listing_table rules of + // listing_table.scss, as on /events/ and /talks/, with + // the air of a list under the h2 and not the 2rem a + // whole-page table leaves. From lg the cell has the + // 0.5rem cell padding of that stylesheet, and the hang + // of the glyph is added to it, so the glyph sits in + // the padding as it does under a disc. The year, the + // month, and the day are the 1% nowrap columns of the + // shared rule, the card is its 8rem, and the event + // column takes the rest. + .person_event_table { + margin-bottom: 0; + } + + // The Gallery column of the table takes its content and no more. The + // shared rule of listing_table.scss fixes it at 8rem, the width that + // holds the widest card of two counts on /events/ and lets a card of + // three wrap. Here a card holds at most one photo and one video, of + // one talk, so the 128px column stood mostly empty and the Event + // column wrapped 128px short of the right edge every other block + // reaches, which David Arcos read as a narrower table (2026-10-15). + // 1% is the trick of the three narrow columns of that stylesheet: the + // column is as wide as its widest card. Measured on + // /people/david-arcos/ at 1280px: the table 825px before and after, + // the Event column 548px before and 597px after, and the Gallery + // column 128px and 78px. + @media (min-width: 992px) { + .person_event_table .listing_gallery, + .person_event_table .listing_gallery_col { + width: 1%; + } + } + + // The two marks inside the cell: the emoji of the type + // of the event before the event, as the row of /people/ + // draws it (people_index.scss), and the icon of the type + // of the talk before the talk, as the row of /talks/ + // draws it. Upright, at the normal weight, and with + // line-height 1, so a slanted or heavy emoji, which is a + // font bug on some platforms, does not open the line. + .person_event_cell { + .event_emoji { + display: inline-block; + margin-right: 0.25rem; + font-style: normal; + font-weight: normal; + line-height: 1; + vertical-align: -0.1em; + } + + .talk_marker { + display: inline-block; + margin-right: 0.3rem; + color: $icon-grey; + } + } + + @media (min-width: 992px) { + .person_event_table .person_event_cell { + padding-left: calc(0.5rem + 1.875em + 8px); + } + } + + // The role glyph as the marker of a Roles or an Events + // row, from data/role_emoji.yaml. Sized as the network + // icon of .person_links below: 1.25em in a 1.5em column + // with 8px to the text, so a glyph of any width leaves + // the text on one line down the list. The column is + // 1.875em of the body font plus 8px, which is the + // padding the two lists above set, and the negative + // margin pulls the glyph back into that padding, where + // a disc sits. Pulled back and not placed with + // position, so it stays on the line of its row when + // the row wraps. + // + // Desaturated, so the glyph sits with the black icons + // of Social Links rather than being the one spot of + // colour in the lists. grayscale and nothing else: a + // colour emoji is a shaded picture, and in grey it is + // a light one, which is what this list wants. The + // marker labels the row; the text beside it is what + // the reader is here for, and a grey mark does not + // compete with it. + // + // Measured in Chromium with Noto Color Emoji, over + // the ink of a whole list of glyphs: grayscale alone + // gives a mean luminance of 142 (0 is black) with 2% + // of the ink almost black. Darkening first and then + // stretching the contrast, which this rule did for one + // build, gives a mean of 53 with 64% of the ink almost + // black: the shaded faces of the compass and the + // calendar fill in and the glyph reads as a blot. A + // flat black glyph like the icons is out of reach for + // a filter either way, because the emoji keeps its + // shading. + // + // Upright and at the normal weight, because the dt is + // bold and a slanted or heavy emoji is a font bug on + // some platforms and never a style. line-height: 1 + // keeps the glyph from opening the line box of its + // row further than its own height, and middle-aligned + // the text sits on the centre of the glyph instead of + // on its bottom edge. + .role_emoji { + display: inline-block; + font-size: 1.25em; + font-style: normal; + font-weight: normal; + line-height: 1; + width: 1.5em; + margin-left: calc(-1.5em - 8px); + margin-right: 8px; + text-align: center; + vertical-align: middle; + filter: grayscale(1); + } +} + +// On a phone the portrait gives up some of its size: 240px of 390px is +// most of the screen for one face, and the links wrap under it anyway. +@media (max-width: 575.98px) { + .person_page .person_portrait, + .person_page .person_portrait_link, + .person_page .person_initials { + width: 160px; + height: 160px; + } + + // The zoom at the size of the row's, which fits a 390px screen with + // the margin of the column to spare. + + .person_page .person_initials { + font-size: 3.4rem; + } +} diff --git a/themes/pybcn_theme/layouts/index.llms.txt b/themes/pybcn_theme/layouts/index.llms.txt index 9a07bb096..f4218551d 100644 --- a/themes/pybcn_theme/layouts/index.llms.txt +++ b/themes/pybcn_theme/layouts/index.llms.txt @@ -32,7 +32,7 @@ This file is generated from the site content on every build. {{- end }} ## People -Organizers and speakers have no page of their own. The PyBCN organizers are listed at {{ "pybcn_association/organizers/" | absURL }}, the PyLadies BCN organizers at {{ "pyladies_bcn/organizers-and-speakers/" | absURL }}, and each event page lists its speakers. +Every person the archive credits has a page at {{ "people/" | absURL }}/, with their roles, their talks, the photos of those talks, their events, and their links. All of them are listed at {{ "people/" | absURL }}, where each row links to the page and is itself addressable as {{ "people/" | absURL }}#. The PyBCN organizers are listed with a photo and a bio at {{ "pybcn_association/organizers/" | absURL }}, the PyLadies BCN organizers at {{ "pyladies_bcn/organizers-and-speakers/" | absURL }}, and each event page lists its speakers. ## Sponsors The companies, organizations, and universities that supported an event are listed at {{ "sponsors/" | absURL }}. Each event page lists its own sponsors. diff --git a/themes/pybcn_theme/layouts/partials/image_square.html b/themes/pybcn_theme/layouts/partials/image_square.html index 902b20912..ed5a47d18 100644 --- a/themes/pybcn_theme/layouts/partials/image_square.html +++ b/themes/pybcn_theme/layouts/partials/image_square.html @@ -9,8 +9,10 @@ anchor where to crop from, "Smart" by default A person photo has to be square, because the grid shows it in a square - slot. Only 76 of the 132 photos in the repository are, with ratios from - 0.54 to 1.33, so the rest need cropping. The old grid did that in CSS, by + slot. Only 76 of the 132 photos in the repository were when this partial + was written, with ratios from 0.54 to 1.33, so the rest needed cropping. + bin/square-photos has since cut every one in the repository: all 128 are + square today. The old grid cropped in CSS, by overflowing a square box and hiding the rest, which broke as soon as an element was added between the box and the photo. Cropping here instead means the emitted file is already square and the stylesheet needs no @@ -24,19 +26,21 @@ Smart anchor crops even when the ratios match: smartcrop looks for the most interesting window of the wanted ratio, and that window can be smaller than the whole picture, so it zooms in. On a square portrait that - silently cropped the frame tighter than the author chose. Seventy six of - the photos are already square and none of them needs cropping at all. + silently cropped the frame tighter than the author chose, and every + photo in the repository is square now, so none of them needs cropping + at all. - Smart is a guess and it does get portraits wrong. Twenty five of the - photos are taller than they are wide, and a square crop of one of those - throws away a third of the height, so a bad guess cuts off a head. A page + Smart is a guess and it does get portraits wrong. A portrait is taller + than it is wide, and a square crop of one throws away a third of the + height, so a bad guess cuts off a head. A page can set photo_anchor in its front matter to decide instead: Top is the one that usually helps, because a face sits in the upper part of a portrait. Every photo is emitted at the size asked for, upscaling a small source - rather than leaving it small. Six of the photos in the repository are - 200px or less, and leaving them at their own size produced a grid of + rather than leaving it small. Some of the photos in the repository are + 200px or less (bin/check-content reports the ones under PHOTO_SIDE), and + leaving them at their own size produced a grid of three different sizes: the stylesheet stretched them to the slot anyway, so the browser upscaled them on every view and the width attribute in the HTML described a size nobody saw. Upscaling here is the same picture, is diff --git a/themes/pybcn_theme/layouts/partials/people-grid.html b/themes/pybcn_theme/layouts/partials/people-grid.html index 39ed9f33b..72abb3cb6 100644 --- a/themes/pybcn_theme/layouts/partials/people-grid.html +++ b/themes/pybcn_theme/layouts/partials/people-grid.html @@ -11,8 +11,10 @@ An event page renders this partial once per people section, and the same person can sit in more than one of them. The key goes into every id the - grid emits, so the row ids and the modal ids stay unique on the page. - Styles hook on the people_grid class, not on an id. + grid emits, so the row ids stay unique on the page. Styles hook on the + people_grid class, not on an id. A card has no id of its own: it used to + carry a modal, whose id needed the key, and it is a link to the page of + the person now (see people_summary.html). An entry of a people list is a bare id, or a map with an id and a role: @@ -22,19 +24,19 @@ role: Former President The role replaces the pybcn_position of the person file on this page - only, on the card and in the modal. It exists so that one person file + only, on the card. It exists so that one person file can sit in two sections of /organizers/ with a different role in each: Laura Pérez Mayos is an Organizer under Collaborators and the Former President under the old board. Before this, each role was a second - person file with its own id, and the modal of the second one listed + person file with its own id, and the second one was credited with none of the events the first one was in. With no entry role, the role is the one the heading of the grid gives, and only then the pybcn_position of the person file. The heading is the level name when the level has one and the section title when it does - not, which is the rule appearances_index.html indexes the modal's - appearance list by, so the card and the modal name the same role for - the same listing. The heading goes through data/roles.yaml: "Speakers" + not, which is the rule appearances_index.html indexes the appearance + list of the page of the person by, so the card and that page name the + same role for the same listing. The heading goes through data/roles.yaml: "Speakers" gives Speaker. A heading that the file does not map gives nothing, and the card falls through to pybcn_position: the four level names of /organizers/ (Management Board, Permanent Committee, Collaborators) are @@ -79,7 +81,7 @@

{{ $level.name }}

{{ $role = default $section_role $role }} {{ range $page := where $pages ".Params.id" "eq" $id }}
- {{ partial "people_summary.html" (dict "person" $page "role" $role "key" (printf "%s_%d_%d" $key $idx $pidx)) }} + {{ partial "people_summary.html" (dict "person" $page "role" $role) }}
{{ end }} {{ end }} diff --git a/themes/pybcn_theme/layouts/partials/people_summary.html b/themes/pybcn_theme/layouts/partials/people_summary.html index 7b2678d28..b9efc236d 100644 --- a/themes/pybcn_theme/layouts/partials/people_summary.html +++ b/themes/pybcn_theme/layouts/partials/people_summary.html @@ -1,31 +1,46 @@ {{- /* - One person card, plus the modal that holds the long bio. + One person card: the photo, the name, and the role, as one link to the + page of the person, /people//, with the social links of the record + as a row of icons under it. + + The card used to open a Bootstrap modal that held the bio, the roles, + the events, and the social links of the person. Every person has a page + with all of that and more (the talks and the photos) since the Archive + gave the person files a URL each (see layouts/people/single.html), and + the modal showed a subset of that page: two renderings of one person, + only one of them linkable. So the card links to the page. What that + removes: the modal markup from every card (22 dialogs on /organizers/: + the minified page went from 122702 to 33443 bytes, measured on + 2026-10-14), a heading tree inside a dialog that + bin/check-rendered had to cut out of the outline of the page, the + Bootstrap 4 limit on a modal inside a modal (which is why a photo + thumbnail in the modal had to be a link and not a viewer), and the + dependency on JavaScript for reading about a person. The page of the + person needs none of it. + + One link, not two. The photo and the name used to be two buttons for + one dialog, with the photo at tabindex="-1" so a keyboard user did not + stop twice on every card. One anchor around the photo and the text is + one tab stop and one target with no trick, and the social links under + it stay outside it: an anchor cannot hold another anchor. The alt of + the photo is empty, because the name is the text of the same link and + an alt that repeats it makes a screen reader say the name twice (axe + reports that as image-redundant-alt). The title carries the whole + name, because a name too long for the card is cut with an ellipsis + (see people.scss) and the hover shows it whole. Photos come from assets/images/people/ and go through the Hugo image pipeline. The grid slot is 350 px wide at most, so one 400 px WebP - serves it, with an 800 px variant for a 2x screen. The modal shows the - same file at 64 px: the card has already loaded it by the time the - modal opens, so the modal costs no request, and a file sized for the - modal would be one more download for the same picture. A person with no - photo falls back to anon_member.png. - - The modal shows the photo no larger than the source. image_square.html - enlarges a small source to 400 px for the grid, where every slot has to - be one size, and bin/check-content warns about the 28 photos that are - under 400 px. The modal has no slot to fill, so a photo with a short - side under the shown size is shown at its own size instead. At 160 px - one source is under it, patricio-reyes-valenzuela.jpg at 120 px square, - and it is shown at 120. Every other source is 199 px or more. The rule - matters more than the count: it is what stops the modal showing an - enlarged, soft face, which is what it did at 300 px, where 21 photos - came out half again their size. + serves it, with an 800 px variant for a 2x screen. A person with no + photo falls back to anon_member.png. image_square.html enlarges a small + source to 400 px, where every slot has to be one size, and + bin/check-content warns about the photos that are under 400 px. Arguments: person the person page role optional, the role to show instead of pybcn_position: the one the page entry carries, or else the one the heading of the grid gives (see people-grid.html) - key the grid key, so the modal id is unique on the page A remote photo is not supported. The template used to accept photo_url and print it in a plain img, which made every page that lists the person @@ -33,8 +48,6 @@ person used the field, and bin/check-content now rejects it. */ -}} {{- $role := default $.person.Params.pybcn_position $.role -}} -{{- $modal := printf "modal_%s" $.person.Params.id -}} -{{- with $.key -}}{{- $modal = printf "modal_%s_%s" . $.person.Params.id -}}{{- end -}} {{- $photo := $.person.Params.photo -}} {{- $img := "" -}} {{- if $photo -}} @@ -44,12 +57,7 @@ {{- $img = resources.Get "images/anon_member.png" -}} {{- end -}} {{- $small := "" -}}{{- $large := "" -}}{{- $srcset := "" -}} -{{- /* The side the modal shows the photo at: 64 px, or the short side of - the source when that is smaller. See the note at the top. The same - number is in people.scss, as the size of the round frame. */ -}} -{{- $shown := 160 -}} {{- with $img -}} - {{- $shown = int (math.Min 160 (math.Min .Width .Height)) -}} {{- $anchor := default "Smart" $.person.Params.photo_anchor -}} {{- $small = partial "image_square.html" (dict "img" . "size" 400 "anchor" $anchor) -}} {{- $large = partial "image_square.html" (dict "img" . "size" 800 "anchor" $anchor) -}} @@ -59,32 +67,23 @@ {{- end -}}
-
- {{/* A button, not a link: it opens a dialog rather than navigating. - The alt names the person, because the photo is the only content. */}} - {{/* tabindex="-1": the name below opens the same dialog, so a keyboard - user would otherwise stop twice on every card for one action. The - photo stays clickable with a pointer. */}} - -
-
- {{/* title carries the whole name, because a name too long for the - card is cut with an ellipsis (see people.scss). The modal shows - it in full as well. */}} -

- {{/* Left out when empty, as in the modal below: a person with no - position under a heading roles.yaml does not map has no role, - and the line used to print as an empty paragraph. The text - alone: the role glyph of data/role_emoji.yaml sat here for - one commit, and it read as a stray character of the italic - line rather than as a mark. It is the list marker of the - Roles and Events rows of the modal now, where it has a column - of its own, like the network icon in Social Links (see - person_appearances.html). */}} - {{ with $role }}

{{ . }}

{{ end }} -
+ +
+ +
+
+

{{ $.person.Params.name }}

+ {{/* Left out when empty: a person with no position under a + heading roles.yaml does not map has no role, and the line + used to print as an empty paragraph. The text alone: the + role glyph of data/role_emoji.yaml sat here for one + commit, and it read as a stray character of the italic + line rather than as a mark. It is the list marker of the + Roles and Events rows of the page of the person now, where + it has a column of its own (see person_appearances.html). */}} + {{ with $role }}

{{ . }}

{{ end }} +
+
-
- - - diff --git a/themes/pybcn_theme/layouts/partials/person_appearances.html b/themes/pybcn_theme/layouts/partials/person_appearances.html index 7ed2d89df..738dd2da6 100644 --- a/themes/pybcn_theme/layouts/partials/person_appearances.html +++ b/themes/pybcn_theme/layouts/partials/person_appearances.html @@ -1,101 +1,96 @@ {{- /* - The pages one person appears on, as two lists with one line each. + The pages one person appears on, for the page of that person + (layouts/people/single.html, the one caller): the Roles list and the + Events table. - Roles + Roles (2) Permanent Committee at Organizers Organizer at PyLadies BCN - Events - Speaker at PyDay BCN (2025) - Organizer at PyDay BCN (2024) - Mentor and Speaker at Hands-on Django with PyLadiesBCN (2022) - Organizer at PyDay BCN (2021) + Events (14) + Year Month Day Event Gallery + 2025 Nov 29 [mic] Speaker at [snake] PyDay BCN (2025): + [comment] Introduction to Analog Quantum Computing [camera] 1 + 2024 Nov 16 [compass] Organizer at [snake] PyDay BCN (2024) + 2022 Jun 11 [cap] Mentor at [party] Hands-on Django with PyLadiesBCN (2022) + [mic] Speaker at [party] Hands-on Django with PyLadiesBCN (2022): + [laptop] Hands-on Django [camera] 2 - Every line of both lists is built the same way: the role glyph, the - role in bold, an italic "at", and the page it is held on. Roles was a - definition list laid out as a table of label and value, which said the - same kind of thing as the line under it in a different shape. + Argument: a dict with + page the person page + talks the talks of the person, the list + partials/person_talks_index.html holds for the id, or nil - Argument: the person page. + Each list is a section of class person_section under an h2, the level + under the h1 of the hero (bin/check-rendered fails a page that skips a + level), and people_page.scss styles the rows. The heading carries the + count, as every heading over a list of things does on this page. The index comes from appearances_index.html, built once per build. - - The list is the same whatever page the modal is opened from. It used - to leave out that page, so that nobody got a link back to the page - they were reading, and on /organizers/ that hid the level of the page, - which is the information: that Jordi Bosch is on the Management Board - (2026), that Laura Pérez Mayos is a Collaborator and was on the - Management Board (2018 - 2025). The modal is on its way to becoming a - page per person with its own URL, and for that its content has to be - the same from everywhere: a list that depends on the page it was - opened from cannot become one page. So the current page stays in, link - and all. On /organizers/ the first row links to the page the reader is - on. That is the price of one list, and it stops being odd the day the - modal is a page. + The list includes the pages that link here, because this page has one + URL and its content cannot depend on where the reader came from. A page with no year is a role, not an event: the organizers pages of - PyBCN and of PyLadies BCN. A page with a year is an event. The two used - to share one list grouped by role, which put "Organizer" with four - events on one row under "Permanent Committee" with one page, and a - reader had to work out that the first was a position and the second a - history. They are now two lists under two headings, each one left out - when it is empty. + PyBCN and of PyLadies BCN. A page with a year is an event. Roles: + grouped by label, one line per label, the pages that give it as + links. The order is the page and then the order the page itself lists + its levels, which is current board first. Ties fall back to the title + and the label, so two builds give the same order. Each line is the + role glyph, the label in bold, an italic "at", and the page. - Roles: grouped by label, one line per label, the pages that give it as - links. A label held on two pages lists both, separated by a comma. The order is the page and then the order the page itself lists - its levels, which is current board first: sorting them by label put - Management Board (2018 - 2025) above Permanent Committee, and the - PyLadies page above both. The page comes from its path, so the PyBCN - organizers page sorts before the PyLadies one. Ties fall back to the - title and the label, so two builds give the same order. + Events: one row per appearance. The table held one row per event, + "Mentor and Speaker at Hands-on Django", and a Talks list above it + held one row per talk, with the same day on it: the two said the same + thing twice, because a talk is something the person did at an event. + David Arcos asked for one table (2026-10-15). A talk the person gave + is a row of its own, "Speaker at : ", with the card of + what that appearance left behind, a photograph or a video, from the + talk record. The other roles of the person at the same event are one + row above the talks, "Mentor at ", "Organizer at ", and + a Speaker label with no talk record behind it (an edition that lists + its speakers by hand and carries no talks block) keeps a row of its + own. A talk on a page with no year has no row, because the page is no + event; none is in that state today. - Events: one line per event, newest first, the role on the line. The - old grouping by role listed the events of each role on one row, so a - reader had to merge four rows to see when the person was active, and - the rows themselves were in no date order. When the meetups arrive - from the Archive this list gets long, and a list that is not in date - order cannot be read. Within one year the tiebreak is the page title - and then its URL: the pages carry a year and no date, and the title is - what the reader sees, so the rule is visible. A person with two roles - at one event, Ferran Jovell as Mentor and Speaker of the Django day, - gets one line with both roles, sorted by name and joined with "and": - two lines for one event would read as two events, and the date order - is the point of the list. + The columns are the ones of /talks/: the year, the month, and the day + once per group, spanning their rows, so /people//#2019 points at + a year the way /events/#2019 does, then the appearance, then the card. + The month and the day come from the date of the page, carried by the + index and parsed through partials/event_date.html with the URL as the + cache key, the key the listings use, so an edition parsed for /talks/ + is a cache hit here. Within one year the rows are newest first, then + the event title and URL, then the place of the talk in the programme + of the day, so the rows of one event are together and the day cell + spans them. The markup is the one of layouts/talks/list.html, with + every table element carrying its role written out, because under lg + the display of a table element is changed and Chromium and WebKit + then drop its table semantics (events_index.html has the detail); + listing_table.scss styles the three tables together. - A person with no appearance gets nothing: no heading, no empty list. - Everyone on /organizers/ has at least that page, but the index covers - only the people listed there, so a speaker of one event who is not an - organizer still has an empty list. The Archive will widen this. + The cell: the glyph of the first role, the roles in bold joined by + "and", in the order of the association (partials/role_order.html, the + order of the role line of the header), the italic "at", the emoji of the type of the event as the row + of /people/ draws it, with the name of the type as its accessible + name, the event as a link, and, on a talk row, a colon, the icon of + the type of the talk, and the talk as the link to its page. Bold + carries the roles and italic the words that join them, so a reader + can pick the role out of the line. The role glyph is hidden from a + screen reader: the role is the text right after it. - Each row of both lists starts with the glyph of its role from - data/role_emoji.yaml, as its list marker, the way each row of Social - Links starts with the icon of its network (see person_links.html). The - glyph was on the card, next to the role under the name, and read there - as a stray character of the italic line; as the marker of a row it has - a column of its own, and the three lists of the modal read as one kind - of thing. An events row has its roles, so the glyph is the one of the - first role in the sorted list: Ferran Jovell as "Mentor and Speaker" - gets the mentor's cap, because the row has one marker, as the social - rows have one icon, and the first role is the one the eye meets. A - roles row is keyed by its label, which is a level name through - roles.yaml, so a label that is a role, "Organizer", finds - its glyph, and a level that is not a role, "Permanent Committee" or - "Collaborators", takes the "*" fallback of the table. + The heading counts events, not rows: "Events (14)" is the sentence of + the row of /people/ and of the header of this page, and a row is an + appearance, which the caption says. - The glyph is hidden from a screen reader in both lists. In the events - list the role is the text right after it, so a label would make the - reader say "Speaker, Speaker" on every row. In the roles list the text - is the level, not the role, but the glyph never says more than the - text does: the table is keyed by role, so a label that finds a glyph - is itself a role name and is read as such, and a label that does not - gets the neutral fallback, which says nothing a name could carry. + A person with no appearance gets nothing: no heading, no empty list. */ -}} +{{- $page := .page -}} +{{- $talks := .talks -}} {{- $index := partialCached "appearances_index.html" site "appearances-index" -}} {{- $groups := dict -}} {{- $events := dict -}} -{{- range index $index .Params.id -}} +{{- range index $index $page.Params.id -}} {{- if .year -}} - {{- $ev := index $events .url | default (dict "title" .title "url" .url "year" .year "labels" slice) -}} + {{- $ev := index $events .url | default (dict "title" .title "url" .url "year" .year "type" .type "date" .date "end_date" .end_date "labels" slice) -}} {{- $ev = merge $ev (dict "labels" ($ev.labels | append .label)) -}} {{- $events = merge $events (dict .url $ev) -}} {{- else -}} @@ -109,40 +104,144 @@ {{- $first := index $items 0 -}} {{- $roles = $roles | append (dict "label" $label "items" $items "key" (printf "%s %04d %s" $first.url $first.ord $label)) -}} {{- end -}} -{{- $list := slice -}} + +{{- /* The talks of the person by the URL of their event. */ -}} +{{- $talks_by_event := dict -}} +{{- with $talks -}} + {{- range $t := . -}} + {{- with $t.event_url -}} + {{- $talks_by_event = merge $talks_by_event (dict . (append $t (default slice (index $talks_by_event .)))) -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{- /* The rows, by year, with the counts of the month and the event + groups, which are the rowspans. */ -}} +{{- $by_year := dict -}} +{{- $month_count := dict -}} +{{- $event_count := dict -}} {{- range $url, $ev := $events -}} - {{- /* 9999 minus the year, so an ascending sort reads newest first. */ -}} - {{- $list = $list | append (merge $ev (dict "labels" (sort $ev.labels) "key" (printf "%04d %s %s" (sub 9999 $ev.year) $ev.title $url))) -}} + {{- $when := partialCached "event_date.html" (dict "date" $ev.date "end_date" $ev.end_date) $url -}} + {{- $ordinal := 0 -}} + {{- $date := dict "month" "" "month_key" "" "day" "" "start_iso" "" -}} + {{- with $when.start -}} + {{- $ordinal = int (time.Format "20060102" .) -}} + {{- $date = dict + "month" $when.month + "month_key" (time.Format "200601" .) + "day" $when.day + "start_iso" (time.Format "2006-01-02" .) -}} + {{- end -}} + {{- $marker := default (index site.Data.event_emoji "*") (index site.Data.event_emoji $ev.type) -}} + {{- $event_talks := sort (default slice (index $talks_by_event $url)) "key" -}} + {{- $labels := partial "role_order.html" (uniq $ev.labels) -}} + {{- /* The roles that are not the talks: every label, or every label + but Speaker when a talk record carries the credit. */ -}} + {{- $others := slice -}} + {{- range $labels -}} + {{- if or (ne . "Speaker") (not $event_talks) }}{{ $others = $others | append . }}{{ end -}} + {{- end -}} + {{- $month_group := printf "%d|%s" $ev.year $date.month_key -}} + {{- $event_group := printf "%d|%s" $ev.year $url -}} + {{- $base := merge $date (dict + "year" $ev.year + "event_title" $ev.title + "event_url" $url + "emoji" $marker.emoji + "mark_label" $marker.label + "month_group" $month_group + "event_group" $event_group) -}} + {{- $rows := slice -}} + {{- with $others -}} + {{- $rows = $rows | append (merge $base (dict "labels" . "talk" "" "card" slice "key" (printf "%08d %s %s %03d" (sub 99999999 $ordinal) $ev.title $url 0))) -}} + {{- end -}} + {{- range $n, $t := $event_talks -}} + {{- $card := slice + (dict "kind" "photo" "count" (len $t.photos)) + (dict "kind" "video" "count" (cond (ne $t.video "") 1 0)) -}} + {{- $rows = $rows | append (merge $base (dict "labels" (slice "Speaker") "talk" $t "card" $card "key" (printf "%08d %s %s %03d" (sub 99999999 $ordinal) $ev.title $url (add $n 1)))) -}} + {{- end -}} + {{- $ykey := printf "%d" $ev.year -}} + {{- range $rows -}} + {{- $month_count = merge $month_count (dict $month_group (add 1 (int (default 0 (index $month_count $month_group))))) -}} + {{- $event_count = merge $event_count (dict $event_group (add 1 (int (default 0 (index $event_count $event_group))))) -}} + {{- $by_year = merge $by_year (dict $ykey (append . (default slice (index $by_year $ykey)))) -}} + {{- end -}} +{{- end -}} +{{- $year_groups := slice -}} +{{- range $ykey, $rows := $by_year -}} + {{- $year := int $ykey -}} + {{- $year_groups = $year_groups | append (dict "year" $year "rows" (sort $rows "key") "key" (printf "%04d" (sub 9999 $year))) -}} {{- end -}} + {{- with $roles -}} -