Skip to content

Commit 002e358

Browse files
committed
fix(programa): eliminate view-mode flash on first paint
Pre-paint script in <head> now sets data-schedule-view on <html> before the first paint, so the saved view (list/grid) is applied without a flash of the other view. When the URL has a session hash, the view is forced to 'list' so the user lands directly on the targeted session. CSS now drives both view visibility and the visual highlight on the view-mode toggle labels from the same data-schedule-view attribute, so the active state matches the view on first paint. The radio's hardcoded checked attribute is removed to avoid a visual mismatch where grid would flash as active before the JS sync ran. The favorites filter CSS was moved to Layout.astro's global styles because the target <li> elements live in a child component (ScheduleList) with a different Astro scope, which made the previously-scoped rule unable to match.
1 parent 9a05178 commit 002e358

3 files changed

Lines changed: 89 additions & 37 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "2026.es.pycon.org",
3-
"version": "1.17.3",
3+
"version": "1.17.4",
44
"description": "",
55
"main": "index.js",
66
"scripts": {

src/components/ProgramaPage.astro

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const schedule = await getSchedule()
3939
<label
4040
class="cursor-pointer px-4 py-2 rounded-lg text-sm has-[:checked]:bg-pycon-orange has-[:checked]:text-pycon-black has-[:checked]:font-semibold text-pycon-gray-25 transition-colors"
4141
>
42-
<input type="radio" name="schedule-view" value="grid" class="sr-only" checked />
42+
<input type="radio" name="schedule-view" value="grid" class="sr-only" />
4343
{t.list.viewModeGrid}
4444
</label>
4545
</fieldset>
@@ -139,23 +139,6 @@ const schedule = await getSchedule()
139139

140140
<div id="favorite-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
141141

142-
<style>
143-
.schedule-view.hidden {
144-
display: none;
145-
}
146-
</style>
147-
148-
<style is:global>
149-
/* Hide non-favourite session cards when the favourites filter is on.
150-
The .schedule-list and its <li> children are rendered by
151-
ScheduleList.astro (its own Astro scope, data-astro-cid-zmszfzov),
152-
so we use is:global here to let this rule reach across component
153-
boundaries. */
154-
#schedule-list-view.filter-favorites-only .schedule-list li:not(.is-favorite) {
155-
display: none;
156-
}
157-
</style>
158-
159142
<script is:inline src="https://pretalx.com/pycones-2026/widgets/schedule.js"></script>
160143

161144
<script is:inline>
@@ -167,13 +150,15 @@ const schedule = await getSchedule()
167150
const views = document.querySelectorAll('.schedule-view')
168151

169152
function applyView(value) {
170-
views.forEach((v) => {
171-
if (v.getAttribute('data-view') === value) {
172-
v.classList.remove('hidden')
173-
} else {
174-
v.classList.add('hidden')
175-
}
176-
})
153+
// Visibility is driven declaratively from CSS via the
154+
// data-schedule-view attribute on <html> (set by the pre-paint
155+
// script in the Layout). We only need to keep that attribute and
156+
// localStorage in sync here.
157+
if (value === 'list' || value === 'grid') {
158+
document.documentElement.setAttribute('data-schedule-view', value)
159+
} else {
160+
document.documentElement.removeAttribute('data-schedule-view')
161+
}
177162
try {
178163
localStorage.setItem(STORAGE_KEY, value)
179164
} catch (e) {}
@@ -187,17 +172,17 @@ const schedule = await getSchedule()
187172
})
188173

