미디어위키:ManifestoIntro.js: 두 판 사이의 차이

(Install package: clbiwiki-mainpage-manifesto-integer-grid-fix-20260713 / js/ManifestoIntro.js)
(Install package: clbiwiki-mainpage-manifesto-emergency-restore-20260713 / js/ManifestoIntro.js)
8번째 줄: 8번째 줄:
실제 HTML 텍스트를 유지한 채 제목, 구분선, 본문 행을 순서대로 연다.
실제 HTML 텍스트를 유지한 채 제목, 구분선, 본문 행을 순서대로 연다.
이미지, canvas, 글자 래스터화는 사용하지 않는다.
이미지, canvas, 글자 래스터화는 사용하지 않는다.
픽셀 폰트 글리프에는 transform을 적용하지 않고 정수 CSS px만 사용한다.
*/
*/


14번째 줄: 13번째 줄:
     'use strict';
     'use strict';


    var VERSION = '20260713-procedural-004-integer-grid';
     var PORTAL_SELECTOR = '.main-portal';
     var PORTAL_SELECTOR = '.main-portal';
     var TITLE_SELECTOR = '.main-manifesto-title';
     var TITLE_SELECTOR = '.main-manifesto-title';
27번째 줄: 25번째 줄:
     var LINE_DURATION = 230;
     var LINE_DURATION = 230;
     var LINE_HEIGHT = 26;
     var LINE_HEIGHT = 26;
    var INNER_WIDTH = 660;
    var COPY_WIDTH = 620;
     var portalStates = typeof WeakMap === 'function' ? new WeakMap() : null;
     var portalStates = typeof WeakMap === 'function' ? new WeakMap() : null;
     var observer = null;
     var observer = null;
    var resizeTimer = 0;


     function findPortal(root) {
     function findPortal(root) {
81번째 줄: 76번째 줄:


         return Promise.race([load, timeout]);
         return Promise.race([load, timeout]);
    }
    function snapManifestoToIntegerGrid(portal) {
        var manifesto = portal.querySelector('.main-manifesto');
        var inner = portal.querySelector('.main-manifesto-inner');
        var copy = portal.querySelector(COPY_SELECTOR);
        var available;
        var left;
        if (!manifesto || !inner || !copy) return;
        available = Math.floor(manifesto.clientWidth || manifesto.getBoundingClientRect().width || 0);
        left = available > INNER_WIDTH ? Math.round((available - INNER_WIDTH) / 2) : 0;
        manifesto.style.setProperty('justify-content', 'flex-start');
        inner.style.setProperty('margin', '0', 'important');
        inner.style.setProperty('margin-left', left + 'px', 'important');
        inner.style.setProperty('transform', 'none', 'important');
        copy.style.setProperty('margin', '0', 'important');
        copy.style.setProperty('margin-left', Math.round((INNER_WIDTH - COPY_WIDTH) / 2) + 'px', 'important');
        copy.style.setProperty('transform', 'none', 'important');
     }
     }


