MediaWiki:KuranokaPageTransition.css:修订间差异
MediaWiki界面页面
更多操作
创建页面,内容为“→================================================================ KURANOKA / KURONATA — PAGE TRANSITION 1.0 Paste the ENTIRE file at the end of MediaWiki:Citizen.css. This does not change the Citizen glass layout or Stormbreak FX. ================================================================: →Allow same-origin document navigation to retain old/new snapshots. The default transition for unrelated Wiki pages stays very short.: @view-t…” |
无编辑摘要 |
||
| 第1行: | 第1行: | ||
/* | /* =============================================================== | ||
Kuranoka Page Transition v2 — REAL CANVAS FIRE | |||
REPLACE MediaWiki:KuranokaPageTransition.js with this entire file. | |||
No global styles. No external JavaScript dependencies. | |||
=============================================================== */ | |||
(function () { | |||
'use strict'; | |||
if (window.__kkptV2 || window !== window.top) return; | |||
window.__kkptV2 = true; | |||
if (!window.mw || !mw.config) return; | |||
if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return; | |||
const page = mw.config.get('wgPageName'); | |||
if (page !== 'Kuranoka' && page !== 'Kuronata') return; | |||
const burn = page === 'Kuranoka'; | |||
const target = burn ? 'Kuronata' : 'Kuranoka'; | |||
const targetUrl = '/wiki/' + encodeURIComponent(target); | |||
const DURATION = burn ? 3450 : 2850; | |||
let busy = false; | |||
function makeButton() { | |||
if (!document.body || document.getElementById('kkpt-toggle')) return; | |||
const button = document.createElement('a'); | |||
button.id = 'kkpt-toggle'; | |||
button.href = targetUrl; | |||
button.setAttribute('aria-label', burn ? '焚毁当前世界,切换到 Kuronata' : '破晓,返回 Kuranoka'); | |||
const icon = document.createElement('span'); | |||
icon.className = 'kkpt-icon'; | |||
icon.setAttribute('aria-hidden', 'true'); | |||
icon.textContent = burn ? '✦' : '☀'; | |||
const label = document.createElement('span'); | |||
label.className = 'kkpt-text'; | |||
const title = document.createElement('span'); | |||
title.textContent = target.toUpperCase(); | |||
const sub = document.createElement('span'); | |||
sub.className = 'kkpt-sub'; | |||
sub.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY'; | |||
label.append(title, sub); | |||
button.append(icon, label); | |||
document.body.append(button); | |||
button.addEventListener('click', function (event) { | |||
// Preserve opening in a new tab, modifiers, and browser behavior. | |||
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || | |||
event.shiftKey || event.altKey) return; | |||
event.preventDefault(); | |||
if (busy) return; | |||
busy = true; | |||
button.classList.add('kkpt-loading'); | |||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { | |||
window.location.assign(targetUrl); | |||
return; | |||
} | |||
} | begin().catch(function (err) { | ||
console.error('[KuranokaPageTransition] Transition failed:', err); | |||
window.location.assign(targetUrl); | |||
}); | |||
}); | |||
} | |||
/ | // Use an actual same-origin rendering of the destination as the layer | ||
// that shows through the burnt holes. If framing is blocked, fall back | |||
// to a solid atmospheric color while retaining the fire animation. | |||
function preparePreview() { | |||
return new Promise(function (resolve) { | |||
const iframe = document.createElement('iframe'); | |||
iframe.id = 'kkpt-preview'; | |||
iframe.className = burn ? 'kkpt-blood' : 'kkpt-sky'; | |||
iframe.title = 'Transition preview'; | |||
iframe.tabIndex = -1; | |||
iframe.setAttribute('aria-hidden', 'true'); | |||
iframe.style.clipPath = burn | |||
? 'polygon(100% 0,100% 100%,100% 100%,100% 0)' | |||
: 'polygon(0 0,0 100%,0 100%,0 0)'; | |||
let completed = false; | |||
const finish = function (success) { | |||
if (completed) return; | |||
completed = true; | |||
} | clearTimeout(timeout); | ||
if (success) { | |||
try { iframe.contentWindow.scrollTo(0, 0); } catch (e) { /* ignore */ } | |||
resolve(iframe); | |||
} else { | |||
iframe.remove(); | |||
const fallback = document.createElement('div'); | |||
fallback.id = 'kkpt-preview'; | |||
fallback.className = burn ? 'kkpt-blood' : 'kkpt-sky'; | |||
fallback.style.clipPath = burn | |||
? 'polygon(100% 0,100% 100%,100% 100%,100% 0)' | |||
: 'polygon(0 0,0 100%,0 100%,0 0)'; | |||
document.body.append(fallback); | |||
resolve(fallback); | |||
} | |||
}; | |||
const timeout = setTimeout(function () { finish(false); }, 3500); | |||
iframe.addEventListener('load', function () { | |||
let valid = false; | |||
} | try { | ||
valid = Boolean(iframe.contentDocument && | |||
iframe.contentDocument.querySelector('#mw-content-text')); | |||
} catch (e) { valid = false; } | |||
finish(valid); | |||
}, { once: true }); | |||
iframe.addEventListener('error', function () { finish(false); }, { once: true }); | |||
document.body.append(iframe); | |||
iframe.src = targetUrl; | |||
}); | |||
} | |||
function createSprite(stops) { | |||
const canvas = document.createElement('canvas'); | |||
canvas.width = canvas.height = 96; | |||
const c = canvas.getContext('2d'); | |||
const gradient = c.createRadialGradient(48, 48, 0, 48, 48, 48); | |||
stops.forEach(function (stop) { gradient.addColorStop(stop[0], stop[1]); }); | |||
c.fillStyle = gradient; | |||
c.fillRect(0, 0, 96, 96); | |||
return canvas; | |||
} | } | ||
const SPRITES = { | |||
flame: createSprite([[0, '#fffadc'], [.14, '#ffeb75'], [.43, '#ff7a17'], [.72, '#eb230b88'], [1, '#9a000000']]), | |||
ember: createSprite([[0, '#ffffff'], [.18, '#fff5af'], [.52, '#ff8500aa'], [1, '#ff330000']]), | |||
smoke: createSprite([[0, '#160b0acc'], [.4, '#24171977'], [1, '#11111100']]), | |||
blue: createSprite([[0, '#ffffff'], [.18, '#e6ffff'], [.47, '#75ccffad'], [1, '#55b2ff00']]) | |||
} | }; | ||
function runAnimation(preview) { | |||
return new Promise(function (resolve) { | |||
const canvas = document.createElement('canvas'); | |||
canvas.id = 'kkpt-canvas'; | |||
canvas.setAttribute('aria-hidden', 'true'); | |||
document.body.append(canvas); | |||
const ctx = canvas.getContext('2d', { alpha: true }); | |||
if (!ctx) { canvas.remove(); resolve(); return; } | |||
let width = 0; | |||
let height = 0; | |||
let ratio = 1; | |||
function size() { | |||
width = window.innerWidth; | |||
height = window.innerHeight; | |||
ratio = Math.min(window.devicePixelRatio || 1, 1.7); | |||
canvas.width = Math.round(width * ratio); | |||
canvas.height = Math.round(height * ratio); | |||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); | |||
} | |||
size(); | |||
const particles = []; | |||
const rand = function (a, b) { return a + Math.random() * (b - a); }; | |||
const smooth = function (x) { return x * x * (3 - 2 * x); }; | |||
let previous = performance.now(); | |||
let start = previous; | |||
let done = false; | |||
function spawn(x, y) { | |||
if (particles.length > 1300) return; | |||
const r = Math.random(); | |||
const kind = burn ? (r < .64 ? 'flame' : r < .84 ? 'ember' : 'smoke') : 'blue'; | |||
const life = kind === 'smoke' ? rand(.6, 1.4) : kind === 'ember' ? rand(.42, 1.1) : rand(.28, .75); | |||
particles.push({ | |||
kind: kind, | |||
x: x + rand(-13, 13), | |||
y: y + rand(-14, 14), | |||
vx: rand(burn ? -125 : -55, burn ? 55 : 85), | |||
vy: rand(-185, -35), | |||
life: life, | |||
remaining: life, | |||
radius: kind === 'smoke' ? rand(32, 85) : kind === 'ember' ? rand(3, 12) : rand(16, 47), | |||
angle: rand(-.4, .4) | |||
}); | |||
} | |||
function render(now) { | |||
if (done) return; | |||
const delta = Math.min(.05, Math.max(0, (now - previous) / 1000)); | |||
previous = now; | |||
const raw = Math.min(1, (now - start) / DURATION); | |||
const eased = smooth(raw); | |||
const center = burn | |||
? (width + 100) - (width + 200) * eased | |||
: -100 + (width + 200) * eased; | |||
const waviness = Math.sin(Math.PI * raw); | |||
function edgeX(y) { | |||
return center + waviness * ( | |||
16 * Math.sin(y * .023 + now * .0055) + | |||
9 * Math.sin(y * .063 - now * .009) + | |||
5 * Math.sin(y * .13 + now * .012) | |||
); | |||
} | |||
const pts = []; | |||
const count = Math.max(28, Math.ceil(height / 25)); | |||
for (let i = 0; i <= count; i++) { | |||
const y = height * i / count; | |||
pts.push([edgeX(y), y]); | |||
} | |||
const reverse = pts.slice().reverse().map(function (p) { | |||
return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'; | |||
}).join(','); | |||
preview.style.clipPath = burn | |||
? 'polygon(100% 0,100% 100%,' + reverse + ')' | |||
: 'polygon(0 0,0 100%,' + reverse + ')'; | |||
ctx.clearRect(0, 0, width, height); | |||
// Smoke first, so bright fire and sparks appear in front. | |||
ctx.globalCompositeOperation = 'source-over'; | |||
for (let i = particles.length - 1; i >= 0; i--) { | |||
const p = particles[i]; | |||
p.remaining -= delta; | |||
if (p.remaining <= 0) { particles.splice(i, 1); continue; } | |||
p.x += p.vx * delta; | |||
p.y += p.vy * delta; | |||
p.vy -= 18 * delta; | |||
if (p.kind !== 'smoke') continue; | |||
const opacity = Math.min(1, p.remaining / .35) * .42; | |||
const size = p.radius * (1 + (1 - p.remaining / p.life) * .8); | |||
ctx.globalAlpha = opacity; | |||
ctx.drawImage(SPRITES.smoke, p.x - size, p.y - size, size * 2, size * 2); | |||
} | |||
ctx.globalAlpha = 1; | |||
// The irregular, continuously moving combustion front. | |||
ctx.beginPath(); | |||
pts.forEach(function (p, i) { | |||
if (i === 0) ctx.moveTo(p[0], p[1]); | |||
else ctx.lineTo(p[0], p[1]); | |||
}); | |||
ctx.lineJoin = 'round'; | |||
ctx.lineCap = 'round'; | |||
ctx.shadowColor = burn ? '#ff3905' : '#b1eaff'; | |||
ctx.shadowBlur = burn ? 48 : 55; | |||
ctx.strokeStyle = burn ? 'rgba(255,41,5,.82)' : 'rgba(160,227,255,.72)'; | |||
ctx.lineWidth = burn ? 29 : 35; | |||
ctx.stroke(); | |||
ctx.shadowBlur = 21; | |||
ctx.strokeStyle = burn ? '#ff9d1c' : '#e5fbff'; | |||
ctx.lineWidth = burn ? 9 : 11; | |||
ctx.stroke(); | |||
ctx.shadowBlur = 11; | |||
ctx.strokeStyle = '#fff9e2'; | |||
ctx.lineWidth = 2.5; | |||
ctx.stroke(); | |||
ctx.shadowBlur = 0; | |||
// Actual flame tongues curling into the page that is burning. | |||
// Layered Bezier shapes prevent this from looking like a flat wipe. | |||
if (burn) { | |||
ctx.save(); | |||
ctx.globalCompositeOperation = 'lighter'; | |||
for (let y = -20; y < height + 45; y += 31) { | |||
const x = edgeX(y); | |||
const flicker = Math.sin(now * .014 + y * .19); | |||
const lift = 24 + 29 * (1 + flicker) + 22 * Math.abs(Math.sin(now * .006 - y * .1)); | |||
const lean = 15 + 12 * Math.sin(now * .009 + y * .075); | |||
ctx.fillStyle = 'rgba(255,77,5,.39)'; | |||
ctx.beginPath(); | |||
ctx.moveTo(x + 13, y + 15); | |||
ctx.bezierCurveTo(x - 17, y - 2, x - lean - 25, y - lift * .62, x - lean, y - lift); | |||
ctx.bezierCurveTo(x - 2, y - lift * .56, x + 11, y - 13, x + 17, y + 15); | |||
ctx.closePath(); | |||
ctx.fill(); | |||
ctx.fillStyle = 'rgba(255,220,91,.43)'; | |||
ctx.beginPath(); | |||
ctx.moveTo(x + 3, y + 7); | |||
ctx.quadraticCurveTo(x - 10, y - lift * .28, x - lean * .6, y - lift * .68); | |||
ctx.quadraticCurveTo(x + 3, y - lift * .3, x + 7, y + 7); | |||
ctx.closePath(); | |||
ctx.fill(); | |||
} | |||
ctx.restore(); | |||
} | |||
/ | // Live flame tongues / sparks, not an animated screenshot. | ||
const amount = Math.max(14, Math.min(34, Math.round(height / 41))); | |||
for (let i = 0; i < amount; i++) { | |||
const y = rand(0, height); | |||
spawn(edgeX(y), y); | |||
} | |||
} | |||
ctx.globalCompositeOperation = 'lighter'; | |||
for (let i = 0; i < particles.length; i++) { | |||
const p = particles[i]; | |||
} | if (p.kind === 'smoke') continue; | ||
const stage = 1 - p.remaining / p.life; | |||
const fade = Math.min(1, p.remaining * 3) * (1 - stage * .5); | |||
ctx.save(); | |||
ctx.globalAlpha = fade * (p.kind === 'ember' ? .85 : .8); | |||
ctx.translate(p.x, p.y); | |||
ctx.rotate(p.angle); | |||
const sprite = p.kind === 'blue' ? SPRITES.blue : | |||
p.kind === 'ember' ? SPRITES.ember : SPRITES.flame; | |||
const sx = p.radius * (p.kind === 'flame' ? .9 : 1); | |||
const sy = p.radius * (p.kind === 'flame' ? 1.7 : 1); | |||
ctx.drawImage(sprite, -sx, -sy, sx * 2, sy * 2); | |||
ctx.restore(); | |||
} | |||
ctx.globalAlpha = 1; | |||
ctx.globalCompositeOperation = 'source-over'; | |||
if (raw < 1) { | |||
requestAnimationFrame(render); | |||
} else { | |||
done = true; | |||
preview.style.clipPath = 'none'; | |||
setTimeout(resolve, 110); | |||
} | |||
} | |||
requestAnimationFrame(render); | |||
}); | |||
} | } | ||
function handoff() { | |||
// A very short opaque frame hides the last real document navigation. | |||
// The preview is already the actual target URL, not mock Wiki text. | |||
const cover = document.createElement('div'); | |||
cover.id = 'kkpt-handoff'; | |||
if (!burn) cover.className = 'kkpt-sky'; | |||
document.body.append(cover); | |||
requestAnimationFrame(function () { | |||
cover.style.opacity = '1'; | |||
setTimeout(function () { window.location.assign(targetUrl); }, 200); | |||
}); | |||
} | } | ||
async function begin() { | |||
const preview = await preparePreview(); | |||
await runAnimation(preview); | |||
handoff(); | |||
} | } | ||
if (document.readyState === 'loading') { | |||
document.addEventListener('DOMContentLoaded', makeButton, { once: true }); | |||
} else { | |||
makeButton(); | |||
} | } | ||
}()); | |||
} | |||
2026年10月23日 (五) 11:39的版本
/* ===============================================================
Kuranoka Page Transition v2 — REAL CANVAS FIRE
REPLACE MediaWiki:KuranokaPageTransition.js with this entire file.
No global styles. No external JavaScript dependencies.
=============================================================== */
(function () {
'use strict';
if (window.__kkptV2 || window !== window.top) return;
window.__kkptV2 = true;
if (!window.mw || !mw.config) return;
if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return;
const page = mw.config.get('wgPageName');
if (page !== 'Kuranoka' && page !== 'Kuronata') return;
const burn = page === 'Kuranoka';
const target = burn ? 'Kuronata' : 'Kuranoka';
const targetUrl = '/wiki/' + encodeURIComponent(target);
const DURATION = burn ? 3450 : 2850;
let busy = false;
function makeButton() {
if (!document.body || document.getElementById('kkpt-toggle')) return;
const button = document.createElement('a');
button.id = 'kkpt-toggle';
button.href = targetUrl;
button.setAttribute('aria-label', burn ? '焚毁当前世界,切换到 Kuronata' : '破晓,返回 Kuranoka');
const icon = document.createElement('span');
icon.className = 'kkpt-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = burn ? '✦' : '☀';
const label = document.createElement('span');
label.className = 'kkpt-text';
const title = document.createElement('span');
title.textContent = target.toUpperCase();
const sub = document.createElement('span');
sub.className = 'kkpt-sub';
sub.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
label.append(title, sub);
button.append(icon, label);
document.body.append(button);
button.addEventListener('click', function (event) {
// Preserve opening in a new tab, modifiers, and browser behavior.
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey ||
event.shiftKey || event.altKey) return;
event.preventDefault();
if (busy) return;
busy = true;
button.classList.add('kkpt-loading');
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
window.location.assign(targetUrl);
return;
}
begin().catch(function (err) {
console.error('[KuranokaPageTransition] Transition failed:', err);
window.location.assign(targetUrl);
});
});
}
// Use an actual same-origin rendering of the destination as the layer
// that shows through the burnt holes. If framing is blocked, fall back
// to a solid atmospheric color while retaining the fire animation.
function preparePreview() {
return new Promise(function (resolve) {
const iframe = document.createElement('iframe');
iframe.id = 'kkpt-preview';
iframe.className = burn ? 'kkpt-blood' : 'kkpt-sky';
iframe.title = 'Transition preview';
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.style.clipPath = burn
? 'polygon(100% 0,100% 100%,100% 100%,100% 0)'
: 'polygon(0 0,0 100%,0 100%,0 0)';
let completed = false;
const finish = function (success) {
if (completed) return;
completed = true;
clearTimeout(timeout);
if (success) {
try { iframe.contentWindow.scrollTo(0, 0); } catch (e) { /* ignore */ }
resolve(iframe);
} else {
iframe.remove();
const fallback = document.createElement('div');
fallback.id = 'kkpt-preview';
fallback.className = burn ? 'kkpt-blood' : 'kkpt-sky';
fallback.style.clipPath = burn
? 'polygon(100% 0,100% 100%,100% 100%,100% 0)'
: 'polygon(0 0,0 100%,0 100%,0 0)';
document.body.append(fallback);
resolve(fallback);
}
};
const timeout = setTimeout(function () { finish(false); }, 3500);
iframe.addEventListener('load', function () {
let valid = false;
try {
valid = Boolean(iframe.contentDocument &&
iframe.contentDocument.querySelector('#mw-content-text'));
} catch (e) { valid = false; }
finish(valid);
}, { once: true });
iframe.addEventListener('error', function () { finish(false); }, { once: true });
document.body.append(iframe);
iframe.src = targetUrl;
});
}
function createSprite(stops) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 96;
const c = canvas.getContext('2d');
const gradient = c.createRadialGradient(48, 48, 0, 48, 48, 48);
stops.forEach(function (stop) { gradient.addColorStop(stop[0], stop[1]); });
c.fillStyle = gradient;
c.fillRect(0, 0, 96, 96);
return canvas;
}
const SPRITES = {
flame: createSprite([[0, '#fffadc'], [.14, '#ffeb75'], [.43, '#ff7a17'], [.72, '#eb230b88'], [1, '#9a000000']]),
ember: createSprite([[0, '#ffffff'], [.18, '#fff5af'], [.52, '#ff8500aa'], [1, '#ff330000']]),
smoke: createSprite([[0, '#160b0acc'], [.4, '#24171977'], [1, '#11111100']]),
blue: createSprite([[0, '#ffffff'], [.18, '#e6ffff'], [.47, '#75ccffad'], [1, '#55b2ff00']])
};
function runAnimation(preview) {
return new Promise(function (resolve) {
const canvas = document.createElement('canvas');
canvas.id = 'kkpt-canvas';
canvas.setAttribute('aria-hidden', 'true');
document.body.append(canvas);
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) { canvas.remove(); resolve(); return; }
let width = 0;
let height = 0;
let ratio = 1;
function size() {
width = window.innerWidth;
height = window.innerHeight;
ratio = Math.min(window.devicePixelRatio || 1, 1.7);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
}
size();
const particles = [];
const rand = function (a, b) { return a + Math.random() * (b - a); };
const smooth = function (x) { return x * x * (3 - 2 * x); };
let previous = performance.now();
let start = previous;
let done = false;
function spawn(x, y) {
if (particles.length > 1300) return;
const r = Math.random();
const kind = burn ? (r < .64 ? 'flame' : r < .84 ? 'ember' : 'smoke') : 'blue';
const life = kind === 'smoke' ? rand(.6, 1.4) : kind === 'ember' ? rand(.42, 1.1) : rand(.28, .75);
particles.push({
kind: kind,
x: x + rand(-13, 13),
y: y + rand(-14, 14),
vx: rand(burn ? -125 : -55, burn ? 55 : 85),
vy: rand(-185, -35),
life: life,
remaining: life,
radius: kind === 'smoke' ? rand(32, 85) : kind === 'ember' ? rand(3, 12) : rand(16, 47),
angle: rand(-.4, .4)
});
}
function render(now) {
if (done) return;
const delta = Math.min(.05, Math.max(0, (now - previous) / 1000));
previous = now;
const raw = Math.min(1, (now - start) / DURATION);
const eased = smooth(raw);
const center = burn
? (width + 100) - (width + 200) * eased
: -100 + (width + 200) * eased;
const waviness = Math.sin(Math.PI * raw);
function edgeX(y) {
return center + waviness * (
16 * Math.sin(y * .023 + now * .0055) +
9 * Math.sin(y * .063 - now * .009) +
5 * Math.sin(y * .13 + now * .012)
);
}
const pts = [];
const count = Math.max(28, Math.ceil(height / 25));
for (let i = 0; i <= count; i++) {
const y = height * i / count;
pts.push([edgeX(y), y]);
}
const reverse = pts.slice().reverse().map(function (p) {
return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px';
}).join(',');
preview.style.clipPath = burn
? 'polygon(100% 0,100% 100%,' + reverse + ')'
: 'polygon(0 0,0 100%,' + reverse + ')';
ctx.clearRect(0, 0, width, height);
// Smoke first, so bright fire and sparks appear in front.
ctx.globalCompositeOperation = 'source-over';
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.remaining -= delta;
if (p.remaining <= 0) { particles.splice(i, 1); continue; }
p.x += p.vx * delta;
p.y += p.vy * delta;
p.vy -= 18 * delta;
if (p.kind !== 'smoke') continue;
const opacity = Math.min(1, p.remaining / .35) * .42;
const size = p.radius * (1 + (1 - p.remaining / p.life) * .8);
ctx.globalAlpha = opacity;
ctx.drawImage(SPRITES.smoke, p.x - size, p.y - size, size * 2, size * 2);
}
ctx.globalAlpha = 1;
// The irregular, continuously moving combustion front.
ctx.beginPath();
pts.forEach(function (p, i) {
if (i === 0) ctx.moveTo(p[0], p[1]);
else ctx.lineTo(p[0], p[1]);
});
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.shadowColor = burn ? '#ff3905' : '#b1eaff';
ctx.shadowBlur = burn ? 48 : 55;
ctx.strokeStyle = burn ? 'rgba(255,41,5,.82)' : 'rgba(160,227,255,.72)';
ctx.lineWidth = burn ? 29 : 35;
ctx.stroke();
ctx.shadowBlur = 21;
ctx.strokeStyle = burn ? '#ff9d1c' : '#e5fbff';
ctx.lineWidth = burn ? 9 : 11;
ctx.stroke();
ctx.shadowBlur = 11;
ctx.strokeStyle = '#fff9e2';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.shadowBlur = 0;
// Actual flame tongues curling into the page that is burning.
// Layered Bezier shapes prevent this from looking like a flat wipe.
if (burn) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let y = -20; y < height + 45; y += 31) {
const x = edgeX(y);
const flicker = Math.sin(now * .014 + y * .19);
const lift = 24 + 29 * (1 + flicker) + 22 * Math.abs(Math.sin(now * .006 - y * .1));
const lean = 15 + 12 * Math.sin(now * .009 + y * .075);
ctx.fillStyle = 'rgba(255,77,5,.39)';
ctx.beginPath();
ctx.moveTo(x + 13, y + 15);
ctx.bezierCurveTo(x - 17, y - 2, x - lean - 25, y - lift * .62, x - lean, y - lift);
ctx.bezierCurveTo(x - 2, y - lift * .56, x + 11, y - 13, x + 17, y + 15);
ctx.closePath();
ctx.fill();
ctx.fillStyle = 'rgba(255,220,91,.43)';
ctx.beginPath();
ctx.moveTo(x + 3, y + 7);
ctx.quadraticCurveTo(x - 10, y - lift * .28, x - lean * .6, y - lift * .68);
ctx.quadraticCurveTo(x + 3, y - lift * .3, x + 7, y + 7);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
// Live flame tongues / sparks, not an animated screenshot.
const amount = Math.max(14, Math.min(34, Math.round(height / 41)));
for (let i = 0; i < amount; i++) {
const y = rand(0, height);
spawn(edgeX(y), y);
}
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
if (p.kind === 'smoke') continue;
const stage = 1 - p.remaining / p.life;
const fade = Math.min(1, p.remaining * 3) * (1 - stage * .5);
ctx.save();
ctx.globalAlpha = fade * (p.kind === 'ember' ? .85 : .8);
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
const sprite = p.kind === 'blue' ? SPRITES.blue :
p.kind === 'ember' ? SPRITES.ember : SPRITES.flame;
const sx = p.radius * (p.kind === 'flame' ? .9 : 1);
const sy = p.radius * (p.kind === 'flame' ? 1.7 : 1);
ctx.drawImage(sprite, -sx, -sy, sx * 2, sy * 2);
ctx.restore();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
if (raw < 1) {
requestAnimationFrame(render);
} else {
done = true;
preview.style.clipPath = 'none';
setTimeout(resolve, 110);
}
}
requestAnimationFrame(render);
});
}
function handoff() {
// A very short opaque frame hides the last real document navigation.
// The preview is already the actual target URL, not mock Wiki text.
const cover = document.createElement('div');
cover.id = 'kkpt-handoff';
if (!burn) cover.className = 'kkpt-sky';
document.body.append(cover);
requestAnimationFrame(function () {
cover.style.opacity = '1';
setTimeout(function () { window.location.assign(targetUrl); }, 200);
});
}
async function begin() {
const preview = await preparePreview();
await runAnimation(preview);
handoff();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', makeButton, { once: true });
} else {
makeButton();
}
}());