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:
1 parent
eb4ea2947c
commit
697b2c8897
11 files changed
+113
-55
No files matched your search
@@ -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
|
||||
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`.
|
||||
- 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`.
|
||||
- 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
|
||||
@@ -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
|
||||
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"
|
||||
(`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
|
||||
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
|
||||
@@ -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
|
||||
(`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
|
||||
character; it closes the picker first. A GIF shows its still picture under `prefers-reduced-motion`. The card's React
|
||||
button (`data-react`) and the announcements banner's (`data-announcement-react`) react with it, after their quick
|
||||
reactions; the composer's emoji button writes it where the caret is, replacing a selection, else at the end, a
|
||||
shortcode with a space on each side where a letter would touch it (`AppStatusService.CaretOf` / `PlaceCaret`,
|
||||
`main.js`'s `caretOf` and `placeCaret`).
|
||||
character; it closes the picker first. An animated emoji moves in the picker for every reader (its `url`, never its
|
||||
`static_url`). The card's React button (`data-react`) and the announcements banner's (`data-announcement-react`)
|
||||
react with it, after their quick reactions; the composer's emoji button writes it where the caret is, replacing a
|
||||
selection, else at the end, a shortcode with a space on each side where a letter would touch it
|
||||
(`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
|
||||
(shortcodes starting with it first, eight at most) among the mention and hashtag suggestions, as
|
||||
`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
|
||||
`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
|
||||
arc to a hovered post, honours `prefers-reduced-motion`, and draws only while something moves (idle, it costs no GPU
|
||||
time; `wake()` before anything that animates).
|
||||
arc to a hovered post, moves the same way for every reader (camera flights, the Sun, pulses, arcs, auto-rotation),
|
||||
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
|
||||
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).
|
||||
@@ -978,6 +984,9 @@ The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noin
|
||||
|
||||
## 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.
|
||||
- `Nullable` is disabled and `ImplicitUsings` is enabled.
|
||||
- Global Razor usings are in `_Imports.razor`.
|
||||
|
||||
@@ -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]")"
|
||||
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" @onerror="() => unavailableMedia.Add(media.Id)" />
|
||||
</button>,
|
||||
// under reduced motion a gifv waits for a tap (its controls)
|
||||
"gifv" => @<video class="w-full rounded-lg" autoplay="@(!CascadingState.Status.PrefersReducedMotion())" controls="@CascadingState.Status.PrefersReducedMotion()" muted loop
|
||||
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop
|
||||
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"
|
||||
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
OnClick="@(() => Pick($":{emoji.Shortcode}:"))" title="@($":{emoji.Shortcode}:")" aria-label="@($":{emoji.Shortcode}:")"
|
||||
data-emoji-choice="@emoji.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>
|
||||
}
|
||||
</div>
|
||||
@@ -143,9 +143,6 @@
|
||||
.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)
|
||||
{
|
||||
isOpen = open;
|
||||
|
||||
@@ -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
|
||||
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
|
||||
public async ValueTask<bool> CopyText(string text, CancellationToken cancellationToken = default) =>
|
||||
await jsRuntime.InvokeAsync<bool>("copyText", cancellationToken, text);
|
||||
|
||||
@@ -282,12 +282,6 @@
|
||||
stroke-dasharray: 2 6;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.globe-ring-value {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.globe-legend {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
|
||||
@@ -46,9 +46,10 @@
|
||||
|
||||
<Target Name="TailwindCompile" DependsOnTargets="EnsureTailwindTools"
|
||||
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">
|
||||
<Exec Command=""$(TailwindExe)" -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. -->
|
||||
<Touch Files="wwwroot/css/app.css;$(IntermediateOutputPath)tailwind.stamp" AlwaysCreate="true" />
|
||||
</Target>
|
||||
|
||||
@@ -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
@@ -74,7 +74,10 @@ prepare() {
|
||||
cd "$ROOT"
|
||||
case "${1:-build}" in
|
||||
prepare) prepare ;;
|
||||
build) prepare; "$TAILWIND" -i Styles/app.css -o wwwroot/css/app.css --optimize ;;
|
||||
watch) prepare; exec "$TAILWIND" -i Styles/app.css -o wwwroot/css/app.css --watch ;;
|
||||
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; "$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 ;;
|
||||
esac
|
||||
+1
-1
@@ -46,7 +46,7 @@ export function scrollToOffset(rootId, top) {
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
+11
-16
@@ -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
|
||||
// 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,
|
||||
// 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
|
||||
// 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
|
||||
@@ -21,7 +21,6 @@ import { createInstruments } from "./instruments.js"
|
||||
const vendor = new URL("../vendor/globe/", import.meta.url).href
|
||||
const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" }
|
||||
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
|
||||
const canHover = matchMedia("(hover: hover)")
|
||||
|
||||
@@ -218,7 +217,7 @@ function resize() {
|
||||
if (state.view)
|
||||
scheduleView()
|
||||
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
|
||||
}
|
||||
@@ -243,7 +242,7 @@ async function build(width, height) {
|
||||
.pointRadius(p => 0.25 + Math.min(Math.sqrt(p.count), 6) * 0.12)
|
||||
.pointColor(p => colour(p.src))
|
||||
.pointLabel(p => label(p))
|
||||
.pointsTransitionDuration(reducedMotion.matches ? 0 : 600)
|
||||
.pointsTransitionDuration(600)
|
||||
.ringColor(r => t => r.colour.replace(/[\d.]+\)$/, `${(1 - t).toFixed(2)})`))
|
||||
.ringMaxRadius(r => r.radius)
|
||||
.ringPropagationSpeed(r => r.speed)
|
||||
@@ -254,7 +253,7 @@ async function build(width, height) {
|
||||
.arcDashLength(0.45)
|
||||
.arcDashGap(0.25)
|
||||
.arcDashInitialGap(1)
|
||||
.arcDashAnimateTime(reducedMotion.matches ? 0 : 1600)
|
||||
.arcDashAnimateTime(1600)
|
||||
.htmlLat(d => d.lat)
|
||||
.htmlLng(d => d.lng)
|
||||
.htmlAltitude(d => d.kind === "country" ? 0.004 : 0.12)
|
||||
@@ -419,13 +418,10 @@ function loadNight() {
|
||||
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
|
||||
// jumps with reduced motion.
|
||||
// The Sun moves to a new moment: its subsolar point travels there over about a second (the shorter way round).
|
||||
function aimSun(sun) {
|
||||
state.sun = sun
|
||||
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.instruments?.setMoment(sun, state.focus)
|
||||
state.instruments?.setSun(state.sunPoint)
|
||||
@@ -659,7 +655,6 @@ function watchTheme() {
|
||||
|
||||
function watchVisibility() {
|
||||
listen(document, "visibilitychange", () => pause(document.visibilityState !== "visible" || !hasSize()))
|
||||
listen(reducedMotion, "change", () => scheduleView())
|
||||
}
|
||||
|
||||
function listen(target, type, listener) {
|
||||
@@ -832,13 +827,13 @@ function drawRings() {
|
||||
const rings = []
|
||||
const hovered = state.hovered && state.points.get(state.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))
|
||||
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 reader = [...state.points.values()].find(p => p.src === "nearyou")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -894,7 +889,7 @@ function updateView() {
|
||||
state.view = null
|
||||
state.spreadKm = 0
|
||||
state.instruments?.setView(null, 0, [], { located: 0, visible: state.visible.size })
|
||||
controls.autoRotate = !reducedMotion.matches
|
||||
controls.autoRotate = true
|
||||
wake()
|
||||
return
|
||||
}
|
||||
@@ -921,8 +916,8 @@ function updateView() {
|
||||
function fly(view, ms) {
|
||||
if (!view || !state?.globe)
|
||||
return
|
||||
wake((reducedMotion.matches ? 0 : ms) + 400)
|
||||
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms)
|
||||
wake(ms + 400)
|
||||
state.globe.pointOfView(view, ms)
|
||||
}
|
||||
|
||||
// A column taller than wide shows less of the globe across: the camera moves back to keep it whole.
|
||||
|
||||
+4
-10
@@ -108,6 +108,10 @@ document.addEventListener("click", event => {
|
||||
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
|
||||
// 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.
|
||||
@@ -136,16 +140,6 @@ window.revealActive = strip => {
|
||||
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
|
||||
window.copyText = async text => {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user