135번째 줄: 108번째 줄:
             var top = Math.round(token.getBoundingClientRect().top);
             var top = Math.round(token.getBoundingClientRect().top);


             if (!current || current.top !== top) {
             if (!current || Math.abs(current.top - top) > 1) {
                 current = { top: top, words: [] };
                 current = { top: top, words: [] };
                 rows.push(current);
                 rows.push(current);
197번째 줄: 170번째 줄:
             }
             }
         });
         });
    }
    function ensureTitleText(title) {
        var existing = title.querySelector(':scope > .manifesto-title-text');
        var divider = title.querySelector(':scope > .manifesto-divider');
        var wrapper;
        var nodes;
        if (existing) return existing;
        wrapper = document.createElement('span');
        wrapper.className = 'manifesto-title-text';
        nodes = Array.prototype.slice.call(title.childNodes);
        nodes.forEach(function (node) {
            if (node !== divider) wrapper.appendChild(node);
        });
        if (divider) {
            title.insertBefore(wrapper, divider);
        } else {
            title.appendChild(wrapper);
        }
        return wrapper;
     }
     }


     function ensureDivider(title) {
     function ensureDivider(title) {
         var divider = title.querySelector(':scope > .manifesto-divider');
         var divider = title.querySelector('.manifesto-divider');
        var left;
        var right;


         if (!divider) {
         if (!divider) {
236번째 줄: 182번째 줄:
         }
         }


        left = divider.querySelector('.manifesto-divider-left');
         return divider;
        right = divider.querySelector('.manifesto-divider-right');
 
        if (!left || !right) {
            divider.textContent = '';
            left = document.createElement('span');
            right = document.createElement('span');
            left.className = 'manifesto-divider-half manifesto-divider-left';
            right.className = 'manifesto-divider-half manifesto-divider-right';
            divider.appendChild(left);
            divider.appendChild(right);
        }
 
         return {
            root: divider,
            left: left,
            right: right
        };
     }
     }


     function applyInitialFrame(portal, titleText, copy, divider, lines) {
     function applyInitialFrame(portal, title, copy, divider, lines) {
         portal.classList.remove('manifesto-intro-complete');
         portal.classList.remove('manifesto-intro-complete');
         portal.classList.add('manifesto-intro-prepared');
         portal.classList.add('manifesto-intro-prepared');
         document.documentElement.classList.add('manifesto-intro-active');
         document.documentElement.classList.add('manifesto-intro-active');


         titleText.style.opacity = '0';
         title.style.opacity = '0';
         divider.left.style.width = '0px';
         divider.style.left = '50%';
         divider.right.style.width = '0px';
        divider.style.width = '0px';
         divider.style.transform = 'none';


         lines.forEach(function (line) {
         lines.forEach(function (line) {
270번째 줄: 200번째 줄:
             line.style.height = '0px';
             line.style.height = '0px';
             line.style.maxHeight = '0px';
             line.style.maxHeight = '0px';
            line.style.opacity = '0';
             line.style.overflow = 'hidden';
             line.style.overflow = 'hidden';
            line.style.opacity = '1';
             line.style.clipPath = 'inset(0 0 100% 0)';
             line.style.clipPath = 'none';


             if (inner) {
             if (inner) {
                 inner.style.opacity = '1';
                 inner.style.opacity = '0.18';
                 inner.style.transform = 'none';
                 inner.style.transform = 'translateY(-10px)';
             }
             }
         });
         });
282번째 줄: 212번째 줄:
         copy.style.visibility = 'visible';
         copy.style.visibility = 'visible';


        /* 초기 프레임을 실제 렌더 트리에 확정한 뒤 pending을 해제한다. */
         portal.getBoundingClientRect();
         portal.getBoundingClientRect();
         lines.forEach(function (line) { line.getBoundingClientRect(); });
         lines.forEach(function (line) { line.getBoundingClientRect(); });
287번째 줄: 218번째 줄:
     }
     }


     function easeOutCubic(value) {
     function finishAnimation(animation, element, finalStyles) {
         var inverse = 1 - value;
         if (!animation || !animation.finished) return Promise.resolve();
        return 1 - (inverse * inverse * inverse);
    }


    function animateFrames(delay, duration, update, complete) {
         return animation.finished.catch(function () {
         return new Promise(function (resolve) {
             return null;
             window.setTimeout(function () {
        }).then(function () {
                var start = window.performance && performance.now ? performance.now() : Date.now();
            Object.keys(finalStyles).forEach(function (property) {
 
                element.style[property] = finalStyles[property];
                function frame(now) {
            });
                    var current = typeof now === 'number' ? now : Date.now();
            animation.cancel();
                    var progress = Math.max(0, Math.min(1, (current - start) / duration));
 
                    update(easeOutCubic(progress), progress);
 
                    if (progress < 1) {
                        window.requestAnimationFrame(frame);
                    } else {
                        if (complete) complete();
                        resolve();
                    }
                }
 
                window.requestAnimationFrame(frame);
            }, delay);
         });
         });
     }
     }


     function animateTitle(titleText) {
     function animateTitle(title) {
         return animateFrames(TITLE_DELAY, TITLE_DURATION, function (eased) {
         var animation = title.animate([
             titleText.style.opacity = String(Math.round(eased * 1000) / 1000);
            { opacity: 0 },
        }, function () {
            { opacity: 1 }
             titleText.style.opacity = '1';
        ], {
            duration: TITLE_DURATION,
             delay: TITLE_DELAY,
            easing: 'cubic-bezier(.18,.72,.24,1)',
             fill: 'both'
         });
         });
        return finishAnimation(animation, title, { opacity: '1' });
     }
     }


     function animateDivider(divider) {
     function animateDivider(divider) {
         var total = Math.max(0, Math.round(divider.root.getBoundingClientRect().width));
        /*
         var leftMax = Math.floor(total / 2);
        완성된 선을 미리 깔고 scale로 채우지 않는다.
        var rightMax = total - leftMax;
        실제 선 요소의 좌표와 폭 자체를 중앙 0px에서 좌우 끝까지 늘린다.
        */
         var animation = divider.animate([
            { left: '50%', width: '0px' },
            { left: '0%', width: '100%' }
        ], {
            duration: DIVIDER_DURATION,
            delay: DIVIDER_DELAY,
            easing: 'cubic-bezier(.2,.78,.22,1)',
            fill: 'both'
         });


         return animateFrames(DIVIDER_DELAY, DIVIDER_DURATION, function (eased) {
         return finishAnimation(animation, divider, {
             divider.left.style.width = Math.round(leftMax * eased) + 'px';
             left: '0%',
             divider.right.style.width = Math.round(rightMax * eased) + 'px';
             width: '100%',
        }, function () {
             transform: 'none'
            divider.left.style.width = leftMax + 'px';
             divider.right.style.width = rightMax + 'px';
         });
         });
     }
     }
