미디어위키:MainPage.css: 두 판 사이의 차이

편집 요약 없음
편집 요약 없음
1번째 줄: 1번째 줄:
/* =========================================
/* =========================================
COASTLINE: BLACK ICE - CategoryNav
COASTLINE: BLACK ICE - MainPage
대문 카테고리 네비 SVG 생성기
대문 전용 스타일
========================================= */
========================================= */


7번째 줄: 7번째 줄:
설계 목적
설계 목적
-----------------------------------------
-----------------------------------------
이 파일은 대문 카테고리 네비를 MediaWiki 본문 위키문법이 아니라
이 파일은 대문 전용 화면 구성만 담당한다.
JavaScript + SVG로 생성한다.


왜 SVG를 쓰는가
이전 테스트 단계에서는 MediaWiki:Test.css 안에
-----------------------------------------
대문 레이아웃, 이미지 피드, 방명록 더미, 스타일 매뉴얼,
CSS 박스 기반 구현에서 다음 문제가 반복되었다.
카테고리 네비 실험 코드가 모두 섞여 있었다.


1. span 안에 [[문서|라벨]]을 넣는 방식
그 상태로 실사용에 넣으면 다음 문제가 생긴다.
  - MediaWiki가 내부에 <a>를 자동 생성한다.
  - span은 모양을 담당하고, a는 클릭을 담당하게 된다.
  - 실제 클릭 영역, 호버 영역, 시각 형태가 서로 분리된다.


2. <a class="...">를 본문에 직접 넣는 방식
1. 어느 코드가 대문 전용인지, 어느 코드가 공통 컴포넌트인지 구분하기 어렵다.
  - MediaWiki 본문에서 안정적으로 먹히지 않았다.
2. 일반 문서 네비바나 사이드바를 고칠 때 대문 코드가 같이 영향을 받을 수 있다.
  - 위키 문법 처리와 충돌해 코드가 무효화되는 문제가 있었다.
3. 테스트용 더미와 실제 운영 UI가 같은 파일에 남아 유지보수가 어려워진다.


3. clip-path + ::after 방식
따라서 이 파일은 대문 본문 안에서 쓰는
  - clip-path가 검은 획 pseudo-element까지 잘라먹었다.
.main-portal 스코프 내부만 담당한다.
  - 왼쪽 항목의 획이 사라지거나 일부만 보였다.
 
4. 버튼을 음수 margin으로 겹치는 방식
  - 사선 빈 영역은 메웠지만, 호버가 옆 칸으로 넘어갔다.
 
5. hover overlay / divider overlay 방식
  - CSS grid, absolute 좌표, 프로젝트 가변폭이 서로 다른 기준을 가져서
    호버 면과 검은 획이 계속 어긋났다.
 
SVG 방식의 장점
-----------------------------------------
SVG에서는 각 항목을 polygon으로 만든다.
따라서 다음 요소들이 모두 같은 좌표계를 사용한다.
 
- 보이는 버튼 면
- 실제 hover 면
- 실제 click 면
- 검은 구분선
 
즉, 이전처럼 사각형 박스를 사선처럼 보이게 흉내 내는 방식이 아니라,
처음부터 사선 polygon을 생성한다.
 
JS의 책임
-----------------------------------------
JS는 구조와 좌표만 만든다.
색상, 호버 색, 글자 스타일은 MainPage.css가 담당한다.


좌표는 현재 nav 폭을 읽어서 계산한다.
주의:
프로젝트 버튼은 좌우 카테고리 3개씩을 제외한 남은 중앙 폭을 차지한다.
- body, .liberty-content, .content-wrapper 같은 전역 레이아웃은 건드리지 않는다.
- 상단 공통 네비바, 좌우 사이드바, 관리자 패널은 이 파일의 책임이 아니다.
- 카테고리 네비는 JS가 SVG DOM을 생성하고, 이 파일은 그 결과물의 시각 스타일만 담당한다.
- 버튼 폭, 사선 좌표, 프로젝트 폭은 CSS가 아니라 CategoryNav.js가 계산한다.
*/
*/