189174
try {
190-
const saved = localStorage.getItem(STORAGE_KEY)
191-
// Persistence rule: the first time the user lands on /programa
192-
// (no saved value, or stored under the old 'list' default from a
193-
// previous design) we show the current default, which is the
194-
// visual grid. From then on, the user's choice wins: a stored
195-
// 'list' or 'grid' is restored on the next visit.
196-
if (saved === 'list' || saved === 'grid') {
197-
const target = document.querySelector(`input[name="schedule-view"][value="${saved}"]`)
198-
if (target) {
199-
target.checked = true
200-
applyView(saved)
175+
// If a session hash is present, force the list view. Otherwise,
176+
// restore the radio state from the saved preference.
177+
const hasHash = location.hash && location.hash.length > 1
178+
if (hasHash) {
179+
const listRadio = document.querySelector('input[name="schedule-view"][value="list"]')
180+
if (listRadio) listRadio.checked = true
181+
} else {
182+
const saved = localStorage.getItem(STORAGE_KEY)
183+
if (saved === 'list' || saved === 'grid') {
184+
const target = document.querySelector(`input[name="schedule-view"][value="${saved}"]`)
185+
if (target) target.checked = true
201186
}
202187
}
203188
} catch (e) {}

src/layouts/Layout.astro

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,28 @@ const alternates = [
112112
</script>
113113

114114
<ClientRouter />
115+
116+
<!--
117+
Pre-paint schedule view preference for /programa.
118+
Runs synchronously before the first paint so the user's saved view
119+
choice (list vs grid) is applied without a flash of the other view.
120+
The CSS that reads this attribute lives further down in <style is:global>.
121+
-->
122+
<script is:inline>
123+
;(function () {
124+
try {
125+
if (typeof location !== 'undefined' && /^\/(es|en|ca)\/programa\/?$/.test(location.pathname)) {
126+
var saved = localStorage.getItem('pycones-schedule-view')
127+
if (saved === 'list' || saved === 'grid') {
128+
document.documentElement.setAttribute('data-schedule-view', saved)
129+
}
130+
if (location.hash && location.hash.length > 1) {
131+
document.documentElement.setAttribute('data-schedule-view', 'list')
132+
}
133+
}
134+
} catch (e) {}
135+
})()
136+
</script>
115137
</head>
116138

117139
<body
@@ -201,4 +223,49 @@ const alternates = [
201223
white-space: nowrap;
202224
border: 0;
203225
}
226+
227+
/* Schedule view preference for /programa.
228+
The default state shows the visual grid. A small inline script in
229+
<head> sets data-schedule-view="list" or "grid" on <html> before the
230+
first paint, based on the user's saved localStorage value, so there's
231+
no flash of the other view. The same attribute is updated by the
232+
toggle script in the page when the user changes their choice. */
233+
#schedule-list-view,
234+
#schedule-grid-view {
235+
display: none;
236+
}
237+
html[data-schedule-view='list'] #schedule-list-view {
238+
display: block;
239+
}
240+
html[data-schedule-view='grid'] #schedule-grid-view,
241+
html:not([data-schedule-view]) #schedule-grid-view {
242+
display: block;
243+
}
244+
245+
/* Visual highlight on the view-mode toggle labels must follow
246+
data-schedule-view (set before paint) so the active state matches
247+
the view on first paint. The :checked highlight still works for
248+
user-initiated changes via JS that sets radio.checked. */
249+
.schedule-view-toggle label {
250+
color: var(--color-pycon-gray-25, #c6c6c6);
251+
background: transparent;
252+
}
253+
html[data-schedule-view='list'] .schedule-view-toggle label:has(input[value='list']) {
254+
background: var(--color-pycon-orange, #ff8200);
255+
color: var(--color-pycon-black, #1d1d1b);
256+
font-weight: 600;
257+
}
258+
html[data-schedule-view='grid'] .schedule-view-toggle label:has(input[value='grid']),
259+
html:not([data-schedule-view]) .schedule-view-toggle label:has(input[value='grid']) {
260+
background: var(--color-pycon-orange, #ff8200);
261+
color: var(--color-pycon-black, #1d1d1b);
262+
font-weight: 600;
263+
}
264+
265+
/* Favorites filter: hide non-favorite sessions when the filter is active.
266+
Lives here (global) so it can target <li> elements rendered by the
267+
child ScheduleList component, which have a different Astro scope. */
268+
#schedule-list-view.filter-favorites-only .schedule-list li:not(.is-favorite) {
269+
display: none;
270+
}
204271
</style>

0 commit comments

Comments
 (0)