340번째 줄: 269번째 줄:
     function animateLine(line, index) {
     function animateLine(line, index) {
         var delay = BODY_DELAY + (index * LINE_STAGGER);
         var delay = BODY_DELAY + (index * LINE_STAGGER);
        var inner = line.querySelector('.manifesto-line-inner');
        var outerAnimation = line.animate([
            {
                height: '0px',
                maxHeight: '0px',
                opacity: 0,
                clipPath: 'inset(0 0 100% 0)'
            },
            {
                offset: 0.42,
                height: '11px',
                maxHeight: '11px',
                opacity: 0.56,
                clipPath: 'inset(0 0 58% 0)'
            },
            {
                height: LINE_HEIGHT + 'px',
                maxHeight: LINE_HEIGHT + 'px',
                opacity: 1,
                clipPath: 'inset(0 0 0 0)'
            }
        ], {
            duration: LINE_DURATION,
            delay: delay,
            easing: 'cubic-bezier(.16,.76,.22,1)',
            fill: 'both'
        });
        var promises = [finishAnimation(outerAnimation, line, {
            height: LINE_HEIGHT + 'px',
            maxHeight: LINE_HEIGHT + 'px',
            opacity: '1',
            overflow: 'visible',
            clipPath: 'inset(0 0 0 0)'
        })];
        if (inner) {
            var innerAnimation = inner.animate([
                { transform: 'translateY(-10px)', opacity: 0.18 },
                { offset: 0.5, transform: 'translateY(-3px)', opacity: 0.72 },
                { transform: 'translateY(0)', opacity: 1 }
            ], {
                duration: LINE_DURATION,
                delay: delay,
                easing: 'cubic-bezier(.16,.76,.22,1)',
                fill: 'both'
            });
            promises.push(finishAnimation(innerAnimation, inner, {
                transform: 'translateY(0)',
                opacity: '1'
            }));
        }


         return animateFrames(delay, LINE_DURATION, function (eased) {
         return Promise.all(promises);
            var height = Math.round(LINE_HEIGHT * eased);
            line.style.height = height + 'px';
            line.style.maxHeight = height + 'px';
        }, function () {
            line.style.height = LINE_HEIGHT + 'px';
            line.style.maxHeight = LINE_HEIGHT + 'px';
            line.style.overflow = 'visible';
        });
     }
     }