(function () {
/* -----------------------------------------
    'use strict';
0. Main portal scope
----------------------------------------- */


    var SVG_NS = 'http://www.w3.org/2000/svg';
.main-portal {
    var XLINK_NS = 'http://www.w3.org/1999/xlink';
--surface-page:#070707;
--surface-frame:#101010;
--surface-frame-soft:#141414;
--surface-title:#1d1d1d;
--surface-title-hover:#252525;
--surface-well:#080808;


    /*
--edge-top:#555555;
    기본 카테고리 구성
--edge-side:#2b2b2b;
    -----------------------------------------
--edge-bottom:#050505;
    현재는 한국어 대문 기준이다.
--edge-soft:#202020;


    다국어 확장 시에는 여기의 label/title을 직접 바꾸기보다
--text-main:#e2e2e2;
    getLabel(), getTitle()에서 window.LANG, window.CAT_LINKS 또는
--text-soft:#c8c8c8;
    별도 MainPageConfig.js를 읽게 하면 된다.
--text-dim:#8a8a8a;
    */
--text-faint:#626262;


    var CATEGORY_NAV_ITEMS = [
--space-block:8px;
        {
--space-inner:6px;
            key: 'history',
--pad-panel:8px;
            label: '역사적 사건',
            title: '역사적_사건',
            type: 'left-start'
        },
        {
            key: 'settings',
            label: '설정',
            title: '설정',
            type: 'left-mid'
        },
        {
            key: 'nations',
            label: '국가 및 조합',
            title: '국가_및_조합',
            type: 'left-end'
        },
        {
            key: 'project',
            label: '프로젝트',
            title: '프로젝트:소개',
            type: 'project'
        },
        {
            key: 'corporations',
            label: '기업 및 공동체',
            title: '기업_및_공동체',
            type: 'right-start'
        },
        {
            key: 'military',
            label: '군, 정치집단',
            title: '군_정치집단',
            type: 'right-mid'
        },
        {
            key: 'personnel',
            label: '인물',
            title: '인물',
            type: 'right-end'
        }
    ];


    /*
--nav-h:30px;
    언어 감지
    -----------------------------------------
    기존 Common.js에 getCurrentLang() 함수가 있을 수 있다.
    이 파일은 단독으로도 깨지지 않아야 하므로 fallback을 둔다.
    */


    function getCurrentLanguageSafe() {
width:100%;
        if (typeof window.getCurrentLang === 'function') {
margin:0 auto;
            return window.getCurrentLang();
padding:var(--space-block);
        }
box-sizing:border-box;
background:var(--surface-page);
color:var(--text-main);
font-size:11px;
line-height:1.5;
}


        var langData = document.getElementById('clbi-lang-data');
.main-portal *,
.main-portal *::before,
.main-portal *::after {
box-sizing:border-box;
}


        if (langData) {
.main-portal a {
            return langData.getAttribute('data-lang') || 'ko';
color:var(--text-main) !important;
        }
text-decoration:none !important;
}


        return 'ko';
.main-portal a:hover {
    }
color:#ffffff !important;
text-decoration:none !important;
}


    /*
/* -----------------------------------------
    경로 생성
1. Category nav mount
    -----------------------------------------
----------------------------------------- */
    기존 Common.js에 buildWikiPath(title)가 있으면 그것을 사용한다.
    없으면 /index.php/문서명 형식으로 fallback한다.
    */


    function buildPath(title) {
/*
        if (typeof window.buildWikiPath === 'function') {
[data-component="category-nav"]는 본문에 남겨두는 자리 표시자다.
            return window.buildWikiPath(title);
        }


        return '/index.php/' + encodeURI(String(title || '').replace(/ /g, '_'));
본문에 위키문법으로 링크를 직접 조립하면,
    }
MediaWiki가 생성한 <a>와 바깥 span이 분리된다.
그 상태에서는 실제 클릭 영역, 호버 영역, 사선 모양이 서로 어긋난다.


    /*
따라서 본문에는 비어 있는 mount만 두고,
    라벨 선택
CategoryNav.js가 실제 SVG 네비를 생성한다.
    -----------------------------------------
*/
    현재는 한국어를 기준으로 둔다.


    기존 LANG.js와 CAT_LINKS에는 모든 새 항목이 정리되어 있지 않을 수 있다.
.main-portal [data-component="category-nav"] {
    예를 들어 '설정', '프로젝트'는 기존 세계관 카테고리 세트와 다르다.
display:block;
width:100%;
margin:0 0 var(--space-block);
}


    부분 번역을 섞으면 언어별 네비 폭과 의미가 흔들릴 수 있으므로,
/* -----------------------------------------
    다국어 구조가 확정되기 전까지는 fallback label을 유지한다.
2. Category navigation - SVG version
    */
----------------------------------------- */


    function getLabel(item, lang) {
/*
        if (lang === 'ko') {
왜 SVG로 바꿨는가
            return item.label;
-----------------------------------------
        }
CSS 박스 기반 구현에서는 계속 같은 문제가 생겼다.


        return item.label;
1. clip-path를 span에 걸면 MediaWiki가 생성한 <a>와
    }
  시각적 버튼이 분리된다.
2. ::after로 검은 획을 만들면 clip-path가 획까지 잘라먹는다.
3. 획과 호버를 별도 overlay로 만들면 좌표가 다시 어긋난다.
4. 음수 margin으로 버튼을 겹치면 호버가 옆 칸으로 넘어간다.


    /*
SVG 방식에서는 각 항목이 실제 polygon이다.
    좌표 계산
    -----------------------------------------
    이상적인 카테고리 폭은 188px이다.
    이전 기준은 163px이었고, 여기서 프로젝트를 제외한 6개 버튼의 폭을
    약 15% 늘린 값이다.


    163 * 1.15 = 187.45 이므로 렌더링 안정성을 위해 188px로 둔다.
즉,
- 보이는 면
- 호버되는 면
- 클릭되는 면
- 검은 구분 획


    다만 실제 본문 폭이 좁으면 188px 6개 + 프로젝트 최소폭이 들어가지 않는다.
이 모두가 같은 SVG 좌표계 안에서 처리된다.
    그럴 때는 cellW를 줄여서 전체가 깨지지 않게 한다.


    계산 원칙:
CSS의 책임
    - 좌측 3개와 우측 3개는 같은 폭이다.
-----------------------------------------
    - 프로젝트는 남은 중앙 폭을 차지한다.
이 파일은 색상, 호버, 텍스트 스타일, 외곽 표면만 담당한다.
    - 사선 cut 값은 cellW에 따라 너무 커지거나 작아지지 않게 제한한다.
버튼 폭과 사선 좌표는 CategoryNav.js가 계산한다.
    */


    function calculateGeometry(width) {
따라서 이 파일에는 --cat-cell-w, --cat-cell-2, --cat-cell-3 같은
        var h = 30;
좌표성 변수를 두지 않는다.
        var idealCell = 188;
*/
        var minCell = 96;
        var minProject = 180;
        var usableWidth = Math.max(320, width);


        var cellW = idealCell;
.portal-category-nav {
position:relative;
width:100%;
height:var(--nav-h);
margin:0;
padding:0;
overflow:hidden;


        if ((cellW * 6) + minProject > usableWidth) {
background:var(--surface-frame-soft);
            cellW = Math.floor((usableWidth - minProject) / 6);
border:1px solid;
            cellW = Math.max(minCell, cellW);
border-color:
        }
var(--edge-top)
var(--edge-side)
var(--edge-bottom)
var(--edge-side);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.055),
inset 0 -1px 0 rgba(0,0,0,0.72);
}


        var projectW = usableWidth - (cellW * 6);
/*
SVG는 JS가 현재 nav 폭을 읽어서 viewBox를 계산한다.
CSS는 svg의 표시 크기만 지정한다.
*/


        if (projectW < 100) {
.portal-category-svg {
            projectW = 100;
display:block;
        }
width:100%;
height:100%;
}


        /*
/*
        projectW를 보정했을 경우 전체 폭이 넘칠 수 있다.
각 항목의 polygon은 기본적으로 투명하다.
        그 상황에서는 viewBox를 전체 필요 폭으로 잡고,
표면은 부모 .portal-category-nav가 담당한다.
        SVG가 가로로 스케일되게 둔다.
        일반 대문 폭에서는 이 분기가 거의 발생하지 않는다.
        */
        var totalW = (cellW * 6) + projectW;


        var cut = Math.round(cellW * 0.061);
호버 시에만 아주 약한 밝기 레이어를 polygon에 입힌다.
        cut = Math.max(6, Math.min(10, cut));
이렇게 해야 독립 버튼 외장처럼 보이지 않는다.
*/


        var x0 = 0;
.portal-cat-shape {
        var x1 = cellW;
fill:transparent;
        var x2 = cellW * 2;
stroke:none;
        var x3 = cellW * 3;
pointer-events:all;
        var x4 = x3 + projectW;
}
        var x5 = x4 + cellW;
        var x6 = x4 + (cellW * 2);
        var x7 = x4 + (cellW * 3);


        return {
.portal-cat-anchor:hover .portal-cat-shape {
            width: totalW,
fill:rgba(255,255,255,0.035);
            height: h,
}
            cellW: cellW,
            projectW: projectW,
            cut: cut,
            x: [x0, x1, x2, x3, x4, x5, x6, x7]
        };
    }


    /*
.portal-cat-anchor:active .portal-cat-shape {
    polygon 좌표
fill:rgba(0,0,0,0.20);
    -----------------------------------------
}
    요구 형태:
    왼쪽군:  \ \ \
    프로젝트: \ PROJECT /
    오른쪽군: / / /


    좌표 설명:
/*
    - \ 경계는 위쪽 x가 왼쪽, 아래쪽 x가 오른쪽이다.
텍스트는 SVG 내부 text로 렌더링된다.
    - / 경계는 위쪽 x가 오른쪽, 아래쪽 x가 왼쪽이다.


    예시:
SVG text에는 일반 CSS text-shadow가 브라우저에 따라 안정적으로 먹지 않을 수 있다.
    왼쪽 첫 경계 \ :
그래서 CategoryNav.js에서 fill, stroke, stroke-width, paint-order를 직접 넣는다.
    top    x1 - cut
    bottom x1


    오른쪽 첫 경계 / :
여기서는 hover 색상과 기본 클래스 스타일을 보조한다.
    top    x4
*/
    bottom x4 - cut
    */


    function getSegmentPoints(index, g) {
.portal-cat-label {
        var x = g.x;
fill:var(--text-main);
        var c = g.cut;
font-size:10px;
        var h = g.height;
font-weight:700;
font-family:inherit;
text-anchor:middle;
dominant-baseline:middle;
pointer-events:none;
paint-order:stroke;
stroke:#000000;
stroke-width:2px;
stroke-linejoin:round;
}


        if (index === 0) {
.portal-cat-anchor:hover .portal-cat-label {
            return [
fill:#ffffff;
                [x[0], 0],
}
                [x[1] - c, 0],
                [x[1], h],
                [x[0], h]
            ];
        }


        if (index === 1) {
.portal-cat-project-label {
            return [
font-size:11px;
                [x[1] - c, 0],
letter-spacing:0.2px;
                [x[2] - c, 0],
}
                [x[2], h],
                [x[1], h]
            ];
        }


        if (index === 2) {
/*
            return [
검은 획은 SVG line이다.
                [x[2] - c, 0],
polygon 안에 넣지 않고, 같은 svg 좌표계 안의 별도 line으로 그린다.
                [x[3] - c, 0],
                [x[3], h],
                [x[2], h]
            ];
        }


        if (index === 3) {
이전 CSS 방식의 실패:
            return [
- 버튼 내부 ::after는 clip-path에 잘렸다.
                [x[3] - c, 0],
- absolute overlay는 CSS grid 좌표와 프로젝트 가변폭 때문에 어긋났다.
                [x[4], 0],
                [x[4] - c, h],
                [x[3], h]
            ];
        }


        if (index === 4) {
SVG line은 polygon 좌표와 같은 값으로 생성되므로,
            return [
사선 면과 획이 같은 기준을 사용한다.
                [x[4], 0],
*/
                [x[5], 0],
                [x[5] - c, h],
                [x[4] - c, h]
            ];
        }


        if (index === 5) {
.portal-cat-divider {
            return [
stroke:#050505;
                [x[5], 0],
stroke-width:1;
                [x[6], 0],
shape-rendering:crispEdges;
                [x[6] - c, h],
vector-effect:non-scaling-stroke;
                [x[5] - c, h]
pointer-events:none;
            ];
}
        }


        return [
/*
            [x[6], 0],
좁은 화면에서는 SVG 사선 네비가 과밀해질 수 있다.
            [x[7], 0],
현재는 JS가 폭에 맞춰 각 칸을 줄인다.
            [x[7], h],
모바일 전용 대문이 필요해지면 여기서 별도 compact nav로 분기한다.
            [x[6] - c, h]
*/
        ];
    }
 
    function pointsToString(points) {
        return points.map(function (point) {
            return point[0] + ',' + point[1];
        }).join(' ');
    }
 
    function getTextX(index, g) {
        var x = g.x;
 
        if (index === 0) return (x[0] + x[1]) / 2;
        if (index === 1) return (x[1] + x[2]) / 2;
        if (index === 2) return (x[2] + x[3]) / 2;
        if (index === 3) return (x[3] + x[4]) / 2;
        if (index === 4) return (x[4] + x[5]) / 2;
        if (index === 5) return (x[5] + x[6]) / 2;
        return (x[6] + x[7]) / 2;
    }
 
    function createSvgElement(name) {
        return document.createElementNS(SVG_NS, name);
    }
 
    /*
    SVG 링크 생성
    -----------------------------------------
    SVG <a> 안에 polygon과 text를 함께 넣는다.
    polygon이 hover/click 면이고, text는 pointer-events:none으로 둔다.
    */
 
    function createSegment(item, index, g, lang) {
        var anchor = createSvgElement('a');
        var polygon = createSvgElement('polygon');
        var text = createSvgElement('text');
        var href = buildPath(item.title);
        var label = getLabel(item, lang);
 
        anchor.setAttribute('class', 'portal-cat-anchor');
        anchor.setAttribute('href', href);
        anchor.setAttributeNS(XLINK_NS, 'xlink:href', href);
        anchor.setAttribute('aria-label', label);
        anchor.setAttribute('data-category-key', item.key);
 
        polygon.setAttribute('class', 'portal-cat-shape');
        polygon.setAttribute('points', pointsToString(getSegmentPoints(index, g)));
 
        text.setAttribute('class', item.key === 'project'
            ? 'portal-cat-label portal-cat-project-label'
            : 'portal-cat-label'
        );
        text.setAttribute('x', getTextX(index, g));
        text.setAttribute('y', g.height / 2 + 1);
        text.textContent = label;
 
        anchor.appendChild(polygon);
        anchor.appendChild(text);
 
        return anchor;
    }
 
    /*
    구분선 생성
    -----------------------------------------
    polygon 경계와 같은 x 값을 사용해 line을 만든다.
    그러므로 호버 면과 검은 획이 다른 좌표계를 쓰지 않는다.
    */
 
    function createDivider(x1, y1, x2, y2, side) {
        var line = createSvgElement('line');
 
        line.setAttribute('class', 'portal-cat-divider portal-cat-divider-' + side);
        line.setAttribute('x1', x1);
        line.setAttribute('y1', y1);
        line.setAttribute('x2', x2);
        line.setAttribute('y2', y2);
 
        return line;
    }
 
    function appendDividers(svg, g) {
        var x = g.x;
        var c = g.cut;
        var h = g.height;
 
        /*
        왼쪽 3개 구분선: \ 방향
        */
        svg.appendChild(createDivider(x[1] - c, 0, x[1], h, 'left'));
        svg.appendChild(createDivider(x[2] - c, 0, x[2], h, 'left'));
        svg.appendChild(createDivider(x[3] - c, 0, x[3], h, 'left'));
 
        /*
        오른쪽 3개 구분선: / 방향
        */
        svg.appendChild(createDivider(x[4], 0, x[4] - c, h, 'right'));
        svg.appendChild(createDivider(x[5], 0, x[5] - c, h, 'right'));
        svg.appendChild(createDivider(x[6], 0, x[6] - c, h, 'right'));
    }
 
    function clearNode(node) {
        while (node.firstChild) {
            node.removeChild(node.firstChild);
        }
    }
 
    /*
    실제 SVG 렌더링
    -----------------------------------------
    nav의 현재 폭을 읽고, 그 폭에 맞춰 viewBox와 polygon을 재생성한다.
    ResizeObserver가 이 함수를 다시 호출하면 폭 변화에도 대응한다.
    */
 
    function renderSvgIntoNav(nav) {
        var rect = nav.getBoundingClientRect();
        var measuredWidth = Math.floor(rect.width || nav.clientWidth || 0);
 
        if (!measuredWidth) {
            return;
        }
 
        var lang = getCurrentLanguageSafe();
        var g = calculateGeometry(measuredWidth);
        var svg = createSvgElement('svg');
 
        svg.setAttribute('class', 'portal-category-svg');
        svg.setAttribute('viewBox', '0 0 ' + g.width + ' ' + g.height);
        svg.setAttribute('width', g.width);
        svg.setAttribute('height', g.height);
        svg.setAttribute('preserveAspectRatio', 'none');
        svg.setAttribute('aria-hidden', 'false');
 
        CATEGORY_NAV_ITEMS.forEach(function (item, index) {
            svg.appendChild(createSegment(item, index, g, lang));
        });
 
        appendDividers(svg, g);
 
        clearNode(nav);
        nav.appendChild(svg);
    }
 
    function scheduleRender(nav) {
        if (!nav || nav.__categoryNavFrame) {
            return;
        }
 
        nav.__categoryNavFrame = window.requestAnimationFrame(function () {
            nav.__categoryNavFrame = null;
            renderSvgIntoNav(nav);
        });
    }
 
    /*
    mount 렌더링
    -----------------------------------------
    본문에는 다음 자리만 둔다.
 
    <div data-component="category-nav"></div>
 
    JS는 그 안에 nav.portal-category-nav를 만들고,
    SVG를 렌더링한다.
    */
 
    function renderCategoryNavMount(mount) {
        if (!mount) return;
 
        if (mount.__categoryNavObserver) {
            mount.__categoryNavObserver.disconnect();
            mount.__categoryNavObserver = null;
        }
 
        clearNode(mount);
 
        var nav = document.createElement('nav');
        nav.className = 'portal-category-nav';
        nav.setAttribute('aria-label', 'Main categories');
 
        mount.appendChild(nav);
        mount.setAttribute('data-category-nav-rendered', '1');
 
        scheduleRender(nav);
 
        /*
        nav 폭이 바뀌면 SVG 좌표를 다시 계산한다.
        이것이 CSS absolute overlay 방식과 다른 점이다.
        폭 변화가 생겨도 JS가 polygon 좌표를 다시 맞춘다.
        */
        if (window.ResizeObserver) {
            var observer = new ResizeObserver(function () {
                scheduleRender(nav);
            });
 
            observer.observe(nav);
            mount.__categoryNavObserver = observer;
        } else {
            window.addEventListener('resize', function () {
                scheduleRender(nav);
            });
        }
    }
 
    function renderAllCategoryNavs(root) {
        var scope = root || document;
        var mounts = scope.querySelectorAll('[data-component="category-nav"]');
 
        Array.prototype.forEach.call(mounts, function (mount) {
            renderCategoryNavMount(mount);
        });
    }
 
    /*
    초기화
    -----------------------------------------
    1. 일반 페이지 로드 시 실행
    2. MediaWiki SPA/동적 본문 갱신 시 실행
    3. 필요할 때 window.CLBI.categoryNav.init()으로 수동 재실행 가능
    */
 
    window.CLBI = window.CLBI || {};
    window.CLBI.categoryNav = {
        init: renderAllCategoryNavs,
        renderMount: renderCategoryNavMount
    };
 
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () {
            renderAllCategoryNavs(document);
        });
    } else {
        renderAllCategoryNavs(document);
    }
 
    if (window.mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            var root = $content && $content[0] ? $content[0] : document;
            renderAllCategoryNavs(root);
        });
    }
}());

