Animations, GIFs and the globe now move for every reader
Build / Build (push) Successful in 33s

decePub no longer looks at the system's "reduce motion" setting, so everything moves the same way whatever it says:
- a GIF video in a post plays by itself, muted and looping, instead of waiting for a tap; it now also starts on a page
  just opened, before the first tap, where the browser used to hold it still;
- animated custom emoji move in the emoji picker instead of showing a still picture;
- the globe turns by itself while no located post is in view, flies to the posts, and its pulses, arcs and Sun move;
- going back to the top of a feed scrolls smoothly;
- tooltips, dropdowns, menus, folding sections, toasts, loading spinners, placeholders, progress bars, radio buttons
  and ratings keep their animations: the build now takes daisyUI's reduce-motion rules out of the stylesheet
  (tools/css/motion.py, run after every Tailwind build, in watch mode too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEKqxKjKyHkFzuRVMNA
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-11-09 20:48:47 +01:00
1 parent eb4ea2947c
commit 697b2c8897
11 files changed
+113 -55

No files matched your search

+17 -8
View File
@@ -80,6 +80,10 @@ without network access. NuGet restore still uses nuget.org.
- `IncludeTailwindOutput` adds the generated file to `Content` before static web assets are resolved. Without that, a - `IncludeTailwindOutput` adds the generated file to `Content` before static web assets are resolved. Without that, a
clean-clone publish would ship `app.css` without its `.gz`/`.br` copies and outside `service-worker-assets.js`. clean-clone publish would ship `app.css` without its `.gz`/`.br` copies and outside `service-worker-assets.js`.
The deploy workflow checks for `css/app.css.gz`. The deploy workflow checks for `css/app.css.gz`.
- Right after Tailwind writes `app.css`, `tools/css/motion.py` (run by `TailwindCompile` and by `tailwind.sh build` and
`watch`) deletes the blocks daisyUI emits for `prefers-reduced-motion: reduce` and unwraps its `no-preference` ones,
so its transitions and animations play for everyone. It fails the build on a query it cannot rewrite. It needs
`python3`.
- `bash tools/tailwind.sh watch` rebuilds the CSS live next to `dotnet watch`. - `bash tools/tailwind.sh watch` rebuilds the CSS live next to `dotnet watch`.
- Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/` and `wwwroot/vendor/`), and only - Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/` and `wwwroot/vendor/`), and only
generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (`"btn-" + size`, `$"btn-{size}"`). Write generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (`"btn-" + size`, `$"btn-{size}"`). Write
@@ -355,7 +359,9 @@ Never redeclare the base's members in a component.
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original" status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`. (`data-media-unavailable`), and a gifv plays by itself, muted and looping, for every reader. Blazor writes `muted`
only as an attribute, which leaves the video unmuted and Chromium refuses to autoplay it before the reader's first
tap, so `main.js` sets the `muted` property of every video carrying the attribute on its `loadstart`.
- **About** (`Pages/About.razor` at `/about`, no sign-in, `data-about`; linked from NavMenu as `data-nav-about` and from - **About** (`Pages/About.razor` at `/about`, no sign-in, `data-about`; linked from NavMenu as `data-nav-about` and from
Login as `data-about-offer`): the server's name (`data-about-title`), short description and thumbnail, its long Login as `data-about-offer`): the server's name (`data-about-title`), short description and thumbnail, its long
description (`data-about-description`, `/api/v1/instance/extended_description`), contact (`data-about-contact`), rules description (`data-about-description`, `/api/v1/instance/extended_description`), contact (`data-about-contact`), rules
@@ -612,11 +618,11 @@ Never redeclare the base's members in a component.
quick choices (`Quick`), the server's emoji by category (none first) and common unicode ones by group quick choices (`Quick`), the server's emoji by category (none first) and common unicode ones by group
(`Helpers/UnicodeEmojis.cs`, single emoji only, searched by their Mastodon/GitHub shortcode and a few words). Each (`Helpers/UnicodeEmojis.cs`, single emoji only, searched by their Mastodon/GitHub shortcode and a few words). Each
choice is `data-emoji-choice` (the shortcode, or the character) and goes to `OnPick` as `:shortcode:` or the choice is `data-emoji-choice` (the shortcode, or the character) and goes to `OnPick` as `:shortcode:` or the
character; it closes the picker first. A GIF shows its still picture under `prefers-reduced-motion`. The card's React character; it closes the picker first. An animated emoji moves in the picker for every reader (its `url`, never its
button (`data-react`) and the announcements banner's (`data-announcement-react`) react with it, after their quick `static_url`). The card's React button (`data-react`) and the announcements banner's (`data-announcement-react`)
reactions; the composer's emoji button writes it where the caret is, replacing a selection, else at the end, a react with it, after their quick reactions; the composer's emoji button writes it where the caret is, replacing a
shortcode with a space on each side where a letter would touch it (`AppStatusService.CaretOf` / `PlaceCaret`, selection, else at the end, a shortcode with a space on each side where a letter would touch it
`main.js`'s `caretOf` and `placeCaret`). (`AppStatusService.CaretOf` / `PlaceCaret`, `main.js`'s `caretOf` and `placeCaret`).
- **Suggestions:** in the composer, `:` and two letters or more at the end of the words offer the matching emoji - **Suggestions:** in the composer, `:` and two letters or more at the end of the words offer the matching emoji
(shortcodes starting with it first, eight at most) among the mention and hashtag suggestions, as (shortcodes starting with it first, eight at most) among the mention and hashtag suggestions, as
`data-emoji-suggestion="{shortcode}"`; one picked is written `:shortcode: `. `data-emoji-suggestion="{shortcode}"`; one picked is written `:shortcode: `.
@@ -765,8 +771,8 @@ Never redeclare the base's members in a component.
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in - **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the `wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU arc to a hovered post, moves the same way for every reader (camera flights, the Sun, pulses, arcs, auto-rotation),
time; `wake()` before anything that animates). and draws only while something moves (idle, it costs no GPU time; `wake()` before anything that animates).
- **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't - **Map.** Each pixel computes its own map coordinates from its point on the sphere (`sharpen`), so the poles don't
pinch; the maps wrap horizontally and get 8× anisotropic filtering (`keepSharp`). The day texture is NASA's, its pinch; the maps wrap horizontally and get 8× anisotropic filtering (`keepSharp`). The day texture is NASA's, its
seam repaired by `tools/globe/prepare-day-texture.py` (dev-only, needs Pillow). seam repaired by `tools/globe/prepare-day-texture.py` (dev-only, needs Pillow).
@@ -978,6 +984,9 @@ The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noin
## Conventions ## Conventions
- **Motion plays for everyone** (owner, 9 November 2026): nothing reads `prefers-reduced-motion` or any equivalent
(`matchMedia`, `motion-safe:`/`motion-reduce:`, a flag fed from it), so animations, GIFs and gifvs, smooth scrolling
and the globe behave the same for every reader.
- Indent with tabs. - Indent with tabs.
- `Nullable` is disabled and `ImplicitUsings` is enabled. - `Nullable` is disabled and `ImplicitUsings` is enabled.
- Global Razor usings are in `_Imports.razor`. - Global Razor usings are in `_Imports.razor`.
+1 -2
View File
@@ -907,8 +907,7 @@
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")" <img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" @onerror="() => unavailableMedia.Add(media.Id)" /> src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" @onerror="() => unavailableMedia.Add(media.Id)" />
</button>, </button>,
// under reduced motion a gifv waits for a tap (its controls) "gifv" => @<video class="w-full rounded-lg" autoplay muted loop
"gifv" => @<video class="w-full rounded-lg" autoplay="@(!CascadingState.Status.PrefersReducedMotion())" controls="@CascadingState.Status.PrefersReducedMotion()" muted loop
playsinline poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>, playsinline poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata" "video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>, poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
+1 -4
View File
@@ -53,7 +53,7 @@
OnClick="@(() => Pick($":{emoji.Shortcode}:"))" title="@($":{emoji.Shortcode}:")" aria-label="@($":{emoji.Shortcode}:")" OnClick="@(() => Pick($":{emoji.Shortcode}:"))" title="@($":{emoji.Shortcode}:")" aria-label="@($":{emoji.Shortcode}:")"
data-emoji-choice="@emoji.Shortcode"> data-emoji-choice="@emoji.Shortcode">
@* alt="": the button is named by the shortcode. *@ @* alt="": the button is named by the shortcode. *@
<img alt="" loading="lazy" draggable="false" class="h-6 w-6 object-contain" src="@PictureOf(emoji)" /> <img alt="" loading="lazy" draggable="false" class="h-6 w-6 object-contain" src="@emoji.Url" />
</DButton> </DButton>
} }
</div> </div>
@@ -143,9 +143,6 @@
.Where(g => g.Emojis.Count > 0) .Where(g => g.Emojis.Count > 0)
]; ];
// the still picture when the reader asked for less motion (an animated GIF moves)
string PictureOf(CustomEmoji emoji) => CascadingState.Status.PrefersReducedMotion() && emoji.StaticUrl is { Length: > 0 } still ? still : emoji.Url;
async Task Opened(bool open) async Task Opened(bool open)
{ {
isOpen = open; isOpen = open;
-5
View File
@@ -31,11 +31,6 @@ public class AppStatusService(IJSRuntime jsRuntime)
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap // whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover"); public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
// read once: a card asks it for each gifv it shows
bool? reducedMotion;
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
// puts the text on the clipboard; false when the browser refused it, which main.js reports through logFromJs // puts the text on the clipboard; false when the browser refused it, which main.js reports through logFromJs
public async ValueTask<bool> CopyText(string text, CancellationToken cancellationToken = default) => public async ValueTask<bool> CopyText(string text, CancellationToken cancellationToken = default) =>
await jsRuntime.InvokeAsync<bool>("copyText", cancellationToken, text); await jsRuntime.InvokeAsync<bool>("copyText", cancellationToken, text);
-6
View File
@@ -282,12 +282,6 @@
stroke-dasharray: 2 6; stroke-dasharray: 2 6;
} }
@media (prefers-reduced-motion: reduce) {
.globe-ring-value {
transition: none;
}
}
.globe-legend { .globe-legend {
position: absolute; position: absolute;
left: 0.5rem; left: 0.5rem;
+2 -1
View File
@@ -46,9 +46,10 @@
<Target Name="TailwindCompile" DependsOnTargets="EnsureTailwindTools" <Target Name="TailwindCompile" DependsOnTargets="EnsureTailwindTools"
Condition="'$(DesignTimeBuild)' != 'true' And '$(NoBuild)' != 'true' And '$(SkipTailwind)' != 'true'" Condition="'$(DesignTimeBuild)' != 'true' And '$(NoBuild)' != 'true' And '$(SkipTailwind)' != 'true'"
Inputs="@(TailwindSource);$(TailwindExe);$(TailwindVendorDir)daisyui.mjs;$(TailwindVendorDir)daisyui-theme.mjs;$(MSBuildProjectFullPath)" Inputs="@(TailwindSource);$(TailwindExe);$(TailwindVendorDir)daisyui.mjs;$(TailwindVendorDir)daisyui-theme.mjs;tools/css/motion.py;$(MSBuildProjectFullPath)"
Outputs="wwwroot/css/app.css;$(IntermediateOutputPath)tailwind.stamp"> Outputs="wwwroot/css/app.css;$(IntermediateOutputPath)tailwind.stamp">
<Exec Command="&quot;$(TailwindExe)&quot; -i Styles/app.css -o wwwroot/css/app.css $(TailwindMode)" WorkingDirectory="$(MSBuildProjectDirectory)" /> <Exec Command="&quot;$(TailwindExe)&quot; -i Styles/app.css -o wwwroot/css/app.css $(TailwindMode)" WorkingDirectory="$(MSBuildProjectDirectory)" />
<Exec Command="python3 -I tools/css/motion.py wwwroot/css/app.css" WorkingDirectory="$(MSBuildProjectDirectory)" />
<!-- The CLI leaves an unchanged output untouched, which would make this target rerun on every build. --> <!-- The CLI leaves an unchanged output untouched, which would make this target rerun on every build. -->
<Touch Files="wwwroot/css/app.css;$(IntermediateOutputPath)tailwind.stamp" AlwaysCreate="true" /> <Touch Files="wwwroot/css/app.css;$(IntermediateOutputPath)tailwind.stamp" AlwaysCreate="true" />
</Target> </Target>
+71
View File
@@ -0,0 +1,71 @@
import re
import sys
from pathlib import Path
MEDIA = re.compile(r'@media([^{;]*prefers-reduced-motion[^{;]*)\{')
MOTION = re.compile(r'\(\s*prefers-reduced-motion\s*:\s*(reduce|no-preference)\s*\)')
def block_end(css, start):
depth, i, quote = 1, start, None
while depth:
ch = css[i]
if quote:
if ch == '\\':
i += 1
elif ch == quote:
quote = None
elif ch in '"\'':
quote = ch
elif ch == '{':
depth += 1
elif ch == '}':
depth -= 1
i += 1
return i
def without_motion(prelude):
if ',' in prelude or re.search(r'\b(or|not|only)\b', prelude):
raise ValueError(f'@media{prelude}')
parts = [part.strip() for part in re.split(r'\band\b', prelude)]
wanted = [MOTION.fullmatch(part).group(1) for part in parts if MOTION.fullmatch(part)]
if len(wanted) != 1 or any('prefers-reduced-motion' in part and not MOTION.fullmatch(part) for part in parts):
raise ValueError(f'@media{prelude}')
return wanted[0], ' and '.join(part for part in parts if not MOTION.fullmatch(part))
def everyone_moves(css):
out, i = [], 0
while match := MEDIA.search(css, i):
out.append(css[i:match.start()])
end = block_end(css, match.end())
wanted, rest = without_motion(match.group(1))
if wanted == 'no-preference':
inner = everyone_moves(css[match.end():end - 1])
if not rest and inner.strip() and not inner.rstrip().endswith((';', '}')):
inner += ';'
out.append(f'@media {rest}{{{inner}}}' if rest else inner)
i = end
out.append(css[i:])
return ''.join(out)
failed = False
for name in sys.argv[1:]:
path = Path(name)
before = path.read_text(encoding='utf-8')
try:
after = everyone_moves(before)
except ValueError as query:
print(f'{name}: cannot rewrite {query}', file=sys.stderr)
failed = True
continue
if 'prefers-reduced-motion' in after:
print(f'{name}: a prefers-reduced-motion query is still there', file=sys.stderr)
failed = True
continue
if after != before:
path.write_text(after, encoding='utf-8')
print(f'{name}: motion now plays for everyone')
sys.exit(1 if failed else 0)
+5 -2
View File
@@ -74,7 +74,10 @@ prepare() {
cd "$ROOT" cd "$ROOT"
case "${1:-build}" in case "${1:-build}" in
prepare) prepare ;; prepare) prepare ;;
build) prepare; "$TAILWIND" -i Styles/app.css -o wwwroot/css/app.css --optimize ;; build) prepare; "$TAILWIND" -i Styles/app.css -o wwwroot/css/app.css --optimize; python3 -I tools/css/motion.py wwwroot/css/app.css ;;
watch) prepare; exec "$TAILWIND" -i Styles/app.css -o wwwroot/css/app.css --watch ;; watch) prepare; "$TAILWIND" -i Styles/app.css -o wwwroot/css/app.css --watch 2>&1 | while IFS= read -r line; do
echo "$line"
case "$line" in Done*) python3 -I tools/css/motion.py wwwroot/css/app.css ;; esac
done ;;
*) echo "usage: tailwind.sh [prepare|build|watch]" >&2; exit 2 ;; *) echo "usage: tailwind.sh [prepare|build|watch]" >&2; exit 2 ;;
esac esac
+1 -1
View File
@@ -46,7 +46,7 @@ export function scrollToOffset(rootId, top) {
} }
export function scrollToTop(rootId) { export function scrollToTop(rootId) {
document.getElementById(rootId)?.scrollTo({ top: 0, behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }) document.getElementById(rootId)?.scrollTo({ top: 0, behavior: "smooth" })
} }
export function dispose() { export function dispose() {
+11 -16
View File
@@ -9,7 +9,7 @@
// night map (city lights) where it is down, blended across a twilight band, and brighter or dimmer with the Earth–Sun // night map (city lights) where it is down, blended across a twilight band, and brighter or dimmer with the Earth–Sun
// distance. It shows now, or, while a post is hovered or focused, the moment that post was written; the Sun moves there // distance. It shows now, or, while a post is hovered or focused, the moment that post was written; the Sun moves there
// and back smoothly. Land borders are thin lines, and country names sit inside each country in the reader's language, // and back smoothly. Land borders are thin lines, and country names sit inside each country in the reader's language,
// more of them and larger as the camera comes closer. Motion follows prefers-reduced-motion. It renders only while // more of them and larger as the camera comes closer. It renders only while
// something moves (a camera flight, the Sun moving, a hovered post's pulse, auto-rotation, the reader dragging or // something moves (a camera flight, the Sun moving, a hovered post's pulse, auto-rotation, the reader dragging or
// zooming) and never while the page or the globe is hidden: an idle globe costs no GPU time. No .NET round-trip on // zooming) and never while the page or the globe is hidden: an idle globe costs no GPU time. No .NET round-trip on
// scroll or hover: the posts' places come in one call per feed change (setPosts), the cards are matched by // scroll or hover: the posts' places come in one call per feed change (setPosts), the cards are matched by
@@ -21,7 +21,6 @@ import { createInstruments } from "./instruments.js"
const vendor = new URL("../vendor/globe/", import.meta.url).href const vendor = new URL("../vendor/globe/", import.meta.url).href
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" } const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
const countriesUrl = vendor + "countries.json" const countriesUrl = vendor + "countries.json"
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")
// a touch screen: a tap on a card opens its thread there (main.js), so cards are pinned with a mouse only // a touch screen: a tap on a card opens its thread there (main.js), so cards are pinned with a mouse only
const canHover = matchMedia("(hover: hover)") const canHover = matchMedia("(hover: hover)")
@@ -218,7 +217,7 @@ function resize() {
if (state.view) if (state.view)
scheduleView() scheduleView()
else else
state.globe.pointOfView({ ...state.globe.pointOfView(), altitude: 2.6 * shape }, reducedMotion.matches ? 0 : 600) state.globe.pointOfView({ ...state.globe.pointOfView(), altitude: 2.6 * shape }, 600)
} }
state.shape = shape state.shape = shape
} }
@@ -243,7 +242,7 @@ async function build(width, height) {
.pointRadius(p => 0.25 + Math.min(Math.sqrt(p.count), 6) * 0.12) .pointRadius(p => 0.25 + Math.min(Math.sqrt(p.count), 6) * 0.12)
.pointColor(p => colour(p.src)) .pointColor(p => colour(p.src))
.pointLabel(p => label(p)) .pointLabel(p => label(p))
.pointsTransitionDuration(reducedMotion.matches ? 0 : 600) .pointsTransitionDuration(600)
.ringColor(r => t => r.colour.replace(/[\d.]+\)$/, `${(1 - t).toFixed(2)})`)) .ringColor(r => t => r.colour.replace(/[\d.]+\)$/, `${(1 - t).toFixed(2)})`))
.ringMaxRadius(r => r.radius) .ringMaxRadius(r => r.radius)
.ringPropagationSpeed(r => r.speed) .ringPropagationSpeed(r => r.speed)
@@ -254,7 +253,7 @@ async function build(width, height) {
.arcDashLength(0.45) .arcDashLength(0.45)
.arcDashGap(0.25) .arcDashGap(0.25)
.arcDashInitialGap(1) .arcDashInitialGap(1)
.arcDashAnimateTime(reducedMotion.matches ? 0 : 1600) .arcDashAnimateTime(1600)
.htmlLat(d => d.lat) .htmlLat(d => d.lat)
.htmlLng(d => d.lng) .htmlLng(d => d.lng)
.htmlAltitude(d => d.kind === "country" ? 0.004 : 0.12) .htmlAltitude(d => d.kind === "country" ? 0.004 : 0.12)
@@ -419,13 +418,10 @@ function loadNight() {
image.src = textures.night image.src = textures.night
} }
// The Sun moves to a new moment: its subsolar point travels there over about a second (the shorter way round), or // The Sun moves to a new moment: its subsolar point travels there over about a second (the shorter way round).
// jumps with reduced motion.
function aimSun(sun) { function aimSun(sun) {
state.sun = sun state.sun = sun
state.sunUniforms.sunIntensity.value = sun.irradiance state.sunUniforms.sunIntensity.value = sun.irradiance
if (reducedMotion.matches)
state.sunPoint = { lat: sun.lat, lng: sun.lng }
state.sunUniforms.sunDirection.value = directionOf(state.sunPoint) state.sunUniforms.sunDirection.value = directionOf(state.sunPoint)
state.instruments?.setMoment(sun, state.focus) state.instruments?.setMoment(sun, state.focus)
state.instruments?.setSun(state.sunPoint) state.instruments?.setSun(state.sunPoint)
@@ -659,7 +655,6 @@ function watchTheme() {
function watchVisibility() { function watchVisibility() {
listen(document, "visibilitychange", () => pause(document.visibilityState !== "visible" || !hasSize())) listen(document, "visibilitychange", () => pause(document.visibilityState !== "visible" || !hasSize()))
listen(reducedMotion, "change", () => scheduleView())
} }
function listen(target, type, listener) { function listen(target, type, listener) {
@@ -832,13 +827,13 @@ function drawRings() {
const rings = [] const rings = []
const hovered = state.hovered && state.points.get(state.hovered) const hovered = state.hovered && state.points.get(state.hovered)
if (placed(hovered)) if (placed(hovered))
rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: reducedMotion.matches ? 100000 : 900, colour: colour(hovered.src, 1) }) rings.push({ lat: hovered.lat, lng: hovered.lng, radius: 4, speed: 3, period: 900, colour: colour(hovered.src, 1) })
else if (hovered && Number.isFinite(hovered.beforeLat)) else if (hovered && Number.isFinite(hovered.beforeLat))
rings.push({ lat: hovered.beforeLat, lng: hovered.beforeLng, radius: 3, speed: 1.5, period: reducedMotion.matches ? 100000 : 1400, colour: colour("country", 1) }) rings.push({ lat: hovered.beforeLat, lng: hovered.beforeLng, radius: 3, speed: 1.5, period: 1400, colour: colour("country", 1) })
const reach = Math.max(0, ...[...state.points.values()].filter(p => p.src === "nearyou").map(p => p.range ?? 0)) const reach = Math.max(0, ...[...state.points.values()].filter(p => p.src === "nearyou").map(p => p.range ?? 0))
const reader = [...state.points.values()].find(p => p.src === "nearyou") const reader = [...state.points.values()].find(p => p.src === "nearyou")
if (reader && reach > 0) if (reader && reach > 0)
rings.push({ lat: reader.lat, lng: reader.lng, radius: reach / 111, speed: reach / 111 / 2, period: reducedMotion.matches ? 100000 : 2400, colour: colour("nearyou", 0.8) }) rings.push({ lat: reader.lat, lng: reader.lng, radius: reach / 111, speed: reach / 111 / 2, period: 2400, colour: colour("nearyou", 0.8) })
state.globe.ringsData(rings) state.globe.ringsData(rings)
} }
@@ -894,7 +889,7 @@ function updateView() {
state.view = null state.view = null
state.spreadKm = 0 state.spreadKm = 0
state.instruments?.setView(null, 0, [], { located: 0, visible: state.visible.size }) state.instruments?.setView(null, 0, [], { located: 0, visible: state.visible.size })
controls.autoRotate = !reducedMotion.matches controls.autoRotate = true
wake() wake()
return return
} }
@@ -921,8 +916,8 @@ function updateView() {
function fly(view, ms) { function fly(view, ms) {
if (!view || !state?.globe) if (!view || !state?.globe)
return return
wake((reducedMotion.matches ? 0 : ms) + 400) wake(ms + 400)
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms) state.globe.pointOfView(view, ms)
} }
// A column taller than wide shows less of the globe across: the camera moves back to keep it whole. // A column taller than wide shows less of the globe across: the camera moves back to keep it whole.
+4 -10
View File
@@ -108,6 +108,10 @@ document.addEventListener("click", event => {
window.logFromJs(e.message, "openThread") window.logFromJs(e.message, "openThread")
} }
}) })
document.addEventListener("loadstart", event => {
if (event.target instanceof HTMLVideoElement && event.target.hasAttribute("muted"))
event.target.muted = true
}, { capture: true })
// The Back button (a popstate) as opposed to the app's own navigation: Blazor's location-changing handlers can't tell // The Back button (a popstate) as opposed to the app's own navigation: Blazor's location-changing handlers can't tell
// them apart, and MainLayout lets Back close an open overlay but lets the app's navigation through. Registered for the // them apart, and MainLayout lets Back close an open overlay but lets the app's navigation through. Registered for the
// capture phase, so it runs before Blazor's own listener. // capture phase, so it runs before Blazor's own listener.
@@ -136,16 +140,6 @@ window.revealActive = strip => {
window.logFromJs(e.message, "revealActive") window.logFromJs(e.message, "revealActive")
} }
} }
// the reader asked for less motion: what would move by itself (a gifv) waits for a tap
window.prefersReducedMotion = () => {
try {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
} catch (e) {
console.error(e)
window.logFromJs(e.message, "prefersReducedMotion")
return false
}
}
// text put on the clipboard (the recovery codes of two-factor sign-in); false when the browser refused it // text put on the clipboard (the recovery codes of two-factor sign-in); false when the browser refused it
window.copyText = async text => { window.copyText = async text => {
try { try {