355번째 줄: 328번째 줄:
         var title = portal && portal.querySelector(TITLE_SELECTOR);
         var title = portal && portal.querySelector(TITLE_SELECTOR);
         var copy = portal && portal.querySelector(COPY_SELECTOR);
         var copy = portal && portal.querySelector(COPY_SELECTOR);
        var titleText;
        var divider;
        var total;
        var leftMax;
        var rightMax;


         if (title) {
         if (title) {
             titleText = ensureTitleText(title);
             title.style.opacity = '1';
            divider = ensureDivider(title);
             title.style.borderBottomColor = '';
            titleText.style.opacity = '1';
             total = Math.max(0, Math.round(title.getBoundingClientRect().width));
            leftMax = Math.floor(total / 2);
            rightMax = total - leftMax;
            divider.left.style.width = leftMax + 'px';
            divider.right.style.width = rightMax + 'px';
         }
         }
         if (copy) copy.style.visibility = 'visible';
         if (copy) copy.style.visibility = 'visible';


         if (portal) {
         if (portal) {
            snapManifestoToIntegerGrid(portal);
             portal.classList.remove('manifesto-intro-prepared');
             portal.classList.remove('manifesto-intro-prepared');
             portal.classList.add('manifesto-intro-complete');
             portal.classList.add('manifesto-intro-complete');
             setState(portal, 'complete');
             setState(portal, 'complete');
             portal.setAttribute('data-manifesto-intro-played', '1');
             portal.setAttribute('data-manifesto-intro-played', '1');
            portal.setAttribute('data-manifesto-intro-version', VERSION);
         }
         }