2026년 5월 19일 (화) 08:14 판

/* =========================================
COASTLINE: BLACK ICE - MainPage
대문 전용 스타일
========================================= */

/*
설계 목적
-----------------------------------------
이 파일은 대문 전용 화면 구성만 담당한다.

이전 테스트 단계에서는 MediaWiki:Test.css 안에
대문 레이아웃, 이미지 피드, 방명록 더미, 스타일 매뉴얼,
카테고리 네비 실험 코드가 모두 섞여 있었다.

그 상태로 실사용에 넣으면 다음 문제가 생긴다.

1. 어느 코드가 대문 전용인지, 어느 코드가 공통 컴포넌트인지 구분하기 어렵다.
2. 일반 문서 네비바나 사이드바를 고칠 때 대문 코드가 같이 영향을 받을 수 있다.
3. 테스트용 더미와 실제 운영 UI가 같은 파일에 남아 유지보수가 어려워진다.

따라서 이 파일은 대문 본문 안에서 쓰는
.main-portal 스코프 내부만 담당한다.

주의:
- body, .liberty-content, .content-wrapper 같은 전역 레이아웃은 건드리지 않는다.
- 상단 공통 네비바, 좌우 사이드바, 관리자 패널은 이 파일의 책임이 아니다.
- 카테고리 네비는 JS가 SVG DOM을 생성하고, 이 파일은 그 결과물의 시각 스타일만 담당한다.
- 버튼 폭, 사선 좌표, 프로젝트 폭은 CSS가 아니라 CategoryNav.js가 계산한다.
*/

