feat: animate more admin icons + add puppeteer install option + various fixes

scarlett
NGPixel 1 month ago
parent c00b7007eb
commit 12ec007bb8
No known key found for this signature in database

@ -509,7 +509,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Install or reinstall an extension', summary: 'Install or reinstall an extension',
description: description:
'Only extensions flagged `isInstallable` can be installed from here — currently Sharp, which is an npm package. It already ships as an optional dependency, so this is mostly a repair: it refetches the package and the prebuilt binary for this OS and architecture, which is what to reach for when the native binary is missing or does not match the platform. Git and Pandoc come from the operating system and answer 409 pointing at the documentation. Runs npm and can take minutes.', 'Only extensions flagged `isInstallable` can be installed from here — the npm packages, which are Sharp and Puppeteer. For Sharp this is mostly a repair: it already ships as an optional dependency, and refetching it replaces a prebuilt binary that is missing or does not match this OS and architecture. Puppeteer is not shipped at all, so this is a first install, and it fetches a Chromium build of a few hundred megabytes unless the server points at one it already has through `PUPPETEER_EXECUTABLE_PATH`. Git and Pandoc come from the operating system and answer 409 pointing at the documentation. Runs npm and can take minutes — allow the request a correspondingly long timeout.',
tags: ['System'], tags: ['System'],
params: { params: {
type: 'object', type: 'object',

@ -7,8 +7,14 @@ import { promisify } from 'node:util'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
/** How long an install may run before it is given up on — fetching a native binary is not instant. */ /**
const installTimeout = 5 * 60 * 1000 * How long an install may run before it is given up on.
*
* Generous because of what the slowest one has to do: Puppeteer fetches a Chromium build of a few
* hundred megabytes, which on a thin connection is minutes of transfer before npm has anything to
* unpack. A ceiling rather than a wait Sharp still finishes in seconds.
*/
const installTimeout = 20 * 60 * 1000
/** How much npm output is kept when reporting a failure, taken from the end where the error is. */ /** How much npm output is kept when reporting a failure, taken from the end where the error is. */
const installErrorLength = 800 const installErrorLength = 800
@ -33,6 +39,15 @@ export interface ExtensionDefinition {
platforms?: string[] platforms?: string[]
/** Whether the admin area can install it, as opposed to it being installed by hand. */ /** Whether the admin area can install it, as opposed to it being installed by hand. */
isInstallable: boolean isInstallable: boolean
/**
* The version `install()` asks npm for.
*
* For an extension that is not declared in `package.json` at all, which is the only place a version
* would otherwise be written down without it npm resolves whatever is newest today, and two
* instances installed a month apart are running different software. An extension the manifest
* already declares leaves this out, since a second pin here could only disagree with the first.
*/
installVersion?: string
} }
/** An extension plus its state on this system, as exposed by the API. */ /** An extension plus its state on this system, as exposed by the API. */
@ -98,10 +113,11 @@ async function moduleExists(specifier: string): Promise<boolean> {
* Puppeteer. Each lives in `modules/extensions/<key>/definition.yml`, which declares how to detect it, * Puppeteer. Each lives in `modules/extensions/<key>/definition.yml`, which declares how to detect it,
* what it is compatible with, and whether it can be installed from here. * what it is compatible with, and whether it can be installed from here.
* *
* Most cannot: a Git or Pandoc binary comes from the system package manager, and the admin area links * The `command` ones cannot be installed from here: a Git or Pandoc binary comes from the system
* out to the instructions. An extension detected as a `module` is an npm package, and `install()` can * package manager, and the admin area links out to the instructions instead. An extension detected as
* (re)install that with npm which for Sharp, shipped as an optional dependency, is how a native * a `module` is an npm package, which `install()` can fetch Sharp to replace a native binary that
* binary that is missing or does not match the platform gets replaced. * is missing or does not match the platform, Puppeteer because it is deliberately not shipped and has
* to come from somewhere.
*/ */
class Extensions { class Extensions {
/** Definitions read from disk, refreshed by `refreshFromDisk()`. */ /** Definitions read from disk, refreshed by `refreshFromDisk()`. */
@ -201,16 +217,34 @@ class Extensions {
* `isInstallable` and `isCompatible` first; this repeats the detection check afterwards, since npm * `isInstallable` and `isCompatible` first; this repeats the detection check afterwards, since npm
* exiting zero and the module actually being there are not the same claim. * exiting zero and the module actually being there are not the same claim.
* *
* Reinstalling is the point as much as installing is. Sharp is a declared optional dependency, so an * The two installable extensions ask for different things, and the flags below serve both.
* ordinary install already has it what goes wrong is its *native* binary: an image built on one *
* platform and run on another, or an install that skipped optional dependencies, leaves the JavaScript * Sharp is a declared optional dependency, so an ordinary install already has it reinstalling is
* package in place and the binary for this OS and architecture missing. Hence the flags: * the point as much as installing is. What goes wrong is its *native* binary: an image built on one
* platform and run on another, or an install that skipped optional dependencies, leaves the
* JavaScript package in place and the binary for this OS and architecture missing.
*
* Puppeteer is not declared anywhere, so this is a genuine first install, and the bulk of it is the
* browser. Nothing has to be arranged for that: Puppeteer's own postinstall fetches one into its
* cache, which is the ordinary case and the one an install straight onto Linux takes. A server that
* already has a browser opts out with `PUPPETEER_SKIP_DOWNLOAD` and points at it with
* `PUPPETEER_EXECUTABLE_PATH` what the Docker image does with the Chromium it takes from the
* distro. Neither is required, and neither is set here: npm inherits this process's environment, so
* an install from the admin area sees exactly what the operator set for the server and nothing else.
*
* Hence the flags:
* *
* - `--no-save` because the manifest already declares the package, and an HTTP request has no * - `--no-save` because the manifest already declares the package, and an HTTP request has no
* business rewriting the manifests the release was built from. * business rewriting the manifests the release was built from.
* - `--force` so npm refetches rather than deciding an already-present but unusable copy is fine. * - `--force` so npm refetches rather than deciding an already-present but unusable copy is fine.
* - `--include=optional` because the per-platform binaries are themselves optional dependencies of * - `--include=optional` because the per-platform binaries are themselves optional dependencies of
* the package, and omitting them is the usual cause of the failure being repaired here. * the package, and omitting them is the usual cause of the failure being repaired here.
* - `--no-ignore-scripts` because the browser IS Puppeteer's postinstall. An operator who has set
* `ignore-scripts` a reasonable thing to harden an npm config with would otherwise get the
* package with no browser under it, npm exiting zero, and this model reporting it as installed:
* the failure would surface much later, as a render that cannot start a browser. Which scripts
* are trusted is still decided by the `allowScripts` policy in `package.json`, and a package
* denied there is skipped whatever this flag says.
* *
* @throws If the extension cannot be installed this way, if npm fails, or if the module is still * @throws If the extension cannot be installed this way, if npm fails, or if the module is still
* missing afterwards * missing afterwards
@ -220,8 +254,13 @@ class Extensions {
throw new Error(`${definition.title} is not an npm package, so it cannot be installed here.`) throw new Error(`${definition.title} is not an npm package, so it cannot be installed here.`)
} }
const specifier = definition.detect.value const specifier = definition.detect.value
// -> What npm is asked for, which carries the pin; what is checked for afterwards is the package
// name on its own, since that is what lands in `node_modules`
const request = definition.installVersion
? `${specifier}@${definition.installVersion}`
: specifier
WIKI.logger.info(`Installing extension ${definition.key} (npm package ${specifier})...`) WIKI.logger.info(`Installing extension ${definition.key} (npm package ${request})...`)
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
process.platform === 'win32' ? 'npm.cmd' : 'npm', process.platform === 'win32' ? 'npm.cmd' : 'npm',
@ -230,9 +269,10 @@ class Extensions {
'--no-save', '--no-save',
'--force', '--force',
'--include=optional', '--include=optional',
'--no-ignore-scripts',
'--no-audit', '--no-audit',
'--no-fund', '--no-fund',
specifier request
], ],
{ {
cwd: WIKI.SERVERPATH, cwd: WIKI.SERVERPATH,
@ -250,7 +290,7 @@ class Extensions {
WIKI.logger.warn(`Failed to install extension ${definition.key}:`) WIKI.logger.warn(`Failed to install extension ${definition.key}:`)
WIKI.logger.warn(detail || err) WIKI.logger.warn(detail || err)
throw new Error( throw new Error(
`npm could not install ${specifier}: ${detail.slice(-installErrorLength) || 'no output'}` `npm could not install ${request}: ${detail.slice(-installErrorLength) || 'no output'}`
) )
} }

@ -2,7 +2,8 @@ key: puppeteer
title: Puppeteer title: Puppeteer
description: >- description: >-
Headless Chromium browser. Required to export pages as PDF and to render content elements on the Headless Chromium browser. Required to export pages as PDF and to render content elements on the
server, such as Mermaid or PlantUML diagrams. server, such as Mermaid or PlantUML diagrams. Installing it downloads a Chromium build of a few
hundred megabytes, unless the server already provides one through PUPPETEER_EXECUTABLE_PATH.
website: 'https://pptr.dev' website: 'https://pptr.dev'
detect: detect:
type: module type: module
@ -10,4 +11,10 @@ detect:
architectures: architectures:
- x64 - x64
- arm64 - arm64
isInstallable: false # Not a declared dependency of the backend, unlike Sharp: a browser is worth a few hundred megabytes
# only to an instance that renders pages on the server, so installing it is a decision rather than a
# default. That makes this a real first install and not merely a repair.
isInstallable: true
# The version installed, here rather than in `package.json` for the reason above. The Docker image
# reads this same line, so an image and a hand-installed instance agree on what Puppeteer is.
installVersion: 25.4.0

@ -112,6 +112,7 @@
"dev": true, "dev": true,
"releaseDate": "2026-01-01T01:01:01.000Z", "releaseDate": "2026-01-01T01:01:01.000Z",
"allowScripts": { "allowScripts": {
"esbuild": false "esbuild": false,
"puppeteer": true
} }
} }

@ -41,9 +41,15 @@ RUN npm ci --omit=dev
# The Puppeteer extension, which server-side page rendering needs. Added here rather than declared in # The Puppeteer extension, which server-side page rendering needs. Added here rather than declared in
# `backend/package.json` because it is an optional extension: an installation that renders its pages in # `backend/package.json` because it is an optional extension: an installation that renders its pages in
# the editor -- which is all of them, on any normal save -- has no use for a browser on the server, and # the editor -- which is all of them, on any normal save -- has no use for a browser on the server, and
# a source checkout should not have to fetch one to install the backend. Pinned like every other # a source checkout should not have to fetch one to install the backend.
# dependency, so an image build is reproducible. #
RUN npm install --no-save puppeteer@25.4.0 # The version is read from the extension definition, which is also what the admin area installs when an
# operator adds Puppeteer to an instance by hand: one place to bump, and an image that cannot drift from
# what a hand-installed instance gets. An empty read fails the build rather than quietly installing
# whatever is newest.
RUN PUPPETEER_VERSION="$(sed -n 's/^installVersion: *//p' modules/extensions/puppeteer/definition.yml)" && \
test -n "$PUPPETEER_VERSION" && \
npm install --no-save "puppeteer@${PUPPETEER_VERSION}"
WORKDIR /wiki WORKDIR /wiki

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px">
<style>
/* Same bob on every plate; the negative delays put the top plate ahead in phase,
so the crest leads there and ripples down through the stack. */
.layer { animation: float 3s ease-in-out infinite }
.layer-1 { animation-delay: 0s }
.layer-2 { animation-delay: -.4s }
.layer-3 { animation-delay: -.8s }
@keyframes float {
0%, 100% { transform: translateY(-1.4px) }
50% { transform: translateY(1.4px) }
}
@media (prefers-reduced-motion: reduce) {
.layer { animation: none }
}
</style>
<linearGradient id="ldLw80Wb5w9tTRcKjgX8Ga" x1="2.252" x2="34.131" y1="12.996" y2="42.423" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0077d2"/><stop offset="1" stop-color="#0b59a2"/></linearGradient><g class="layer layer-1"><path fill="url(#ldLw80Wb5w9tTRcKjgX8Ga)" d="M23.008,22.387L6.256,31.181c-1.523,0.8-1.523,2.98,0,3.779l16.752,8.795 c0.621,0.326,1.363,0.326,1.984,0l16.752-8.795c1.523-0.8,1.523-2.98,0-3.779l-16.752-8.795 C24.371,22.06,23.629,22.06,23.008,22.387z"/><path d="M25.457,35.569L37.78,29.1l-12.787-6.713c-0.621-0.326-1.363-0.326-1.984,0L10.22,29.1l12.322,6.469 c0.447,0.235,0.952,0.36,1.458,0.36S25.011,35.805,25.457,35.569z" opacity=".05"/><path d="M25.225,35.127l12.017-6.309l-12.25-6.431c-0.621-0.326-1.363-0.326-1.984,0l-12.25,6.431 l12.017,6.309c0.376,0.198,0.8,0.303,1.225,0.303S24.849,35.325,25.225,35.127z" opacity=".07"/></g><linearGradient id="ldLw80Wb5w9tTRcKjgX8Gb" x1="6.773" x2="38.652" y1="8.098" y2="37.525" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2aa4f4"/><stop offset="1" stop-color="#007ad9"/></linearGradient><g class="layer layer-2"><path fill="url(#ldLw80Wb5w9tTRcKjgX8Gb)" d="M23.008,13.316L6.256,22.11c-1.523,0.8-1.523,2.98,0,3.779l16.752,8.795 c0.621,0.326,1.363,0.326,1.984,0l16.752-8.795c1.523-0.8,1.523-2.98,0-3.779l-16.752-8.795 C24.371,12.989,23.629,12.989,23.008,13.316z"/><path d="M25.457,26.498l12.322-6.469l-12.787-6.713c-0.621-0.326-1.363-0.326-1.984,0l-12.787,6.713 l12.321,6.469c0.447,0.235,0.952,0.36,1.458,0.36S25.011,26.733,25.457,26.498z" opacity=".05"/><path d="M25.225,26.056l12.017-6.309l-12.25-6.431c-0.621-0.326-1.363-0.326-1.984,0l-12.25,6.431 l12.017,6.309c0.376,0.198,0.8,0.303,1.225,0.303S24.849,26.254,25.225,26.056z" opacity=".07"/></g><linearGradient id="ldLw80Wb5w9tTRcKjgX8Gc" x1="11.294" x2="43.173" y1="3.201" y2="32.627" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#75daff"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><g class="layer layer-3"><path fill="url(#ldLw80Wb5w9tTRcKjgX8Gc)" d="M23.008,4.245L6.256,13.039c-1.523,0.8-1.523,2.98,0,3.779l16.752,8.795 c0.621,0.326,1.363,0.326,1.984,0l16.752-8.795c1.523-0.8,1.523-2.98,0-3.779L24.992,4.245C24.371,3.918,23.629,3.918,23.008,4.245z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px">
<style>
.key { transform-box: view-box; animation: swing 2.4s ease-in-out infinite }
/* Each key turns on its own hole, so the ring stays threaded through it. The front
hole is drawn at 27,18; the back key hangs where the ring band passes behind it. */
.key-front { transform-origin: 27px 18px; --swing: 5deg }
.key-back { transform-origin: 21.5px 18.9px; --swing: 8deg; animation-delay: -.3s }
@keyframes swing {
0%, 100% { rotate: calc(-1 * var(--swing)) }
50% { rotate: var(--swing) }
}
@media (prefers-reduced-motion: reduce) {
.key { animation: none }
}
</style>
<g class="key key-back"><path fill="#e3a600" d="M20.834,29.595l-6.808,13.958c-0.136,0.279-0.33,0.518-0.579,0.708l-0.91,0.702 c-0.302,0.231-0.708,0.266-1.05,0.1l-2.481-1.21c-0.342-0.167-0.564-0.509-0.568-0.889l-0.007-1.149 c-0.003-0.313,0.065-0.613,0.201-0.892l0.671-1.375c0.609,0.074,1.271-0.326,1.556-0.91s0.192-1.353-0.241-1.787l0.877-1.798 c0.609,0.074,1.271-0.326,1.556-0.91s0.192-1.353-0.241-1.787l2.63-5.393L20.834,29.595z"/><path fill="#edbe00" d="M25.59,13c-4.967-2.423-10.949-0.363-13.372,4.604s-0.363,10.949,4.604,13.372 s10.949,0.363,13.372-4.604S30.557,15.423,25.59,13z"/><path fill="#edbe00" d="M25.59,13c-0.77-0.37-1.56-0.64-2.36-0.8c-0.31-0.07-0.62-0.12-0.93-0.15 c-2.337-0.258-4.68,0.321-6.613,1.595l-1.123-0.323l-0.461,1.62c-0.75,0.756-1.39,1.646-1.883,2.657 c-2.42,4.97-0.36,10.95,4.6,13.38c1.75,0.85,3.63,1.15,5.43,0.95c0.32-0.03,0.64-0.08,0.96-0.14c2.93-0.6,5.57-2.51,6.98-5.42 C32.62,21.4,30.56,15.42,25.59,13z"/><path d="M25.59,13c-0.77-0.37-1.56-0.64-2.36-0.8c-0.31-0.07-0.62-0.12-0.93-0.15 C18.58,13.82,16,17.61,16,22c0,4.37,2.56,8.15,6.25,9.93c0.32-0.03,0.64-0.08,0.96-0.14c2.93-0.6,5.57-2.51,6.98-5.42 C32.62,21.4,30.56,15.42,25.59,13z M27,20c-1.1,0-2-0.9-2-2s0.9-2,2-2s2,0.9,2,2S28.1,20,27,20z" opacity=".05"/><path d="M25.59,13c-0.77-0.37-1.56-0.64-2.36-0.8c-3.93,1.52-6.73,5.34-6.73,9.8 c0,4.45,2.79,8.27,6.71,9.79c2.93-0.6,5.57-2.51,6.98-5.42C32.62,21.4,30.56,15.42,25.59,13z M27,20.5c-1.38,0-2.5-1.12-2.5-2.5 s1.12-2.5,2.5-2.5s2.5,1.12,2.5,2.5S28.38,20.5,27,20.5z" opacity=".07"/></g><g class="key key-front"><linearGradient id="bN0gXF350I4SeGvD0XhmSa" x1="27" x2="27" y1="18.098" y2="2.759" gradientTransform="matrix(1 0 0 -1 0 50)" gradientUnits="userSpaceOnUse"><stop offset=".014" stop-color="#e5a505"/><stop offset=".063" stop-color="#e9a804"/><stop offset=".128" stop-color="#f4b102"/><stop offset=".169" stop-color="#fbb600"/><stop offset=".323" stop-color="#fdb700"/></linearGradient><path fill="url(#bN0gXF350I4SeGvD0XhmSa)" d="M30,29v15.53c0,0.31-0.07,0.61-0.21,0.89l-0.51,1.03c-0.17,0.34-0.52,0.55-0.9,0.55h-2.76 c-0.38,0-0.73-0.21-0.9-0.55l-0.51-1.03C24.07,45.14,24,44.84,24,44.53V43c0.58-0.2,1-0.85,1-1.5s-0.42-1.3-1-1.5v-2 c0.58-0.2,1-0.85,1-1.5s-0.42-1.3-1-1.5v-6H30z"/><linearGradient id="bN0gXF350I4SeGvD0XhmSb" x1="22.304" x2="31.696" y1="36.832" y2="19.168" gradientTransform="matrix(1 0 0 -1 0 50)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fede00"/><stop offset="1" stop-color="#ffd000"/></linearGradient><path fill="url(#bN0gXF350I4SeGvD0XhmSb)" d="M27,12c-5.526,0-10,4.474-10,10s4.474,10,10,10s10-4.474,10-10S32.526,12,27,12z M27,21 c-1.656,0-3-1.344-3-3s1.344-3,3-3s3,1.344,3,3S28.656,21,27,21z"/></g><linearGradient id="bN0gXF350I4SeGvD0XhmSc" x1="15.816" x2="17.996" y1="63.86" y2="55.91" gradientTransform="matrix(1 0 0 -1 0 50)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcfcfc"/><stop offset=".495" stop-color="#f4f4f4"/><stop offset=".946" stop-color="#e8e8e8"/><stop offset="1" stop-color="#e8e8e8"/></linearGradient><path fill="url(#bN0gXF350I4SeGvD0XhmSc)" d="M22.5,1C17.26,1,13,5.26,13,10.5c0,1.61,0.385,3.13,1.095,4.45 c0.48-0.5,1.022-0.935,1.592-1.305C15.237,12.685,15,11.62,15,10.5C15,6.36,18.36,3,22.5,3S30,6.36,30,10.5 c0,0.65-0.08,1.29-0.25,1.89c-0.25,0.99-0.7,1.9-1.31,2.68c-0.06,0.07-0.11,0.14-0.17,0.21c-1.06,1.29-2.56,2.22-4.26,2.57 C24,17.9,24,17.95,24,18c0,0.66,0.22,1.28,0.58,1.77c2.03-0.45,3.8-1.55,5.11-3.07l0.01-0.01c0.88-1.01,1.55-2.22,1.92-3.55 C31.87,12.3,32,11.42,32,10.5C32,5.26,27.74,1,22.5,1z"/><path d="M24.5,18c0-0.09,0.01-0.19,0.01-0.28c-0.16,0.05-0.33,0.1-0.5,0.13 C24,17.9,24,17.95,24,18c0,0.1,0.01,0.2,0.02,0.3c0.05,0.55,0.25,1.05,0.56,1.47c0.18-0.04,0.36-0.08,0.53-0.14 C24.73,19.2,24.5,18.62,24.5,18z" opacity=".07"/><path d="M25,18c0-0.16,0.02-0.31,0.05-0.46c-0.17,0.08-0.36,0.14-0.54,0.18 c-0.16,0.05-0.33,0.1-0.5,0.13C24,17.9,24,17.95,24,18c0,0.1,0.01,0.2,0.02,0.3c0.05,0.55,0.25,1.05,0.56,1.47 c0.18-0.04,0.36-0.08,0.53-0.14c0.18-0.05,0.36-0.1,0.53-0.17C25.25,19.09,25,18.57,25,18z" opacity=".05"/></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px">
<style>
.tick { opacity: 1; animation: tick-pulse 1.4s linear infinite }
.step-1 { animation-delay: 0s }
.step-2 { animation-delay: .2s }
.step-3 { animation-delay: .4s }
.step-4 { animation-delay: .6s }
/* Sweep runs over the first second, then a 0.4s pause before the next loop. */
@keyframes tick-pulse {
0% { opacity: 1 }
7% { opacity: .15 }
29%, 100% { opacity: 1 }
}
@media (prefers-reduced-motion: reduce) {
.tick { opacity: 1; animation: none }
}
</style>
<path class="tick step-3" fill="#199be2" d="M22.436,42.965l0.403-2.973c0.074-0.548-0.31-1.051-0.857-1.125 c-0.547-0.074-1.051,0.309-1.125,0.857l-0.403,2.973c-0.074,0.548,0.31,1.051,0.857,1.125S22.362,43.513,22.436,42.965z"/><path class="tick step-4" fill="#199be2" d="M17.554,41.905l1.163-2.765c0.214-0.51-0.025-1.095-0.534-1.309 c-0.509-0.214-1.095,0.024-1.309,0.534l-1.163,2.765c-0.214,0.51,0.025,1.095,0.534,1.309 C16.754,42.653,17.34,42.415,17.554,41.905z"/><path class="tick step-2" fill="#199be2" d="M25.922,38.878c-0.547,0.071-0.934,0.572-0.864,1.12l0.385,2.975 c0.071,0.548,0.572,0.934,1.12,0.864s0.934-0.572,0.864-1.12l-0.385-2.975C26.971,39.193,26.47,38.807,25.922,38.878z"/><path class="tick step-1" fill="#199be2" d="M29.686,37.882c-0.511,0.209-0.756,0.793-0.546,1.304l1.137,2.776 c0.21,0.512,0.794,0.756,1.304,0.546c0.511-0.209,0.756-0.793,0.546-1.304l-1.137-2.776C30.781,37.917,30.197,37.673,29.686,37.882 z"/><path class="tick step-3" fill="#199be2" d="M25.564,5.043l-0.403,2.973c-0.074,0.548,0.31,1.051,0.857,1.125 c0.547,0.074,1.051-0.309,1.125-0.857l0.403-2.973c0.074-0.548-0.31-1.051-0.857-1.125S25.638,4.495,25.564,5.043z"/><path class="tick step-4" fill="#199be2" d="M30.446,6.103l-1.163,2.765c-0.214,0.51,0.025,1.095,0.534,1.309 c0.509,0.214,1.095-0.024,1.309-0.534l1.163-2.765c0.214-0.51-0.025-1.095-0.534-1.309C31.246,5.355,30.66,5.593,30.446,6.103z"/><path class="tick step-2" fill="#199be2" d="M22.078,9.13c0.547-0.071,0.934-0.572,0.864-1.12l-0.385-2.975c-0.071-0.548-0.572-0.934-1.12-0.864 s-0.934,0.572-0.864,1.12l0.385,2.975C21.028,8.815,21.53,9.201,22.078,9.13z"/><path class="tick step-1" fill="#199be2" d="M18.314,10.126c0.511-0.209,0.756-0.793,0.546-1.304l-1.137-2.776 c-0.21-0.512-0.794-0.756-1.304-0.546c-0.511,0.209-0.756,0.793-0.546,1.304L17.01,9.58C17.219,10.091,17.803,10.335,18.314,10.126 z"/><path fill="#199be2" d="M33.55,35.722c0.018-0.018,0.037-0.036,0.057-0.052c6.186-5.085,7.33-14.153,2.555-20.631 c0.021-0.005,0.041-0.006,0.063-0.011c1.606-0.379,2.99-1.282,4.201-2.4c5.963,8.602,4.295,20.397-3.906,26.971 c-0.221,0.177-0.547,0.134-0.719-0.092c0,0-2.319-3.047-2.322-3.051C33.294,36.216,33.353,35.922,33.55,35.722z"/><path fill="#199be2" d="M35.796,11.445l-0.881,6.486c-0.058,0.427,0.43,0.711,0.773,0.45l7.913-6.021 c0.342-0.26,0.198-0.806-0.229-0.864l-6.486-0.881C36.356,10.544,35.868,10.915,35.796,11.445z"/><path fill="#199be2" d="M14.44,12.291c-0.019,0.017-0.039,0.034-0.058,0.051c-6.186,5.085-7.33,14.153-2.555,20.631 c-0.021,0.005-0.041,0.006-0.063,0.011c-1.606,0.379-2.99,1.282-4.201,2.4c-5.963-8.602-4.295-20.397,3.906-26.971 c0.221-0.177,0.547-0.134,0.719,0.092l2.322,3.051c0.084,0.133,0.146,0.265,0.119,0.426C14.608,12.11,14.532,12.207,14.44,12.291z"/><path fill="#199be2" d="M12.192,36.568l0.881-6.486c0.058-0.427-0.43-0.711-0.773-0.45l-7.913,6.021 c-0.342,0.26-0.198,0.806,0.229,0.864l6.486,0.881C11.632,37.468,12.12,37.098,12.192,36.568z"/><path fill="#199be2" d="M33.371,35.865c-0.468,0.429-0.486,1.161-0.039,1.613l10.133,10.229c0.391,0.391,1.024,0.391,1.414,0 l2.828-2.828c0.391-0.391,0.391-1.024,0-1.414L36.936,32.596L33.371,35.865z"/></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px">
<style>
/* The mask reveals the check along its own centreline, so the leading edge is a clean
nib-width cut travelling left to right rather than a vertical wipe. */
.check-nib {
stroke-dasharray: 100;
animation: check-draw 4s ease-in-out infinite;
}
.check { animation: check-ink 4s linear infinite }
@keyframes check-draw {
0% { stroke-dashoffset: 100 }
22%, 88% { stroke-dashoffset: 0 }
100% { stroke-dashoffset: 100 }
}
/* Clears the ink while the stroke rewinds, so the loop never snaps back. */
@keyframes check-ink {
0%, 78% { opacity: 1 }
88%, 100% { opacity: 0 }
}
@media (prefers-reduced-motion: reduce) {
.check-nib, .check { animation: none }
.check-nib { stroke-dashoffset: 0 }
}
</style>
<linearGradient id="g6m9hEw6~hyGkm4KdOogNa" x1="16.86" x2="29.576" y1="1.533" y2="41.546" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c77740"/><stop offset="1" stop-color="#b0622b"/></linearGradient><path fill="url(#g6m9hEw6~hyGkm4KdOogNa)" d="M38,4H26c0,1.105-0.895,2-2,2s-2-0.895-2-2H10C8.895,4,8,4.895,8,6v36c0,1.105,0.895,2,2,2h28 c1.105,0,2-0.895,2-2V6C40,4.895,39.105,4,38,4z"/><linearGradient id="g6m9hEw6~hyGkm4KdOogNb" x1="24" x2="24" y1="36.966" y2="40.893" gradientUnits="userSpaceOnUse"><stop offset=".442" stop-color="#878786"/><stop offset=".594" stop-color="#9f9f9e"/><stop offset=".859" stop-color="#c3c3c3"/><stop offset="1" stop-color="#d1d1d1"/></linearGradient><path fill="url(#g6m9hEw6~hyGkm4KdOogNb)" d="M37,42H11c-0.552,0-1-0.448-1-1v-9h28v9C38,41.552,37.552,42,37,42z"/><path fill="#f3f3f3" d="M37,40H11c-0.552,0-1-0.448-1-1V7c0-0.552,0.448-1,1-1h26c0.552,0,1,0.448,1,1v32 C38,39.552,37.552,40,37,40z"/><linearGradient id="g6m9hEw6~hyGkm4KdOogNc" x1="-384.93" x2="-383.046" y1="275.072" y2="282.909" gradientTransform="rotate(180 -180 142.5)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c3cdd9"/><stop offset="1" stop-color="#9fa7b0"/></linearGradient><path fill="url(#g6m9hEw6~hyGkm4KdOogNc)" d="M31,8V4h-4.184C26.403,2.837,25.304,2,24,2s-2.403,0.837-2.816,2H17v4c0,0.552,0.448,1,1,1h12 C30.552,9,31,8.552,31,8z M24,4c0.552,0,1,0.448,1,1c0,0.552-0.448,1-1,1s-1-0.448-1-1C23,4.448,23.448,4,24,4z"/><linearGradient id="g6m9hEw6~hyGkm4KdOogNd" x1="15" x2="34" y1="25.038" y2="25.038" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#3079d6"/><stop offset="1" stop-color="#297cd2"/></linearGradient><mask id="check-draw-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48">
<path class="check-nib" d="M15.1824,22.8144L22.223,29.855L33.8196,18.2584" pathLength="100" fill="none" stroke="#fff" stroke-width="7" stroke-linecap="butt" stroke-linejoin="miter"/>
</mask><path class="check" mask="url(#check-draw-mask)" fill="url(#g6m9hEw6~hyGkm4KdOogNd)" d="M21.594,31.74l-6.333-6.333c-0.347-0.347-0.347-0.91,0-1.257l1.257-1.257 c0.347-0.347,0.91-0.347,1.257,0l4.448,4.448l9.004-9.004c0.347-0.347,0.91-0.347,1.257,0l1.257,1.257 c0.347,0.347,0.347,0.91,0,1.257L22.851,31.74C22.504,32.087,21.941,32.087,21.594,31.74z"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px">
<style>
.dot {
fill: #7a8793;
transform-box: view-box;
transform-origin: 0 0;
offset-anchor: 0 0;
offset-rotate: 0deg;
animation: flow-fwd 2s linear infinite;
}
.d1 { offset-path: path("M9,24V12.3A3.3,3.3 0 0 1 12.3,9H24"); animation-duration: 1.6s }
.d2 { offset-path: path("M24,9H35.7A3.3,3.3 0 0 1 39,12.3V24"); animation-duration: 1.9s; animation-delay: -.4s }
.d3 { offset-path: path("M39,24V35.7A3.3,3.3 0 0 1 35.7,39H24"); animation-duration: 1.5s; animation-delay: -.9s }
.d4 { offset-path: path("M24,39H12.3A3.3,3.3 0 0 1 9,35.7V24"); animation-duration: 1.8s; animation-delay: -.2s }
/* Two dots run against the flow so the traffic does not read as one rotating chain. */
.d5 { offset-path: path("M24,9H35.7A3.3,3.3 0 0 1 39,12.3V24"); animation-name: flow-rev; animation-duration: 2.2s; animation-delay: -1.1s }
.d6 { offset-path: path("M24,39H12.3A3.3,3.3 0 0 1 9,35.7V24"); animation-name: flow-rev; animation-duration: 2.1s; animation-delay: -.7s }
@keyframes flow-fwd { from { offset-distance: 0% } to { offset-distance: 100% } }
@keyframes flow-rev { from { offset-distance: 100% } to { offset-distance: 0% } }
/* Short green blips at uneven points; mismatched periods keep the four out of sync. */
.node { animation: node-flash 2.3s linear infinite }
.node-2 { animation-duration: 3.1s; animation-delay: -.6s }
.node-3 { animation-duration: 2.7s; animation-delay: -1.4s }
.node-4 { animation-duration: 3.7s; animation-delay: -.9s }
@keyframes node-flash {
0%, 8% { fill: #50e6ff }
10% { fill: #17e05c }
16% { fill: #50e6ff }
43% { fill: #50e6ff }
45% { fill: #17e05c }
51%, 100% { fill: #50e6ff }
}
@media (prefers-reduced-motion: reduce) {
.dot, .node { animation: none }
.traffic { display: none }
}
</style>
<linearGradient id="k2yiUqNWn2Slk9gpK~j2Da" x1="14.104" x2="33.763" y1="42.408" y2="5.435" gradientTransform="matrix(1 0 0 -1 0 47.89)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c8d3de"/><stop offset="1" stop-color="#c8d3de"/></linearGradient><path fill="url(#k2yiUqNWn2Slk9gpK~j2Da)" d="M35.7,40H12.3C9.9,40,8,38.1,8,35.7V12.3C8,9.9,9.9,8,12.3,8h23.3c2.4,0,4.3,1.9,4.3,4.3v23.3 C40,38.1,38.1,40,35.7,40z M12.3,10C11,10,10,11,10,12.3v23.3c0,1.3,1,2.3,2.3,2.3h23.3c1.3,0,2.3-1,2.3-2.3V12.3 c0-1.3-1-2.3-2.3-2.3H12.3z"/><g class="traffic">
<circle class="dot d1" r="1.3"/>
<circle class="dot d2" r="1.3"/>
<circle class="dot d3" r="1.3"/>
<circle class="dot d4" r="1.3"/>
<circle class="dot d5" r="1.3"/>
<circle class="dot d6" r="1.3"/>
</g><linearGradient id="k2yiUqNWn2Slk9gpK~j2Db" x1="5.936" x2="12.063" y1="29.652" y2="18.128" gradientTransform="matrix(1 0 0 -1 0 47.89)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2aa4f4"/><stop offset="1" stop-color="#007ad9"/></linearGradient><path fill="url(#k2yiUqNWn2Slk9gpK~j2Db)" d="M15,27c0,1.1-0.9,2-2,2H5c-1.1,0-2-0.9-2-2v-6c0-1.1,0.9-2,2-2h8c1.1,0,2,0.9,2,2V27z"/><linearGradient id="k2yiUqNWn2Slk9gpK~j2Dc" x1="20.936" x2="27.064" y1="44.652" y2="33.129" gradientTransform="matrix(1 0 0 -1 0 47.89)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2aa4f4"/><stop offset="1" stop-color="#007ad9"/></linearGradient><path fill="url(#k2yiUqNWn2Slk9gpK~j2Dc)" d="M30,12c0,1.1-0.9,2-2,2h-8c-1.1,0-2-0.9-2-2V6c0-1.1,0.9-2,2-2h8c1.1,0,2,0.9,2,2V12z"/><linearGradient id="k2yiUqNWn2Slk9gpK~j2Dd" x1="20.936" x2="27.064" y1="14.652" y2="3.128" gradientTransform="matrix(1 0 0 -1 0 47.89)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2aa4f4"/><stop offset="1" stop-color="#007ad9"/></linearGradient><path fill="url(#k2yiUqNWn2Slk9gpK~j2Dd)" d="M30,42c0,1.1-0.9,2-2,2h-8c-1.1,0-2-0.9-2-2v-6c0-1.1,0.9-2,2-2h8c1.1,0,2,0.9,2,2V42z"/><linearGradient id="k2yiUqNWn2Slk9gpK~j2De" x1="35.937" x2="42.063" y1="29.652" y2="18.128" gradientTransform="matrix(1 0 0 -1 0 47.89)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2aa4f4"/><stop offset="1" stop-color="#007ad9"/></linearGradient><path fill="url(#k2yiUqNWn2Slk9gpK~j2De)" d="M45,27c0,1.1-0.9,2-2,2h-8c-1.1,0-2-0.9-2-2v-6c0-1.1,0.9-2,2-2h8c1.1,0,2,0.9,2,2V27z"/><rect class="node node-1" width="8" height="6" x="5" y="21" fill="#50e6ff"/><rect class="node node-2" width="8" height="6" x="20" y="6" fill="#50e6ff"/><rect class="node node-3" width="8" height="6" x="20" y="36" fill="#50e6ff"/><rect class="node node-4" width="8" height="6" x="35" y="21" fill="#50e6ff"/></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px">
<style>
.tool { transform-box: view-box }
/* The handle is painted after the tools, so a folded tool is simply hidden behind it. */
.tool-1 { transform-origin: 28px 11px; animation: fold-1 3.5s ease-in-out infinite }
.tool-2 { transform-origin: 18px 26px; animation: fold-2 3.5s ease-in-out infinite }
.tool-3 { transform-origin: 18px 39px; animation: fold-3 3.5s ease-in-out infinite }
/* Open in turn, close in reverse, then stay shut for the last ~1s of the cycle. */
@keyframes fold-1 {
0% { transform: rotate(53deg) }
13%, 57% { transform: rotate(0deg) }
70%, 100% { transform: rotate(53deg) }
}
@keyframes fold-2 {
0%, 7% { transform: rotate(55deg) }
20%, 50% { transform: rotate(0deg) }
63%, 100% { transform: rotate(55deg) }
}
@keyframes fold-3 {
0%, 14% { transform: rotate(44deg) }
27%, 43% { transform: rotate(0deg) }
56%, 100% { transform: rotate(44deg) }
}
@media (prefers-reduced-motion: reduce) {
.tool { animation: none }
}
</style>
<defs><linearGradient id="q4ZcgI2I2xY6Kw_QGjTtla" x1="47.342" x2="24.972" y1="16.75" y2="16.75" data-name="Безымянный градиент 55" gradientUnits="userSpaceOnUse"><stop offset=".087" stop-color="#dfe9f2"/><stop offset=".291" stop-color="#d6e0e9"/><stop offset=".662" stop-color="#bfc8d1"/><stop offset=".741" stop-color="#889097"/></linearGradient><linearGradient id="q4ZcgI2I2xY6Kw_QGjTtlb" x1="5.412" x2="18" y1="17" y2="17" data-name="Безымянный градиент 56" gradientUnits="userSpaceOnUse"><stop offset=".024" stop-color="#dfe9f2"/><stop offset=".327" stop-color="#d6e0e9"/><stop offset=".798" stop-color="#bfc8d1"/><stop offset=".941" stop-color="#889097"/></linearGradient><linearGradient id="q4ZcgI2I2xY6Kw_QGjTtlc" x1="4.414" x2="18" y1="30.543" y2="30.543" data-name="Безымянный градиент 57" gradientUnits="userSpaceOnUse"><stop offset=".038" stop-color="#dfe9f2"/><stop offset=".488" stop-color="#d6e0e9"/><stop offset=".809" stop-color="#bfc8d1"/><stop offset=".956" stop-color="#889097"/></linearGradient><linearGradient id="q4ZcgI2I2xY6Kw_QGjTtld" x1="17.087" x2="31.387" y1="5.006" y2="44.294" data-name="Безымянный градиент 123" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f44f5a"/><stop offset=".443" stop-color="#ee3d4a"/><stop offset="1" stop-color="#e52030"/></linearGradient></defs><g class="tool tool-1"><path fill="url(#q4ZcgI2I2xY6Kw_QGjTtla)" d="M30,9,45,24a8.67909,8.67909,0,0,1-9-2L26,12l3-3Z"/></g><g class="tool tool-2"><path fill="url(#q4ZcgI2I2xY6Kw_QGjTtlb)" d="M18,24,5.70711,11.70711A1,1,0,0,1,6.41421,10H10v2h2v2h2v2h2v2h2Z"/></g><g class="tool tool-3"><path fill="url(#q4ZcgI2I2xY6Kw_QGjTtlc)" d="M14.11129,25.33513a1.62707,1.62707,0,0,1-.27677,1.82874,1.90889,1.90889,0,0,1-2.70188-.03127L9.882,25.882a1.07858,1.07858,0,0,1-.17486-1.58907L10.8043,23.1957a.50575.50575,0,0,0-.1902-.83335,4.96853,4.96853,0,0,0-5.14962,1.17317l-.75737.75737a1,1,0,0,0,0,1.41422L18,39V29l-3.7496-3.74959A.08377.08377,0,0,0,14.11129,25.33513Z"/></g><rect width="14" height="38" x="17" y="5" fill="url(#q4ZcgI2I2xY6Kw_QGjTtld)" rx="7"/><path d="M24.5,33a.5.5,0,0,1,.5.5V35h1.5a.5.5,0,0,1,.5.5v1a.5.5,0,0,1-.5.5H25v1.5a.5.5,0,0,1-.5.5h-1a.5.5,0,0,1-.5-.5V37H21.5a.5.5,0,0,1-.5-.5v-1a.5.5,0,0,1,.5-.5H23V33.5a.5.5,0,0,1,.5-.5h1m0-1h-1A1.5017,1.5017,0,0,0,22,33.5V34h-.5A1.5017,1.5017,0,0,0,20,35.5v1A1.5017,1.5017,0,0,0,21.5,38H22v.5A1.5017,1.5017,0,0,0,23.5,40h1A1.5017,1.5017,0,0,0,26,38.5V38h.5A1.5017,1.5017,0,0,0,28,36.5v-1A1.5017,1.5017,0,0,0,26.5,34H26v-.5A1.5017,1.5017,0,0,0,24.5,32Z" opacity=".05"/><path d="M24.5,33a.5.5,0,0,1,.5.5V35h1.5a.5.5,0,0,1,.5.5v1a.5.5,0,0,1-.5.5H25v1.5a.5.5,0,0,1-.5.5h-1a.5.5,0,0,1-.5-.5V37H21.5a.5.5,0,0,1-.5-.5v-1a.5.5,0,0,1,.5-.5H23V33.5a.5.5,0,0,1,.5-.5h1m0-.5h-1a1.00112,1.00112,0,0,0-1,1v1h-1a1.00112,1.00112,0,0,0-1,1v1a1.00112,1.00112,0,0,0,1,1h1v1a1.00112,1.00112,0,0,0,1,1h1a1.00112,1.00112,0,0,0,1-1v-1h1a1.00112,1.00112,0,0,0,1-1v-1a1.00112,1.00112,0,0,0-1-1h-1v-1a1.00112,1.00112,0,0,0-1-1Z" opacity=".07"/><path fill="#fff" d="M26.5,35H25V33.5a.5.5,0,0,0-.5-.5h-1a.5.5,0,0,0-.5.5V35H21.5a.5.5,0,0,0-.5.5v1a.5.5,0,0,0,.5.5H23v1.5a.5.5,0,0,0,.5.5h1a.5.5,0,0,0,.5-.5V37h1.5a.5.5,0,0,0,.5-.5v-1A.5.5,0,0,0,26.5,35Z"/></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px">
<style>
.sock {
transform-box: view-box;
transform-origin: 17px 18px;
animation: sock-wobble 4.3s ease-in-out infinite;
}
/* Uneven stops and amplitudes so the flapping reads as gusty rather than a metronome. */
@keyframes sock-wobble {
0%, 100% { transform: rotate(-1deg) }
9% { transform: rotate(2.5deg) }
17% { transform: rotate(-2deg) }
28% { transform: rotate(4deg) }
36% { transform: rotate(.5deg) }
47% { transform: rotate(-3.5deg) }
55% { transform: rotate(1.5deg) }
63% { transform: rotate(-.5deg) }
74% { transform: rotate(3deg) }
85% { transform: rotate(-2.5deg) }
93% { transform: rotate(1deg) }
}
@media (prefers-reduced-motion: reduce) {
.sock { animation: none }
}
</style>
<path fill="#64717c" d="M17,19H7c-0.552,0-1-0.447-1-1s0.448-1,1-1h10c0.552,0,1,0.447,1,1S17.552,19,17,19z"/><path fill="#64717c" d="M7.001,17c-0.354,0-0.697-0.188-0.879-0.521c-0.265-0.484-0.086-1.092,0.399-1.356l11-6 c0.486-0.265,1.093-0.085,1.357,0.399c0.265,0.484,0.086,1.092-0.399,1.356l-11,6C7.327,16.961,7.163,17,7.001,17z"/><path fill="#64717c" d="M17.999,27c-0.162,0-0.326-0.039-0.478-0.122l-11-6c-0.485-0.265-0.664-0.872-0.399-1.356 s0.872-0.663,1.357-0.399l11,6c0.485,0.265,0.664,0.872,0.399,1.356C18.696,26.812,18.353,27,17.999,27z"/><path fill="#64717c" d="M7,44c-0.552,0-1-0.447-1-1V8c0-0.553,0.448-1,1-1s1,0.447,1,1v35C8,43.553,7.552,44,7,44z"/><linearGradient id="FLX5bOLQ980VwobKteXXKa" x1="5" x2="9" y1="18" y2="18" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#92a3b0"/><stop offset=".015" stop-color="#a3b5c4"/><stop offset=".032" stop-color="#aec2d1"/><stop offset=".046" stop-color="#b2c6d6"/></linearGradient><line x1="7" x2="7" y1="20" y2="16" fill="none" stroke="url(#FLX5bOLQ980VwobKteXXKa)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="4"/><linearGradient id="FLX5bOLQ980VwobKteXXKb" x1="5.648" x2="8.44" y1="5.375" y2="10.796" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f44f5a"/><stop offset=".443" stop-color="#ee3d4a"/><stop offset="1" stop-color="#e52030"/></linearGradient><circle cx="7" cy="8" r="3" fill="url(#FLX5bOLQ980VwobKteXXKb)"/><g class="sock"><linearGradient id="FLX5bOLQ980VwobKteXXKc" x1="28.203" x2="30.596" y1="7.332" y2="24.394" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f44f5a"/><stop offset=".443" stop-color="#ee3d4a"/><stop offset="1" stop-color="#e52030"/></linearGradient><path fill="url(#FLX5bOLQ980VwobKteXXKc)" d="M42.352,12.317l-24-4.286C17.126,7.813,16,8.755,16,10.001v15.999 c0,1.245,1.126,2.188,2.352,1.969l24-4.286c0.954-0.17,1.648-1,1.648-1.969v-7.427C44,13.317,43.305,12.488,42.352,12.317z"/><linearGradient id="FLX5bOLQ980VwobKteXXKd" x1="27.618" x2="31.169" y1="8.863" y2="24.613" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f0f0f0"/><stop offset="1" stop-color="#bbc1c4"/></linearGradient><path fill="url(#FLX5bOLQ980VwobKteXXKd)" d="M22,8.683l5,0.893v16.848l-5,0.893V8.683z M33,10.647v14.705l5-0.893V11.54L33,10.647z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@ -132,7 +132,10 @@
auto-close auto-close
transition-show="jump-left"> transition-show="jump-left">
<w-list padding style="min-width: 225px"> <w-list padding style="min-width: 225px">
<w-item clickable disabled v-if="userStore.can(`manage:pages`)"> <w-item
clickable
disabled
v-if="flagsStore.experimental && userStore.can(`manage:pages`)">
<w-item-section class="items-center" avatar> <w-item-section class="items-center" avatar>
<w-icon class="text-deep-orange-9" name="la:atom" size="sm" /> <w-icon class="text-deep-orange-9" name="la:atom" size="sm" />
</w-item-section> </w-item-section>
@ -144,7 +147,7 @@
</w-item-section> </w-item-section>
<w-item-section><w-item-label>Rerender Page</w-item-label></w-item-section> <w-item-section><w-item-label>Rerender Page</w-item-label></w-item-section>
</w-item> </w-item>
<w-item clickable disabled> <w-item clickable disabled v-if="flagsStore.experimental">
<w-item-section class="items-center" avatar> <w-item-section class="items-center" avatar>
<w-icon class="text-deep-orange-9" name="la:sun" size="sm" /> <w-icon class="text-deep-orange-9" name="la:sun" size="sm" />
</w-item-section> </w-item-section>

@ -2,7 +2,7 @@
<w-page> <w-page>
<div class="flex flex-wrap items-center p-4"> <div class="flex flex-wrap items-center p-4">
<div class="flex-none"> <div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-inspection.svg" /> <img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-inspection-animated.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.approval.title') }}</div> <div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.approval.title') }}</div>

@ -2,7 +2,7 @@
<w-page class="admin-dashboard"> <w-page class="admin-dashboard">
<div class="flex flex-wrap p-4 items-center"> <div class="flex flex-wrap p-4 items-center">
<div class="flex-none"> <div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-apps-tab.svg" /> <img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-apps-tab-animated.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.dashboard.title') }}</div> <div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.dashboard.title') }}</div>

@ -143,6 +143,16 @@ const state = reactive({
extensions: [] extensions: []
}) })
/**
* How long to give an install, in milliseconds.
*
* Stated because the client's own default is ten seconds, which no npm install finishes inside: the
* request would be abandoned here while npm carried on running on the server, reporting a failure for
* something that was about to succeed and leaving the administrator to install it twice. Matches the
* ceiling the server puts on the same work, Puppeteer's browser download being what sets it.
*/
const INSTALL_TIMEOUT = 20 * 60 * 1000
// METHODS // METHODS
async function load() { async function load() {
@ -167,7 +177,9 @@ async function install(ext) {
html: true html: true
}) })
try { try {
const resp = await API_CLIENT.post(`system/extensions/${ext.key}/install`).json() const resp = await API_CLIENT.post(`system/extensions/${ext.key}/install`, {
timeout: INSTALL_TIMEOUT
}).json()
if (!resp?.ok) { if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured') throw new Error(resp?.message || 'An unexpected error occured')
} }

@ -2,7 +2,7 @@
<w-page class="admin-flags"> <w-page class="admin-flags">
<div class="flex flex-wrap p-4 items-center"> <div class="flex flex-wrap p-4 items-center">
<div class="flex-none"> <div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-windsock.svg" /> <img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-windsock-animated.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.flags.title') }}</div> <div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.flags.title') }}</div>

@ -2,7 +2,7 @@
<w-page class="admin-terminal"> <w-page class="admin-terminal">
<div class="flex flex-wrap p-4 items-center"> <div class="flex flex-wrap p-4 items-center">
<div class="flex-none"> <div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-network.svg" /> <img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-network-animated.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.instances.title') }}</div> <div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.instances.title') }}</div>

@ -2,7 +2,7 @@
<w-page class="admin-login"> <w-page class="admin-login">
<div class="flex flex-wrap p-4 items-center"> <div class="flex flex-wrap p-4 items-center">
<div class="flex-none"> <div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-bunch-of-keys.svg" /> <img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-bunch-of-keys-animated.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.login.title') }}</div> <div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.login.title') }}</div>

@ -4,7 +4,7 @@
<div class="flex-none"> <div class="flex-none">
<img <img
class="admin-icon animated fadeInLeft" class="admin-icon animated fadeInLeft"
src="/_assets/icons/fluent-find-and-replace.svg" /> src="/_assets/icons/fluent-find-and-replace-animated.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.search.title') }}</div> <div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.search.title') }}</div>

@ -4,7 +4,7 @@
<div class="flex-none"> <div class="flex-none">
<img <img
class="admin-icon animated fadeInLeft" class="admin-icon animated fadeInLeft"
src="/_assets/icons/fluent-swiss-army-knife.svg" /> src="/_assets/icons/fluent-swiss-army-knife-animated.svg" />
</div> </div>
<div class="min-w-0 flex-1 pl-4"> <div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.utilities.title') }}</div> <div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.utilities.title') }}</div>

Loading…
Cancel
Save