388번째 줄: 348번째 줄:
         var title = portal.querySelector(TITLE_SELECTOR);
         var title = portal.querySelector(TITLE_SELECTOR);
         var copy = portal.querySelector(COPY_SELECTOR);
         var copy = portal.querySelector(COPY_SELECTOR);
         var titleText;
         var lines;
         var divider;
         var divider;
        var lines;


         if (!title || !copy) return null;
         if (!title || !copy) return null;


         try {
         try {
             snapManifestoToIntegerGrid(portal);
             lines = buildRenderedLines(copy);
            titleText = ensureTitleText(title);
             divider = ensureDivider(title);
             divider = ensureDivider(title);
            lines = buildRenderedLines(copy);
         } catch (error) {
         } catch (error) {
             restoreOriginalText(copy);
             restoreOriginalText(copy);
404번째 줄: 361번째 줄:
         }
         }


         if (!lines.length || !divider || !titleText) {
         if (!lines.length || !divider) {
             restoreOriginalText(copy);
             restoreOriginalText(copy);
             return null;
             return null;
         }
         }


         applyInitialFrame(portal, titleText, copy, divider, lines);
         applyInitialFrame(portal, title, copy, divider, lines);


         return {
         return {
             titleText: titleText,
             title: title,
             copy: copy,
             copy: copy,
             divider: divider,
             divider: divider,
424번째 줄: 381번째 줄:
         setState(portal, 'running');
         setState(portal, 'running');
         portal.setAttribute('data-manifesto-intro-played', '1');
         portal.setAttribute('data-manifesto-intro-played', '1');
        portal.setAttribute('data-manifesto-intro-version', VERSION);


         animations.push(animateTitle(prepared.titleText));
         animations.push(animateTitle(prepared.title));
         animations.push(animateDivider(prepared.divider));
         animations.push(animateDivider(prepared.divider));


443번째 줄: 399번째 줄:


     function begin(portal) {
     function begin(portal) {
        var state;
         if (!portal || !portal.isConnected) return Promise.resolve(false);
         if (!portal || !portal.isConnected) return Promise.resolve(false);


         if (portal.getAttribute('data-manifesto-intro-version') !== VERSION) {
         var state = getState(portal);
            portal.removeAttribute('data-manifesto-intro-played');
            portal.removeAttribute('data-manifesto-intro-state');
            portal.classList.remove('manifesto-intro-prepared', 'manifesto-intro-running', 'manifesto-intro-complete');
            if (portalStates) portalStates.delete(portal);
        }
 
        state = getState(portal);
         if (state === 'preparing' || state === 'running' || state === 'complete') {
         if (state === 'preparing' || state === 'running' || state === 'complete') {
             return Promise.resolve(false);
             return Promise.resolve(false);
         }
         }


         if (prefersReducedMotion()) {
         if (prefersReducedMotion() || typeof Element.prototype.animate !== 'function') {
             showImmediately(portal);
             showImmediately(portal);
             return Promise.resolve(false);
             return Promise.resolve(false);
467번째 줄: 414번째 줄:


         return waitForFont().then(function () {
         return waitForFont().then(function () {
            var prepared;
             if (!portal.isConnected) return false;
             if (!portal.isConnected) return false;


             prepared = prepare(portal);
             var prepared = prepare(portal);
             if (!prepared) {
             if (!prepared) {
                 showImmediately(portal);
                 showImmediately(portal);
498번째 줄: 443번째 줄:
         var portal = findPortal(root);
         var portal = findPortal(root);


         if (!portal) return Promise.resolve(false);
         if (!portal) {
            return Promise.resolve(false);
        }
 
         return begin(portal);
         return begin(portal);
     }
     }
507번째 줄: 455번째 줄:
         observer = new MutationObserver(function () {
         observer = new MutationObserver(function () {
             var portal = document.querySelector(PORTAL_SELECTOR);
             var portal = document.querySelector(PORTAL_SELECTOR);
             if (portal) init(portal);
             if (!portal) return;
            init(portal);
         });
         });


535번째 줄: 484번째 줄:
     window.addEventListener('pageshow', function () {
     window.addEventListener('pageshow', function () {
         boot(document);
         boot(document);
    });
    window.addEventListener('resize', function () {
        window.clearTimeout(resizeTimer);
        resizeTimer = window.setTimeout(function () {
            var portal = document.querySelector(PORTAL_SELECTOR);
            if (portal) snapManifestoToIntegerGrid(portal);
        }, 80);
     });
     });


552번째 줄: 493번째 줄:


     window.ManifestoIntro = {
     window.ManifestoIntro = {
         version: VERSION,
         version: '20260713-procedural-restore-001',
         init: init,
         init: init,
         status: function () {
         status: function () {
             var portal = document.querySelector(PORTAL_SELECTOR);
             var portal = document.querySelector(PORTAL_SELECTOR);
            var title = portal && portal.querySelector(TITLE_SELECTOR);
            var copy = portal && portal.querySelector(COPY_SELECTOR);
            var titleRect = title ? title.getBoundingClientRect() : null;
            var copyRect = copy ? copy.getBoundingClientRect() : null;
             return {
             return {
                 loaded: true,
                 loaded: true,
                version: VERSION,
                 portal: !!portal,
                 portal: !!portal,
                 state: portal ? getState(portal) : '',
                 state: portal ? getState(portal) : '',
569번째 줄: 504번째 줄:
                 running: !!(portal && portal.classList.contains('manifesto-intro-running')),
                 running: !!(portal && portal.classList.contains('manifesto-intro-running')),
                 complete: !!(portal && portal.classList.contains('manifesto-intro-complete')),
                 complete: !!(portal && portal.classList.contains('manifesto-intro-complete')),
                 lineCount: portal ? portal.querySelectorAll('.manifesto-line').length : 0,
                 lineCount: portal ? portal.querySelectorAll('.manifesto-line').length : 0
                titleX: titleRect ? titleRect.x : null,
                copyX: copyRect ? copyRect.x : null,
                titleXIsInteger: titleRect ? Number.isInteger(titleRect.x) : null,
                copyXIsInteger: copyRect ? Number.isInteger(copyRect.x) : null
             };
             };
         }
         }
     };
     };
}(window, document, window.mw));
}(window, document, window.mw));

2026년 7월 13일 (월) 06:57 판

/* =========================================
ManifestoIntro
대문 선언문 절차적 진입 애니메이션
========================================= */

/*
새 기능과 공개 이름에는 프로젝트 접두사를 사용하지 않는다.
실제 HTML 텍스트를 유지한 채 제목, 구분선, 본문 행을 순서대로 연다.
이미지, canvas, 글자 래스터화는 사용하지 않는다.
*/

(function (window, document, mw) {
    'use strict';

    var PORTAL_SELECTOR = '.main-portal';
    var TITLE_SELECTOR = '.main-manifesto-title';
    var COPY_SELECTOR = '.main-manifesto-copy';
    var FONT_QUERY = '18px "NanumMyeongjoBold Pixel 18"';
    var TITLE_DELAY = 50;
    var TITLE_DURATION = 420;
    var DIVIDER_DELAY = 190;
    var DIVIDER_DURATION = 500;
    var BODY_DELAY = DIVIDER_DELAY + DIVIDER_DURATION + 30;
    var LINE_STAGGER = 64;
    var LINE_DURATION = 230;
    var LINE_HEIGHT = 26;
    var portalStates = typeof WeakMap === 'function' ? new WeakMap() : null;
    var observer = null;

    function findPortal(root) {
        var scope = root && root.querySelector ? root : document;

        if (scope.matches && scope.matches(PORTAL_SELECTOR)) {
            return scope;
        }

        return scope.querySelector(PORTAL_SELECTOR) || document.querySelector(PORTAL_SELECTOR);
    }

    function prefersReducedMotion() {
        return !!(
            window.matchMedia &&
            window.matchMedia('(prefers-reduced-motion: reduce)').matches
        );
    }

    function getState(portal) {
        if (portalStates) return portalStates.get(portal) || '';
        return portal.getAttribute('data-manifesto-intro-state') || '';
    }

    function setState(portal, state) {
        if (portalStates) portalStates.set(portal, state);
        portal.setAttribute('data-manifesto-intro-state', state);
    }

    function disarmPending() {
        document.documentElement.classList.remove('manifesto-intro-pending');
        document.documentElement.classList.remove('manifesto-intro-active');
    }

    function waitForFont() {
        if (!document.fonts || typeof document.fonts.load !== 'function') {
            return Promise.resolve();
        }

        var timeout = new Promise(function (resolve) {
            window.setTimeout(resolve, 1400);
        });

        var load = document.fonts.load(FONT_QUERY).then(function () {
            return document.fonts.ready;
        }).catch(function () {
            return null;
        });

        return Promise.race([load, timeout]);
    }

    function tokenizeParagraph(paragraph) {
        var text = String(paragraph.textContent || '').replace(/\s+/g, ' ').trim();
        var words = text ? text.split(' ') : [];

        paragraph.textContent = '';
        paragraph.setAttribute('data-manifesto-original-text', text);

        words.forEach(function (word, index) {
            var token = document.createElement('span');
            token.className = 'manifesto-measure-token';
            token.textContent = word;
            paragraph.appendChild(token);

            if (index < words.length - 1) {
                paragraph.appendChild(document.createTextNode(' '));
            }
        });

        return Array.prototype.slice.call(
            paragraph.querySelectorAll('.manifesto-measure-token')
        );
    }

    function groupTokensIntoRows(tokens) {
        var rows = [];
        var current = null;

        tokens.forEach(function (token) {
            var top = Math.round(token.getBoundingClientRect().top);

            if (!current || Math.abs(current.top - top) > 1) {
                current = { top: top, words: [] };
                rows.push(current);
            }

            current.words.push(token.textContent);
        });

        return rows.map(function (row) {
            return row.words.join(' ');
        });
    }

    function createLine(text, index) {
        var line = document.createElement('span');
        var inner = document.createElement('span');

        line.className = 'manifesto-line';
        line.setAttribute('data-manifesto-line-index', String(index));

        inner.className = 'manifesto-line-inner';
        inner.textContent = text;

        line.appendChild(inner);
        return line;
    }

    function buildRenderedLines(copy) {
        var paragraphs = Array.prototype.slice.call(copy.querySelectorAll('p'));
        var lines = [];
        var lineIndex = 0;

        if (!paragraphs.length) return lines;

        copy.classList.add('manifesto-line-measuring');

        paragraphs.forEach(function (paragraph) {
            var tokens = tokenizeParagraph(paragraph);
            var rows = groupTokensIntoRows(tokens);

            paragraph.textContent = '';

            rows.forEach(function (text) {
                var line = createLine(text, lineIndex);
                paragraph.appendChild(line);
                lines.push(line);
                lineIndex += 1;
            });
        });

        copy.classList.remove('manifesto-line-measuring');
        return lines;
    }

    function restoreOriginalText(copy) {
        Array.prototype.forEach.call(copy.querySelectorAll('p'), function (paragraph) {
            var original = paragraph.getAttribute('data-manifesto-original-text');
            if (original !== null) {
                paragraph.textContent = original;
                paragraph.removeAttribute('data-manifesto-original-text');
            }
        });
    }

    function ensureDivider(title) {
        var divider = title.querySelector('.manifesto-divider');

        if (!divider) {
            divider = document.createElement('span');
            divider.className = 'manifesto-divider';
            divider.setAttribute('aria-hidden', 'true');
            title.appendChild(divider);
        }

        return divider;
    }

    function applyInitialFrame(portal, title, copy, divider, lines) {
        portal.classList.remove('manifesto-intro-complete');
        portal.classList.add('manifesto-intro-prepared');
        document.documentElement.classList.add('manifesto-intro-active');

        title.style.opacity = '0';
        divider.style.left = '50%';
        divider.style.width = '0px';
        divider.style.transform = 'none';

        lines.forEach(function (line) {
            var inner = line.querySelector('.manifesto-line-inner');

            line.style.height = '0px';
            line.style.maxHeight = '0px';
            line.style.opacity = '0';
            line.style.overflow = 'hidden';
            line.style.clipPath = 'inset(0 0 100% 0)';

            if (inner) {
                inner.style.opacity = '0.18';
                inner.style.transform = 'translateY(-10px)';
            }
        });

        copy.style.visibility = 'visible';

        /* 초기 프레임을 실제 렌더 트리에 확정한 뒤 pending을 해제한다. */
        portal.getBoundingClientRect();
        lines.forEach(function (line) { line.getBoundingClientRect(); });
        document.documentElement.classList.remove('manifesto-intro-pending');
    }

    function finishAnimation(animation, element, finalStyles) {
        if (!animation || !animation.finished) return Promise.resolve();

        return animation.finished.catch(function () {
            return null;
        }).then(function () {
            Object.keys(finalStyles).forEach(function (property) {
                element.style[property] = finalStyles[property];
            });
            animation.cancel();
        });
    }

    function animateTitle(title) {
        var animation = title.animate([
            { opacity: 0 },
            { opacity: 1 }
        ], {
            duration: TITLE_DURATION,
            delay: TITLE_DELAY,
            easing: 'cubic-bezier(.18,.72,.24,1)',
            fill: 'both'
        });

        return finishAnimation(animation, title, { opacity: '1' });
    }

    function animateDivider(divider) {
        /*
        완성된 선을 미리 깔고 scale로 채우지 않는다.
        실제 선 요소의 좌표와 폭 자체를 중앙 0px에서 좌우 끝까지 늘린다.
        */
        var animation = divider.animate([
            { left: '50%', width: '0px' },
            { left: '0%', width: '100%' }
        ], {
            duration: DIVIDER_DURATION,
            delay: DIVIDER_DELAY,
            easing: 'cubic-bezier(.2,.78,.22,1)',
            fill: 'both'
        });

        return finishAnimation(animation, divider, {
            left: '0%',
            width: '100%',
            transform: 'none'
        });
    }

    function animateLine(line, index) {
        var delay = BODY_DELAY + (index * LINE_STAGGER);
        var inner = line.querySelector('.manifesto-line-inner');
        var outerAnimation = line.animate([
            {
                height: '0px',
                maxHeight: '0px',
                opacity: 0,
                clipPath: 'inset(0 0 100% 0)'
            },
            {
                offset: 0.42,
                height: '11px',
                maxHeight: '11px',
                opacity: 0.56,
                clipPath: 'inset(0 0 58% 0)'
            },
            {
                height: LINE_HEIGHT + 'px',
                maxHeight: LINE_HEIGHT + 'px',
                opacity: 1,
                clipPath: 'inset(0 0 0 0)'
            }
        ], {
            duration: LINE_DURATION,
            delay: delay,
            easing: 'cubic-bezier(.16,.76,.22,1)',
            fill: 'both'
        });
        var promises = [finishAnimation(outerAnimation, line, {
            height: LINE_HEIGHT + 'px',
            maxHeight: LINE_HEIGHT + 'px',
            opacity: '1',
            overflow: 'visible',
            clipPath: 'inset(0 0 0 0)'
        })];

        if (inner) {
            var innerAnimation = inner.animate([
                { transform: 'translateY(-10px)', opacity: 0.18 },
                { offset: 0.5, transform: 'translateY(-3px)', opacity: 0.72 },
                { transform: 'translateY(0)', opacity: 1 }
            ], {
                duration: LINE_DURATION,
                delay: delay,
                easing: 'cubic-bezier(.16,.76,.22,1)',
                fill: 'both'
            });

            promises.push(finishAnimation(innerAnimation, inner, {
                transform: 'translateY(0)',
                opacity: '1'
            }));
        }

        return Promise.all(promises);
    }

    function showImmediately(portal) {
        var title = portal && portal.querySelector(TITLE_SELECTOR);
        var copy = portal && portal.querySelector(COPY_SELECTOR);

        if (title) {
            title.style.opacity = '1';
            title.style.borderBottomColor = '';
        }
        if (copy) copy.style.visibility = 'visible';

        if (portal) {
            portal.classList.remove('manifesto-intro-prepared');
            portal.classList.add('manifesto-intro-complete');
            setState(portal, 'complete');
            portal.setAttribute('data-manifesto-intro-played', '1');
        }

        disarmPending();
    }

    function prepare(portal) {
        var title = portal.querySelector(TITLE_SELECTOR);
        var copy = portal.querySelector(COPY_SELECTOR);
        var lines;
        var divider;

        if (!title || !copy) return null;

        try {
            lines = buildRenderedLines(copy);
            divider = ensureDivider(title);
        } catch (error) {
            restoreOriginalText(copy);
            return null;
        }

        if (!lines.length || !divider) {
            restoreOriginalText(copy);
            return null;
        }

        applyInitialFrame(portal, title, copy, divider, lines);

        return {
            title: title,
            copy: copy,
            divider: divider,
            lines: lines
        };
    }

    function play(portal, prepared) {
        var animations = [];

        setState(portal, 'running');
        portal.setAttribute('data-manifesto-intro-played', '1');

        animations.push(animateTitle(prepared.title));
        animations.push(animateDivider(prepared.divider));

        prepared.lines.forEach(function (line, index) {
            animations.push(animateLine(line, index));
        });

        return Promise.all(animations).then(function () {
            portal.classList.add('manifesto-intro-complete');
            portal.classList.remove('manifesto-intro-running');
            setState(portal, 'complete');
            document.documentElement.classList.remove('manifesto-intro-active');
            return true;
        });
    }

    function begin(portal) {
        if (!portal || !portal.isConnected) return Promise.resolve(false);

        var state = getState(portal);
        if (state === 'preparing' || state === 'running' || state === 'complete') {
            return Promise.resolve(false);
        }

        if (prefersReducedMotion() || typeof Element.prototype.animate !== 'function') {
            showImmediately(portal);
            return Promise.resolve(false);
        }

        setState(portal, 'preparing');

        return waitForFont().then(function () {
            if (!portal.isConnected) return false;

            var prepared = prepare(portal);
            if (!prepared) {
                showImmediately(portal);
                return false;
            }

            portal.classList.add('manifesto-intro-running');

            return new Promise(function (resolve) {
                window.requestAnimationFrame(function () {
                    window.requestAnimationFrame(function () {
                        play(portal, prepared).then(resolve).catch(function () {
                            showImmediately(portal);
                            resolve(false);
                        });
                    });
                });
            });
        }).catch(function () {
            showImmediately(portal);
            return false;
        });
    }

    function init(root) {
        var portal = findPortal(root);

        if (!portal) {
            return Promise.resolve(false);
        }

        return begin(portal);
    }

    function observeForPortal() {
        if (observer || !window.MutationObserver || !document.documentElement) return;

        observer = new MutationObserver(function () {
            var portal = document.querySelector(PORTAL_SELECTOR);
            if (!portal) return;
            init(portal);
        });

        observer.observe(document.documentElement, {
            childList: true,
            subtree: true
        });
    }

    function boot(root) {
        init(root || document);
        observeForPortal();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () {
            boot(document);
        }, { once: true });
    } else {
        boot(document);
    }

    window.addEventListener('load', function () {
        boot(document);
    }, { once: true });

    window.addEventListener('pageshow', function () {
        boot(document);
    });

    if (mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            boot($content && $content[0] ? $content[0] : document);
        });
    }

    window.ManifestoIntro = {
        version: '20260713-procedural-restore-001',
        init: init,
        status: function () {
            var portal = document.querySelector(PORTAL_SELECTOR);
            return {
                loaded: true,
                portal: !!portal,
                state: portal ? getState(portal) : '',
                prepared: !!(portal && portal.classList.contains('manifesto-intro-prepared')),
                running: !!(portal && portal.classList.contains('manifesto-intro-running')),
                complete: !!(portal && portal.classList.contains('manifesto-intro-complete')),
                lineCount: portal ? portal.querySelectorAll('.manifesto-line').length : 0
            };
        }
    };
}(window, document, window.mw));