/* -----------------------------------------
0. Main portal scope
----------------------------------------- */

.main-portal {
--surface-page:#070707;
--surface-frame:#101010;
--surface-frame-soft:#141414;
--surface-title:#1d1d1d;
--surface-title-hover:#252525;
--surface-well:#080808;

--edge-top:#555555;
--edge-side:#2b2b2b;
--edge-bottom:#050505;
--edge-soft:#202020;

--text-main:#e2e2e2;
--text-soft:#c8c8c8;
--text-dim:#8a8a8a;
--text-faint:#626262;

--space-block:8px;
--space-inner:6px;
--pad-panel:8px;

--nav-h:30px;

width:100%;
margin:0 auto;
padding:var(--space-block);
box-sizing:border-box;
background:var(--surface-page);
color:var(--text-main);
font-size:11px;
line-height:1.5;
}

.main-portal *,
.main-portal *::before,
.main-portal *::after {
box-sizing:border-box;
}

.main-portal a {
color:var(--text-main) !important;
text-decoration:none !important;
}

.main-portal a:hover {
color:#ffffff !important;
text-decoration:none !important;
}

/* -----------------------------------------
1. Category nav mount
----------------------------------------- */

/*
[data-component="category-nav"]는 본문에 남겨두는 자리 표시자다.

본문에 위키문법으로 링크를 직접 조립하면,
MediaWiki가 생성한 <a>와 바깥 span이 분리된다.
그 상태에서는 실제 클릭 영역, 호버 영역, 사선 모양이 서로 어긋난다.

따라서 본문에는 비어 있는 mount만 두고,
CategoryNav.js가 실제 SVG 네비를 생성한다.
*/

.main-portal [data-component="category-nav"] {
display:block;
width:100%;
margin:0 0 var(--space-block);
}

/* -----------------------------------------
2. Category navigation - SVG version
----------------------------------------- */

/*
왜 SVG로 바꿨는가
-----------------------------------------
CSS 박스 기반 구현에서는 계속 같은 문제가 생겼다.

1. clip-path를 span에 걸면 MediaWiki가 생성한 <a>와
   시각적 버튼이 분리된다.
2. ::after로 검은 획을 만들면 clip-path가 획까지 잘라먹는다.
3. 획과 호버를 별도 overlay로 만들면 좌표가 다시 어긋난다.
4. 음수 margin으로 버튼을 겹치면 호버가 옆 칸으로 넘어간다.

SVG 방식에서는 각 항목이 실제 polygon이다.

즉,
- 보이는 면
- 호버되는 면
- 클릭되는 면
- 검은 구분 획

이 모두가 같은 SVG 좌표계 안에서 처리된다.

CSS의 책임
-----------------------------------------
이 파일은 색상, 호버, 텍스트 스타일, 외곽 표면만 담당한다.
버튼 폭과 사선 좌표는 CategoryNav.js가 계산한다.

따라서 이 파일에는 --cat-cell-w, --cat-cell-2, --cat-cell-3 같은
좌표성 변수를 두지 않는다.
*/

.portal-category-nav {
position:relative;
width:100%;
height:var(--nav-h);
margin:0;
padding:0;
overflow:hidden;

background:var(--surface-frame-soft);
border:1px solid;
border-color:
var(--edge-top)
var(--edge-side)
var(--edge-bottom)
var(--edge-side);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.055),
inset 0 -1px 0 rgba(0,0,0,0.72);
}

/*
SVG는 JS가 현재 nav 폭을 읽어서 viewBox를 계산한다.
CSS는 svg의 표시 크기만 지정한다.
*/

.portal-category-svg {
display:block;
width:100%;
height:100%;
}

/*
각 항목의 polygon은 기본적으로 투명하다.
표면은 부모 .portal-category-nav가 담당한다.

호버 시에만 아주 약한 밝기 레이어를 polygon에 입힌다.
이렇게 해야 독립 버튼 외장처럼 보이지 않는다.
*/

.portal-cat-shape {
fill:transparent;
stroke:none;
pointer-events:all;
}

.portal-cat-anchor:hover .portal-cat-shape {
fill:rgba(255,255,255,0.035);
}

.portal-cat-anchor:active .portal-cat-shape {
fill:rgba(0,0,0,0.20);
}

/*
텍스트는 SVG 내부 text로 렌더링된다.

SVG text에는 일반 CSS text-shadow가 브라우저에 따라 안정적으로 먹지 않을 수 있다.
그래서 CategoryNav.js에서 fill, stroke, stroke-width, paint-order를 직접 넣는다.

여기서는 hover 색상과 기본 클래스 스타일을 보조한다.
*/

.portal-cat-label {
fill:var(--text-main);
font-size:10px;
font-weight:700;
font-family:inherit;
text-anchor:middle;
dominant-baseline:middle;
pointer-events:none;
paint-order:stroke;
stroke:#000000;
stroke-width:2px;
stroke-linejoin:round;
}

.portal-cat-anchor:hover .portal-cat-label {
fill:#ffffff;
}

.portal-cat-project-label {
font-size:11px;
letter-spacing:0.2px;
}

/*
검은 획은 SVG line이다.
polygon 안에 넣지 않고, 같은 svg 좌표계 안의 별도 line으로 그린다.

이전 CSS 방식의 실패:
- 버튼 내부 ::after는 clip-path에 잘렸다.
- absolute overlay는 CSS grid 좌표와 프로젝트 가변폭 때문에 어긋났다.

SVG line은 polygon 좌표와 같은 값으로 생성되므로,
사선 면과 획이 같은 기준을 사용한다.
*/

.portal-cat-divider {
stroke:#050505;
stroke-width:1;
shape-rendering:crispEdges;
vector-effect:non-scaling-stroke;
pointer-events:none;
}

/*
좁은 화면에서는 SVG 사선 네비가 과밀해질 수 있다.
현재는 JS가 폭에 맞춰 각 칸을 줄인다.
모바일 전용 대문이 필요해지면 여기서 별도 compact nav로 분기한다.
*/