AppShell
먼저 보고, 그다음 계약을 읽습니다
페이지 전체 레이아웃 chrome — 슬롯 골격과 Design/Gate 공용 workspace 표면.
Component intent의도와 경계 읽기
variant 로 그리드 골격을 고른다: docs = Sidebar | Content | TOC, dashboard = Sidebar | Content, landing = Content. `.axe-workspace-shell`을 함께 쓰면 Design과 Gate가 공유하는 280px rail · 68rem bounded canvas · 30px navigation row 계약이 활성화된다. 본질은 CSS-only — Rust(maud)/Python(jinja) 도 같은 markup으로 같은 chrome을 그릴 수 있고, React wrapper는 슬롯 합성과 semantic role만 얹는 thin 층이다. 1024–1439px에서는 우측 context를 topbar disclosure로 옮기고, ≤1023px에서는 navigation과 context 양쪽을 native disclosure로 노출한다. 캔버스 캡과 레일은 표면마다 다르게 고를 수 있다 — `.axe-workspace-shell--wide` 는 68rem 캡을 풀고, 루트의 `data-rail-collapsed` 속성은 ≥1024px 에서 레일 트랙을 통째로 뺀다. 둘 다 opt-in 이라 클래스·속성을 주지 않은 셸의 기하는 종전과 같다.
예제
SSOT에 등록된 실제 API 기준 snippet입니다. standalone 배지만 독립 실행 단위이며, fragment는 주변 state·handler 문맥을 생략합니다.
import { AppShell, Sidebar, Toc, TopNav, Footer } from "@axe/ui";<AppShell variant="docs" topbar={<TopNav /* ... */ />} sidebar={ <Sidebar items={navItems} value={cur} onValueChange={setCur} /> } toc={ <Toc activeId={activeHeading} items={[ { id: "intro", label: "소개" }, { id: "api", label: "API", level: 3 }, ]} /> } footer={<Footer />}> <article className="axe-prose">…</article></AppShell>import { AppShell, Sidebar } from "@axe/ui";<AppShell variant="dashboard" sidebar={<Sidebar items={navItems} value={cur} onValueChange={setCur} />} subnavLabel="탐색"> <section>…대시보드 본문…</section></AppShell><div class="axe-app-shell" data-shell="docs"> <a href="#main" class="axe-skip-link">본문으로 건너뛰기</a> <div class="axe-app-shell__topbar"> <!-- topbar --> <details class="axe-app-shell__subnav"> <summary class="axe-app-shell__subnav-summary"> <span class="axe-app-shell__subnav-label">메뉴</span> </summary> <div class="axe-app-shell__subnav-panel"> <nav class="axe-sidebar" aria-label="Primary"><!-- 네비 복제 --></nav> </div> </details> </div> <div class="axe-app-shell__main"> <aside class="axe-app-shell__sidebar" aria-label="Primary">…</aside> <main id="main" class="axe-app-shell__content">…</main> <aside class="axe-app-shell__toc" aria-label="On this page">…</aside> </div> <div class="axe-app-shell__footer"><!-- footer --></div></div>import { useCallback, useSyncExternalStore } from "react";import { AppShell, PanelLeftIcon } from "@axe/ui";const RAIL_KEY = "axe-rail-collapsed:console";const RAIL_EVENT = "axe:rail-collapse";// 상태의 진실은 localStorage 다. useState + useEffect 로 읽으면 펼친 폭이// 한 프레임 보였다가 접힌다 — useSyncExternalStore 는 서버 스냅샷(펼침)으로// hydration 을 맞춘 뒤 저장값을 첫 페인트 전에 반영한다.let snapshot = null;function readCollapsed() { if (snapshot !== null) return snapshot; try { snapshot = window.localStorage.getItem(RAIL_KEY) === "1"; } catch { snapshot = false; } return snapshot;}function subscribe(onStoreChange) { window.addEventListener(RAIL_EVENT, onStoreChange); return () => window.removeEventListener(RAIL_EVENT, onStoreChange);}// 같은 버튼이 두 자리에 선다 — 레일 brand 행(펼침용)과 bar 좌측 칸(접힘용).// 계약이 상태마다 하나만 display 하므로 숨은 쪽은 AX 트리에서 빠진다.function RailToggle({ collapsed, onToggle, className = "axe-workspace-rail-toggle" }) { return ( <button type="button" className={className} aria-label="사이드 패널 접기/펼치기" aria-controls="console-rail" aria-expanded={!collapsed} onClick={onToggle} > <PanelLeftIcon /> </button> );}const collapsed = useSyncExternalStore(subscribe, readCollapsed, () => false);const toggleRail = useCallback(() => { snapshot = !readCollapsed(); try { window.localStorage.setItem(RAIL_KEY, snapshot ? "1" : "0"); } catch { /* 저장이 막혀도 이번 세션의 토글은 살아 있다 */ } window.dispatchEvent(new Event(RAIL_EVENT));}, []);<AppShell variant="dashboard" className="axe-workspace-shell axe-workspace-shell--wide" data-rail-collapsed={collapsed ? "" : undefined} sidebar={ <div id="console-rail" className="axe-workspace-rail"> <div className="axe-workspace-rail__brand"> {/* 펼침용 토글 — 레일과 함께 사라진다 */} <RailToggle collapsed={collapsed} onToggle={toggleRail} /> </div> {/* 나머지 rail */} </div> } topbar={ <header className="axe-workspace-bar"> <div className="axe-workspace-bar__left"> {/* 접힘용 토글 — 레일이 비켜난 칸에서만 보인다 */} <RailToggle collapsed={collapsed} onToggle={toggleRail} className="axe-workspace-bar__icon-button axe-workspace-rail-toggle" /> </div> <div className="axe-workspace-bar__center" /> <div className="axe-workspace-bar__right">{/* 검색 · 계정 */}</div> </header> }> <section>…표 밀도가 목적인 콘솔 본문…</section></AppShell><div id="console-shell" class="axe-app-shell axe-workspace-shell axe-workspace-shell--wide" data-shell="dashboard"> <!-- 여는 태그 직후, parser-blocking. 첫 프레임이 이미 접힌 폭으로 그려진다. --> <script> (function () { try { if (localStorage.getItem("axe-rail-collapsed:console") !== "1") return; } catch (error) { return; } document.getElementById("console-shell") .setAttribute("data-rail-collapsed", ""); })(); </script> <div class="axe-app-shell__topbar"> <header class="axe-workspace-bar"> <div class="axe-workspace-bar__left"> <!-- 접힘용 인스턴스. 펼침 상태에서는 레일이 이 칸을 덮으므로 계약이 감춘다. --> <button type="button" class="axe-workspace-bar__icon-button axe-workspace-rail-toggle" aria-label="사이드 패널 접기/펼치기" aria-controls="console-rail" aria-expanded="true"> <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <rect x="3.5" y="4.5" width="17" height="15" rx="2.5" /> <path d="M9.5 4.5v15" /> </svg> </button> </div> <div class="axe-workspace-bar__center"></div> <div class="axe-workspace-bar__right"><!-- 검색 · 계정 --></div> </header> </div> <div class="axe-app-shell__main"> <aside class="axe-app-shell__sidebar" aria-label="Primary"> <div id="console-rail" class="axe-workspace-rail"> <div class="axe-workspace-rail__brand"> <!-- entity 칩 · 검색 … --> <!-- 펼침용 인스턴스. 28px brand 행 재료는 계약이 준다(추가 클래스 불요). 레일이 접히면 이 버튼도 레일과 함께 사라진다. --> <button type="button" class="axe-workspace-rail-toggle" aria-label="사이드 패널 접기/펼치기" aria-controls="console-rail" aria-expanded="true"> <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <rect x="3.5" y="4.5" width="17" height="15" rx="2.5" /> <path d="M9.5 4.5v15" /> </svg> </button> </div> <!-- rail body · identity --> </div> </aside> <main id="main" class="axe-app-shell__content">…</main> </div></div><script> (function () { var shell = document.getElementById("console-shell"); if (!shell) return; /* 인스턴스는 둘 — 상태를 함께 받고 함께 고지한다. */ var toggles = shell.querySelectorAll(".axe-workspace-rail-toggle"); if (!toggles.length) return; // 첫 동기화 — pre-paint 스니펫이 속성만 복원했으므로 버튼 상태를 맞춘다. var announce = function () { var expanded = shell.hasAttribute("data-rail-collapsed") ? "false" : "true"; Array.prototype.forEach.call(toggles, function (toggle) { toggle.setAttribute("aria-expanded", expanded); }); }; announce(); Array.prototype.forEach.call(toggles, function (toggle) { toggle.addEventListener("click", function () { var next = !shell.hasAttribute("data-rail-collapsed"); shell.toggleAttribute("data-rail-collapsed", next); try { localStorage.setItem("axe-rail-collapsed:console", next ? "1" : "0"); } catch (error) { /* 저장이 막혀도 이번 세션의 토글은 살아 있다 */ } announce(); }); }); })();</script><div class="axe-workspace-rail__brand"> <details class="axe-workspace-entity"> <summary aria-label="현재 조직: AXE Labs. 조직 전환"> <span class="axe-workspace-entity__label">AXE Labs</span> </summary> <div> <a class="axe-workspace-rail__link" href="/axev">AXE Ventures</a> <a class="axe-workspace-rail__link" href="/axec">AXE Corporation</a> </div> </details> <!-- 전환할 entity 가 없으면 같은 칩을 정적으로: --> <!-- <span class="axe-workspace-entity axe-workspace-entity--static"> <span class="axe-workspace-entity__label">Cortex</span> </span> --> <button class="axe-workspace-search" type="button" data-icon-only="true" aria-label="검색 열기" aria-haspopup="dialog"> <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none"> <circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="1.8" /> <path d="m16 16 4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /> </svg> <span>검색</span> </button></div><details class="axe-workspace-entity"> <summary aria-label="현재 서비스: Design. 서비스 전환"> <span class="axe-workspace-entity__label">Design</span> <!-- chevron 을 직접 넣으면 계약이 그것을 14px 로 재고 열림 시 회전시킨다. 생략하면 같은 자리에 CSS 글리프가 그려진다 — 둘 중 하나만. --> <svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none"> <path d="m5.5 7.5 4.5 4.5 4.5-4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /> </svg> </summary> <div> <a href="https://design.axelabs.ai" aria-current="page"> <span class="axe-workspace-entity__row"> <span class="axe-workspace-entity__copy"> <strong>Design</strong> <small>토큰과 컴포넌트 계약</small> </span> <!-- 현재 항목 체크: 행 오른쪽 슬롯의 svg. 없으면 슬롯이 비어 접힌다. --> <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <path d="m5 12.5 4.5 4.5L19 7.5" /> </svg> </span> </a> <a href="https://gate.axelabs.ai"> <span class="axe-workspace-entity__row"> <span class="axe-workspace-entity__copy"> <strong>Gate</strong> <small>결재와 전자서명</small> </span> </span> </a> </div></details><div class="axe-workspace-rail__identity axe-workspace-rail__identity--stacked"> <!-- entity 행: 18px 마크 · 라벨 · chevron. 메뉴는 entity 전환 메뉴 표면과 같다. --> <details class="axe-workspace-identity-menu"> <summary class="axe-workspace-identity axe-workspace-identity--entity" aria-label="현재 entity: AXE Labs. Entity 변경"> <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <path d="M4 20V6.5a1.5 1.5 0 0 1 1-1.42l6-2.1a1 1 0 0 1 1.33.95V20" /> <path d="M12.33 9.4 19 11.6a1.5 1.5 0 0 1 1 1.42V20" /> <path d="M3 20h18M7.5 9.5v0M7.5 13.5v0M16 15v0" /> </svg> <span class="axe-workspace-identity__label">AXE Labs</span> <svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none"> <path d="m5.5 7.5 4.5 4.5 4.5-4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /> </svg> </summary> <div class="axe-workspace-identity-menu__panel"> <a href="/axev"> <span class="axe-workspace-entity__row"> <span class="axe-workspace-entity__copy"> <strong>AXE Ventures</strong> <small>Investment workspace</small> </span> </span> </a> </div> </details> <!-- 계정 행: avatar · 라벨. 메뉴 항목의 글리프는 16px/1.6 stroke 한 벌. --> <details class="axe-workspace-identity-menu"> <summary class="axe-workspace-identity" aria-label="[email protected] 계정 메뉴"> <span class="axe-workspace-identity__avatar" aria-hidden="true">A</span> <span class="axe-workspace-identity__label">[email protected]</span> </summary> <div class="axe-workspace-identity-menu__panel"> <!-- 도움말: 디스플레이 아래 · 로그아웃 위(0.35.0 확정 구성). 목적지는 이 서비스의 문서 한 장이고, 앱 바깥이라 새 탭이다 — 하던 일이 있는 탭을 뺏지 않는다. --> <a class="axe-workspace-rail__link" href="https://docs.axelabs.ai/services/gate" target="_blank" rel="noopener noreferrer"> <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="9" /> <path d="M9.7 9a2.45 2.45 0 0 1 4.7.9c0 1.75-2.4 2-2.4 3.7" /> <circle cx="12" cy="17.2" r="1" fill="currentColor" stroke="none" /> </svg> 도움말 </a> <button class="axe-workspace-rail__link" type="button"> <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <path d="M14.5 4.5h3a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-3" /> <path d="M10 8.5 6.5 12 10 15.5M6.5 12H15" /> </svg> 로그아웃 </button> </div> </details></div><!-- 인스턴스 핸들은 **셸이 소유하는 호스트**에 붙는다. 패널 콘텐츠에는 인스턴스에 의존하는 바이트가 없어야 두 사본이 바이트 동일해진다. --><aside class="axe-app-shell__toc" data-axe-ctx="toc" aria-label="현재 컨텍스트"> <section class="axe-workspace-context" data-axe-page="workflow_detail" data-axe-subject-service="gate" data-axe-subject-kind="workflow" data-axe-subject-id="3f0e4a1c-8b2d-4c6e-9a1f-7d5b2e8c0a44"> <!-- ⓪ 선택. 있으면 **패널의 첫 시각 요소**다 — 아래 ①②③ 을 포함해 어떤 것도 그보다 앞서지 않는다. 전용 슬롯 클래스는 없다(자리가 계약이고 재료는 세션 어휘다). 다음 예제가 이 구역의 전체 마크업이다. 이 자리 대신 <section class="axe-workspace-context"> 의 **앞 형제**로 세워도 된다 — 그때는 킷의 :not(:first-child) 가드가 패널 min-height 를 되돌려 레일이 상시 스크롤되지 않게 하고, 여백은 .axe-workspace-context__body 래퍼가 준다. **로컬 padding 을 세우지 말 것** — 여기 이 자리(패널 안 첫 자식)에서는 킷이 head/list/빈-줄에 직접 padding-inline: var(--space-4) 를 주고 run 의 양 끝이 세로 여백을 든다. 반대로 이 자리를 __body 로 감싸는 것도 금지다: 그 클래스는 flex: 1 1 auto 라 남는 높이를 통째로 먹어 ①help 를 바닥으로 밀어낸다. --> <div class="axe-session-head">…</div> <ul class="axe-session-list axe-session-list--compact">…</ul> <!-- ① 항상. ⓪ 다음. 접힘 없음. --> <div class="axe-workspace-context__help"> <div class="axe-workspace-context__header"> <span class="axe-workspace-context__eyebrow">Current context</span> <strong class="axe-workspace-context__title">결재 문서</strong> <p class="axe-workspace-context__summary">승인하면 다음 결재자에게 넘어갑니다.</p> </div> </div> <!-- ② 조건부. 비면 이 노드를 아예 내지 않는다. 복사 텍스트는 핸드셰이크형이고(0.40.0), 오리엔테이션 항목은 refetch 와 독립이라 대상이 없는 화면에서도 선다 — 그때 카드는 "이 서비스 데이터 조회로 바로 들어가는 프롬프트" 가 되고 대상 블록 3줄만 빠진다. __slot 안은 골든(6-플레이스홀더 템플릿)에 값을 채운 바이트 그대로다. --> <div class="axe-workspace-context__prompt"> <div class="axe-prompt-card"> <p class="axe-prompt-card__line">[AXE gate] https://gate.axelabs.ai/wf/3f0e4a1c · 법인 axev</p> <p class="axe-prompt-card__line">대상: gate/[email protected]</p> <!-- 복사 원본은 보이고 선택 가능해야 한다(실패 시 유일한 수동 경로). --> <pre class="axe-prompt-card__slot" data-axe-copy-source="prompt" data-axe-copy-target>(사용자님: 붙여넣고 작업 지시 없이 바로 엔터 치세요)[AXE gate] https://gate.axelabs.ai/wf/3f0e4a1c · 법인 axev이 화면의 대상으로 작업한다.이 대화 세션의 제목을 "gate · <2번에서 읽은 대상의 이름>" 으로 맞춰라 (set_session_title 도구가 있으면; 없으면 건너뛴다). 첫 작업을 받으면 "gate · <작업 한 줄>" 로 바꾼다.1. ~/axe-cli/axe gate guide 를 실행해 그 지침을 따른다.2. 대상을 먼저 읽는다. 대상: gate/[email protected] 3f0e4a1c 현재 상태: ~/axe-cli/axe ref axeref1_eyJpc3M…3. 준비가 끝나면 "준비됐습니다. 어떤 작업을 해볼까요?" 한 줄로 묻고 기다린다. 작업을 받으면 묻지 말고 조회해 답한다.</pre> <button class="axe-prompt-card__copy" type="button" data-axe-copy="prompt" aria-label="프롬프트 복사">복사</button> <p class="axe-prompt-card__status" role="status" data-axe-copy-status></p> <p class="axe-prompt-card__notice">참조 토큰은 암호가 아니라 인코딩입니다.</p> </div> </div> <!-- ③ 조건부. chat 을 서빙하는 소비자만. 도크 노드는 문서당 1개이고, 비활성 사본은 빈 호스트만 낸다. --> <div class="axe-workspace-context__dock" data-axe-dock-host> <form data-axe-dock-root><!-- 소비자 소유 --></form> </div> <!-- ④ 선택. 서면 패널 직계 자식의 **마지막**이다(margin-top: auto 라 자리는 언제나 맨 아래다). 정본 문안 = 화면 이동 단축키 안내. 배너·프로모션· 상태 배지의 자리가 아니다. 여기까지가 **닫힌 목록**이다 — 이 다섯 밖의 직계 자식이 서면 소비자 conformance 가 red 다(0.39.0). --> <div class="axe-workspace-context__footer">⌘K 로 화면 이동</div> </section></aside><!-- 세션 섹션 = ContextPanel@1 의 **슬롯 ⓪** 이다(0.38.3). 우측 패널의 첫 시각 요소이며, 자리는 둘 중 하나다 — .axe-workspace-context 의 첫 자식, 또는 같은 셸 호스트 안에서 그 앞 형제. 배너·헤더·프롬프트 카드·페이지 섹션은 전부 이 아래다. --><!-- ① 레일 — 머리 행 + 한 줄 목록(--compact) --><div class="axe-session-head"> <!-- 타이틀 **태그는 예시일 뿐**이다. 계약은 클래스와 head 행 배치이고, h2/p/div 중 무엇으로 셀지는 그 페이지의 문서 개요가 정한다 (실제로 cortex=p · gate=div · blueprint=h2 로 선다). --> <h2 class="axe-workspace-context__section-title">세션</h2> <!-- <div> 이지 <span> 이 아니다 — <span> 은 phrasing content 만 담을 수 있어 안의 <details> 가 마크업을 무효로 만든다(a > button 을 가른 것과 같은 근거). --> <div class="axe-session-head__actions"> <!-- 생성 폼은 네이티브 <details>. JS 0 인 소비자가 여닫을 수 있는 유일한 수단이고, 상태가 DOM 에 있으므로 React 소비자도 같은 마크업을 쓴다. summary 가 곧 28px 아이콘 버튼이다(list-style/마커는 CSS 가 지운다). --> <details class="axe-session-head__new"> <summary class="axe-session-head__button" aria-label="새 세션"> <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <path d="M8 3.5v9M3.5 8h9" /> </svg> </summary> <!-- 입력·제출 재료는 이 계약이 주지 않는다 — .axe-input · .axe-btn 조립. --> <form class="axe-session-head__new-panel" method="post" action="/axe/sessions"> <input class="axe-input" type="text" name="name" required maxlength="400" placeholder="새 세션 이름" aria-label="새 세션 이름"> <button class="axe-btn axe-btn--sm" type="submit">만들기</button> </form> </details> <!-- 목록 전체로 가는 링크. 슬라이더 두 줄 + 빈 손잡이(어긋난 x 위치). --> <a class="axe-session-head__button" href="/axe/sessions" aria-label="세션 목록"> <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <path d="M2.5 5h4.3M12.2 5h1.3M2.5 11h1.3M9.2 11h4.3" /> <circle cx="9.5" cy="5" r="1.8" /> <circle cx="6.5" cy="11" r="1.8" /> </svg> </a> </div></div><ul class="axe-session-list axe-session-list--compact"> <!-- 행 = <li>. 클릭면 = 그 안의 <a>/<button>. 액션 버튼이 링크의 형제로 서야 하므로 둘을 나눈다(<a> 안의 <button> 은 유효하지 않다). --compact 행은 __meta 를 아예 내지 않는다 — 메타는 링크의 title 로. --> <li class="axe-session-item axe-session-item--active"> <a class="axe-session-item__link" href="/axe/sessions/9f2c?entity=axec" aria-current="page" aria-label="결산 자동화 파일럿 — 진행 중" title="axec · 2시간 전 · 게재 3"> <!-- 도트는 색으로만 말한다 → aria-hidden + 링크의 aria-label 이 짝. 이 짝은 **compact 전용**이다(아래 ② 기본 행은 걸지 않는다). --> <span class="axe-session-item__dot axe-session-item__dot--active" aria-hidden="true"></span> <span class="axe-session-item__name">결산 자동화 파일럿</span> </a> <!-- 선택. CSS-only 소비자는 생략한다 — 없어도 레이아웃이 깨지지 않는다. 머리의 __actions 와 같은 이유로 <div> 다(이름변경도 <details> 로 열 수 있다). compact 안에서는 높이만 28px 로 캡되므로 글자 버튼은 제 폭대로 서고, 아이콘 재료를 써도 28px 바닥에 걸려 정사각이 된다. **재료는 아이콘이다.** __actions 는 opacity 로만 숨으므로 라벨 폭이 숨은 동안에도 남는다 — 글자 두 짝("이름"·"복사")이면 ~100px 이 모든 행에서 이름 말줄임을 앞당긴다(아이콘 두 짝은 56px). 동작은 aria-label 이 진다. React 는 RenameIcon · CopyIcon 을 import 한다. --> <div class="axe-session-item__actions"> <button class="axe-workspace-bar__icon-button" type="button" aria-label="이름 변경"> <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <path d="M11.1 2.5 13.5 4.9 5.7 12.7 2.7 13.3 3.3 10.3Z" /> <path d="m3.3 10.3 2.4 2.4" /> </svg> </button> <button class="axe-workspace-bar__icon-button" type="button" aria-label="프롬프트 복사"> <svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <rect x="6" y="6" width="8" height="8" rx="1.6" /> <path d="M10 6V3.6A1.6 1.6 0 0 0 8.4 2H3.6A1.6 1.6 0 0 0 2 3.6v4.8A1.6 1.6 0 0 0 3.6 10H6" /> </svg> </button> </div> </li> <li class="axe-session-item"> <a class="axe-session-item__link" href="/axe/sessions/41ab?entity=axec" aria-label="LP 명부 정합성 점검 — 완료" title="axec · 어제 · 게재 1"> <span class="axe-session-item__dot axe-session-item__dot--done" aria-hidden="true"></span> <span class="axe-session-item__name">LP 명부 정합성 점검</span> </a> </li></ul><!-- ①-b 같은 레일, 목록이 비었을 때. <ul> 을 빈 채로 내지 말고 그 자리에 안내 한 줄을 세운다 — 구역 제목만 있고 아래가 비면 "아직 없다" 가 아니라 "고장났다" 로 읽힌다(로딩 실패와 구분되지 않는다). 머리는 그대로 남는다: 생성 버튼은 목록이 비었을 때 가장 필요하다. 문안은 계약이다 — 소비자마다 다른 말을 세우면 같은 셸의 같은 자리가 서비스마다 다른 제품처럼 보인다. (세션을 아예 서빙하지 않는 표면은 이 문구가 아니라 섹션 전체를 내지 않는다 — "없음" 과 "해당 없음" 은 다른 말이다.) --><div class="axe-session-head"> <h2 class="axe-workspace-context__section-title">세션</h2> <div class="axe-session-head__actions"><!-- …생성·목록 버튼 그대로… --></div></div><p class="axe-workspace-context__summary">아직 세션이 없습니다.</p><!-- ② 작업면 목록 — 기본(두 줄) 형태. 여기서는 메타를 접을 이유가 없다. 도트가 있으면 __link 가 grid 로 전환돼 도트·이름이 첫 줄, __meta 가 둘째 줄 전폭에 선다(마크업은 compact 와 같고 조판만 CSS 가 가른다). --><ul class="axe-session-list"> <li class="axe-session-item"> <!-- **aria-label 없음.** 걸면 자손 텍스트를 덮어 아래 보이는 메타(배지· entity·시각·게재수)가 접근 이름에서 통째로 사라진다 — 여기서는 자손 텍스트가 접근 이름의 정본이고, 상태는 가시 배지가 진다. --> <a class="axe-session-item__link" href="/axe/sessions/41ab?entity=axec"> <!-- 보관 도트는 --active 와 채움이 같아 색으로만 갈린다 → 가시 상태 텍스트를 반드시 병기한다(도트는 그 배지의 장식일 뿐). 병기가 설 자리가 __meta 라, 보관은 이 기본 행의 것이다. --> <span class="axe-session-item__dot axe-session-item__dot--archived" aria-hidden="true"></span> <span class="axe-session-item__name">LP 명부 정합성 점검</span> <span class="axe-session-item__meta"> <span class="axe-badge">보관</span> <span class="axe-badge">axec</span> 어제 · 게재 1 </span> </a> </li></ul><!-- ③ 게재물 피드. 컨테이너는 신설하지 않는다 — .axe-stack 으로 족하다. --><div class="axe-stack axe-stack--gap-4"> <article class="axe-session-post"> <header class="axe-session-post__head"> <span class="axe-session-post__author">회계 담당</span> <!-- __origin 은 자리만 준다. 배지 재료는 .axe-badge 가 이긴다. --> <span class="axe-session-post__origin axe-badge axe-badge--info">claude-code@mac-mini</span> <time class="axe-session-post__time" datetime="2026-08-27T14:02:00+09:00">08-27 14:02</time> </header> <!-- 마크다운이면 .axe-prose 를 함께 붙인다(조판은 prose 가 소유). --> <div class="axe-session-post__body axe-prose"> <p>마감 전표 3건을 재분류했다.</p> </div> </article> <article class="axe-session-post"> <header class="axe-session-post__head"> <span class="axe-session-post__author">회계 담당</span> <time class="axe-session-post__time" datetime="2026-08-27T15:10:00+09:00">08-27 15:10</time> </header> <!-- 렌더러가 없는 소비자는 원문을 이스케이프해 그대로 세운다(개행 보존). --> <pre class="axe-session-post__body axe-session-post__body--plain">## 마감 전표- 3건 재분류</pre> </article></div>Props
TSX 소스가 진실입니다. 주요 export 컴포넌트의 public API만 노출합니다.
| 이름 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
variant | "docs" | "dashboard" | "landing" | — | "docs" | 그리드 골격 선택. data-shell 속성으로 CSS 가 분기. |
topbar | React.ReactNode | — | — | 상단 바 슬롯(보통 <TopNav>). |
sidebar | React.ReactNode | — | — | 좌측 네비 슬롯(보통 <Sidebar>). landing 에서는 무시. |
toc | React.ReactNode | — | — | 우측 목차 슬롯(보통 <Toc>). variant="docs" 에서만 표시. |
footer | React.ReactNode | — | — | 하단 슬롯(보통 <Footer>). |
contentClassName | string | — | — | 본문 <main> 에 추가 className. |
contentAs | "main" | "div" | — | "main" | 본문 태그. 다른 문서 안에 specimen으로 중첩할 때만 div. |
contentId | string | — | "main" | 본문 id와 skip-link target. |
showSkipLink | boolean | — | true | 스킵 링크 렌더 여부. 중첩 specimen에서는 false. |
sidebarLabel | string | — | "Primary" | sidebar <aside> 의 aria-label. |
tocLabel | string | — | "On this page" | toc <aside> 의 aria-label. |
subnav | React.ReactNode | false | — | — | ≤1023px disclosure 내용. 기본 = sidebar 재사용. 명시 DOM id가 있는 sidebar는 고유 id의 별도 노드를 넘기거나 false로 끈다. |
subnavLabel | React.ReactNode | — | "메뉴" | ≤1023px disclosure summary 라벨. |
children | React.ReactNode | — | — | 본문 — <main id="main"> 안에 렌더. |
data-rail-collapsed | "" | undefined | — | — | 루트 <div> 로 전파되는 boolean DOM 속성(별도 prop 아님 — ...rest 경유). 존재 = 레일 접힘, 부재 = 펼침이며 "false" 로는 끌 수 없다. React 는 collapsed ? "" : undefined 로 쓴다. ≥1024px 에서만 효과가 있고(그 아래는 서랍 담당), 이 속성을 한 번도 주지 않는 셸의 기하는 이 계약 도입 전과 같다. |
...rest | React.HTMLAttributes<HTMLDivElement> | — | — | 나머지는 루트 <div> 로 전파. |
Toc.items | TocItem[] | 필수 | — | { id, label, level?: 2 | 3 } 배열. level=2 top / 3 sub(들여쓰기), 기본 2. |
Toc.title | React.ReactNode | — | "On this page" | 목차 상단 라벨. string 이면 <nav> aria-label 로도 사용. |
Toc.activeId | string | — | — | 현재 활성 heading id(외부 스크롤 spy 가 전달). 해당 링크에 aria-current="location". |
.axe-* 클래스 계약
React 밖에서도 같은 표면을 그리는 공개 계약입니다. stable은 minor 버전 안에서 이름을 바꾸지 않습니다.
| 클래스 | 안정성 | 용도 |
|---|---|---|
.axe-app-shell | stable | 루트 <div>. data-shell="docs|dashboard|landing" 로 그리드 분기. |
.axe-skip-link | stable | 본문(#main)으로 건너뛰는 스킵 링크(포커스 시 노출). |
.axe-app-shell__topbar | stable | 상단 바 영역(topbar + ≤1023px subnav 포함). |
.axe-app-shell__main | stable | sidebar|content|toc 그리드 행. |
.axe-app-shell__sidebar | stable | 좌측 네비 슬롯 <aside>(sticky/scroll). |
.axe-app-shell__content | stable | 본문 <main id="main">. variant 별 max-width. |
.axe-app-shell__toc | stable | 우측 목차 슬롯 <aside>(docs 전용). |
.axe-app-shell__footer | stable | 하단 footer 슬롯. |
.axe-app-shell__toc-title | stable | Toc 상단 라벨. |
.axe-app-shell__toc-list | stable | Toc 링크 <ul>. |
.axe-app-shell__toc-link | stable | Toc 앵커 링크. 활성 시 aria-current="location". |
.axe-app-shell__toc-link--h3 | stable | h3(level 3) Toc 링크 — 들여쓰기 변형. |
.axe-app-shell__subnav | stable | ≤1023px 보조 네비 <details> disclosure(≥1024px display:none). |
.axe-app-shell__subnav-summary | stable | disclosure <summary> 토글. |
.axe-app-shell__subnav-label | stable | disclosure summary 라벨 span. |
.axe-app-shell__subnav-panel | stable | disclosure 펼침 패널(네비 복제 렌더). |
.axe-app-shell__subnav-toc | stable | subnav 패널 안 목차 래퍼(docs + toc 있을 때). |
.axe-app-shell__subnav-chevron | internal | subnav summary chevron(장식, [open] 회전은 CSS). |
.axe-workspace-shell | stable | Design/Gate 공용 neutral workspace root. AppShell docs variant와 함께 사용. |
.axe-workspace-shell--wide | stable | 셸 루트의 opt-in modifier — 작업면 최대 폭 캡(--workspace-content-max)을 none 으로 푼다. 거터(--app-shell-gutter)는 유지되므로 full-bleed 가 아니라 '넓은 캔버스'다. 표 밀도가 목적 그 자체인 콘솔용. |
.axe-workspace-bar | stable | 56px workspace top bar. |
.axe-workspace-bar__left | stable | top bar 좌측 rail track. |
.axe-workspace-bar__center | stable | bounded center route track. |
.axe-workspace-bar__right | stable | 검색과 context action track. |
.axe-workspace-bar__routes | stable | desktop 중앙 route navigation. |
.axe-workspace-bar__mobile-brand | stable | rail이 접힐 때 표시하는 mobile product identity. |
.axe-workspace-bar__icon-button | stable | 44px menu/context icon trigger. |
.axe-workspace-rail-toggle | stable | 레일 접기 토글 표식 — 자리가 둘이고 상태마다 하나만 보인다. 펼침용은 .axe-workspace-rail__brand 안(검색 뒤, brand 행의 28px 아이콘 리듬을 계약이 준다), 접힘용은 .axe-workspace-bar__left 안(.axe-workspace-bar__icon-button 과 함께 주면 44px 재료를 받는다). 기본은 display:none 이고 ≥1024px 에서만 해당 인스턴스가 켜진다. |
.axe-workspace-disclosure | stable | native details 기반 navigation/context sheet root. |
.axe-workspace-disclosure--navigation | stable | ≤1023px 좌측 navigation disclosure. |
.axe-workspace-disclosure--context | stable | <1440px 우측 context disclosure. |
.axe-workspace-disclosure__panel | stable | viewport 고정 disclosure panel. |
.axe-workspace-rail | stable | full-height primary rail flex root. |
.axe-workspace-drawer | stable | rail 을 담은 서랍(모바일 시트·drawer) 표면 표식. 안의 rail 직계 부품은 배경·테두리를 버리고 서랍 하나의 면으로 읽히며, 이음매는 스크롤 영역과 identity footer 사이 1px 한 줄만 남는다. React Sheet(.axe-sheet__content)는 이 규칙을 자동으로 받으므로 표식이 필요 없다 — 서랍을 직접 짓는 maud/jinja 소비자를 위한 클래스. |
.axe-workspace-rail__brand | stable | 56px entity/search header. |
.axe-workspace-rail__body | stable | rail 내부의 유일한 세로 scroll 영역. |
.axe-workspace-rail__identity | stable | 45px 고정 identity footer (1px rule + 8 + 28px chip + 8). 계정 칩 하나만 담는 단일 행 — 담을 수 있는 것은 rail composition 계약(contracts/rail-selectors/contract.json)이 정한다. |
.axe-workspace-rail__identity--stacked | stable | 77px 2행 identity footer — entity 행 위, account 행 아래. entity 축이 없는 소비자(layer)는 단일 행으로 남으므로 opt-in. |
.axe-workspace-identity | stable | identity footer의 남는 폭을 채우는 28px identity 칩. 열림은 [open](부모 details)·[aria-expanded="true"]·[data-state="open"] 병렬 수신. |
.axe-workspace-identity--entity | stable | stacked footer의 entity 행 칩 — 선행 18px 마크 + 후행 chevron(위로 열리므로 기본 180° 회전). 라벨은 regular weight. |
.axe-workspace-identity__avatar | stable | 18px brand-tone identity avatar. |
.axe-workspace-identity__label | stable | 말줄임 처리되는 identity label. |
.axe-workspace-identity-menu | stable | rail 하단에서 위로 열리는 native identity menu. |
.axe-workspace-identity-menu__panel | stable | identity action panel — 272px(--workspace-menu-w) · 4px 패딩 · 22px 라벨 · 24px 행 · 12px 안쪽선 구분선. native <details> 자식이면 위로 열리는 위치를 스스로 갖고, portal 이면 host popper 가 위치를 잡는다. |
.axe-workspace-identity-menu__submenu | stable | identity 메뉴의 서브패널(언어·표시 등). 같은 재료, min-width 168px. |
.axe-workspace-rail__nav | stable | 30px row와 1px seam을 소유하는 navigation. |
.axe-workspace-rail__group-label | stable | 대메뉴 또는 비상호작용 group label. **링크와 굵기 하나로 갈린다(0.39.0)** — 같은 30px 행·같은 --workspace-muted 안에서 라벨만 500(--weight-medium)이고 그 아래 목적지는 400 이다. 둘이 같은 400 이면 캡션과 목적지가 한 덩어리로 읽히고, 그 순간 소비자가 로컬 CSS 로 캡션을 다시 칠한다(킷 어휘가 서비스마다 갈라지는 첫 걸음). 값은 새로 정한 것이 아니라 이 레일에서 .axe-sidebar__group-header 가 이미 서 있던 값이고, 두 이름이 같은 규칙을 공유한다. **이 라벨을 쓰면 그 아래 링크는 전부 data-level="2"** 다(부분 적용 금지 — rail-composition 계약의 nav 슬롯). |
.axe-workspace-rail__link | stable | hover/active/focus가 정렬된 navigation link. |
.axe-workspace-entity | stable | rail brand 행의 entity 칩. <details> root, 또는 trigger 자신(<a>/<button>). 열림은 [open]·[aria-expanded="true"]·[data-state="open"] 병렬 수신. |
.axe-workspace-entity--static | stable | 전환할 entity 가 없는 단일-도메인 소비자용 정적 칩. 같은 칩 스킨, chevron·hover/open·cursor 없음. |
.axe-workspace-entity__label | stable | 말줄임 처리되는 entity 칩 라벨. |
.axe-workspace-entity__menu | stable | entity/서비스 전환 메뉴 표면 — 272px(--workspace-menu-w) · 4px 패딩 · radius 12. portal 로 셸 밖에 렌더돼도 같은 재료가 되도록 workspace 변수를 자체 선언. |
.axe-workspace-entity__row | stable | 메뉴 옵션 한 행 — copy | 우측 슬롯(meta 또는 현재 항목 체크 svg) 2열 grid. 두 줄 옵션이면 행이 52px 로 자란다. Radix 소비자는 .axe-dropdown-menu__item-label 이 같은 격자를 받는다. |
.axe-workspace-entity__copy | stable | 옵션 이름(strong, 20px)과 설명(small, 18px) 스택 — 사이 2px. |
.axe-workspace-entity__meta | stable | 옵션 우측 mono 짧은 식별자. |
.axe-workspace-search | stable | text 또는 data-icon-only 검색 trigger. rail brand 행과 top bar 우측에서 같은 elevated 칩. |
.axe-workspace-search-dialog | stable | static CommandPalette markup을 native dialog로 호스트하는 workspace 검색 표면. |
.axe-workspace-picker-dialog | stable | rail 전환기(서비스·entity·계정)의 접힌-폭 팝업 — 검색과 같은 native <dialog> 표면. top layer라 --workspace-* 를 자체 선언한다. Esc·backdrop 클릭은 native, 닫기 버튼은 close(). |
.axe-workspace-picker | stable | picker 팝업 패널 — 420px · radius 16 · shadow-xl. 검색 팝업(.axe-cmdk)과 같은 floating surface 위계. |
.axe-workspace-picker__header | stable | 팝업 제목(<strong>)과 44px 닫기 버튼 2열 행. |
.axe-workspace-picker__close | stable | 44px borderless 팝업 닫기 버튼. |
.axe-workspace-picker__list | stable | 팝업 안의 유일한 세로 scroll 영역. |
.axe-workspace-picker__label | stable | 팝업 목록의 group caption(<p>). role="group" + aria-labelledby 로 묶을 때 이름 노드가 된다. |
.axe-workspace-picker__item | stable | 44px 이상 터치 행(button 또는 a). 선행/후행 <svg> 는 제 치수를 지키고 라벨(<span> 또는 .axe-workspace-entity__copy)이 남는 폭을 갖는다. 현재 항목은 aria-current 로 표시. |
.axe-workspace-context | stable | full-height 우측 context rail. |
.axe-workspace-context__header | stable | 현재 업무의 제목과 요약. |
.axe-workspace-context__eyebrow | stable | context overline. |
.axe-workspace-context__title | stable | context title. |
.axe-workspace-context__summary | stable | context 설명. **레일 안 빈 목록의 안내 문구도 이 재료다** — 세션 목록이 비면(설정은 되어 있고 0행) 목록 자리에 <p class="axe-workspace-context__summary">아직 세션이 없습니다.</p> 를 세운다(.axe-session-list 항목 참조). **레일 전용이다(0.38.7)** — 캔버스(작업면)의 보조 고지 한 줄("최근 50건만 표시" 류 절단 고지)에 이 클래스를 빌려 쓰지 말 것. 그 자리의 재료는 `.axe-section__note` 다: 이름이 자리를 말하지 않으면 다음 사람이 레일 리듬을 캔버스로, 캔버스 리듬을 레일로 끌고 온다. |
.axe-workspace-context__tabs | stable | 균등 분할 context tab row. |
.axe-workspace-context__tab | stable | 현재 tab 하단 indicator trigger/link. |
.axe-workspace-context__body | stable | context panel content. |
.axe-workspace-context__section-title | stable | context section caption — .axe-workspace-context__eyebrow 와 선언을 공유하는 페이지 섹션 캡션(mono·text-xs·uppercase·muted). **예외: .axe-session-head 안에서는 캡션이 아니라 그 레일의 제목이라(0.38.5) sans·text-sm·semibold·--workspace-text 로 덮인다 — .axe-session-head 항목 참조.** 그 밖의 자리는 캡션 그대로. |
.axe-workspace-context__link | stable | 32px context location link. |
.axe-workspace-context__footer | stable | ContextPanel@1 슬롯 ④ — 패널을 닫는 고정 하단 한 줄(margin-top: auto). 정본 문안은 화면 이동 단축키 안내(⌘K)다. 서면 **패널 직계 자식의 마지막**이다(0.39.0 닫힌 목록 — ⓪sessions → ①help → ②prompt → ③dock → ④footer). **선다/안 선다는 재량이 아니라 조건이다(0.39.0)**: 명령 팔레트(⌘K)를 서빙하는 소비자(blueprint·gate·cortex)는 필수, 팔레트가 없는 소비자는 생략 — 열리지 않는 단축키 안내는 거짓 어포던스다. 배너·프로모션·상태 배지의 자리가 아니다. |
.axe-workspace-context__help | stable | ContextPanel@1 슬롯 ① — 도움말. 항상 존재 · 접힘 없음. 키 0·동의 0·네트워크 0 인 상태에서도 렌더된다. **최상단이 아니다** — 슬롯 ⓪(세션 구역)이 존재하면 그것이 패널의 첫 시각 요소이고 ① 은 그 아래다(0.38.3 가산 개정). ⓪ 이 없는 표면에서는 종전대로 ① 이 첫 요소다. |
.axe-workspace-context__prompt | stable | ContextPanel@1 슬롯 ② — 프롬프트 카드 1장이 사는 자리. 조건부이며, 비면 노드를 내지 않는다(빈 카드·placeholder 금지). |
.axe-workspace-context__dock | stable | ContextPanel@1 슬롯 ③ — 하단 sticky 도크. in-flow 라 ①② 를 덮지 않는다(덮는 순간 그것은 도크가 아니라 탭이다). chat 을 서빙하는 소비자에만 존재하고, 문서당 노드는 1개다. |
.axe-prompt-card | stable | 슬롯 ② 의 카드. 외부 AI 에 붙여넣을 블록의 미리보기·복사면. 복사 텍스트는 **핸드셰이크형**이다(0.40.0 · D-ops-114) — 붙여넣고 엔터만 치면 에이전트가 오리엔테이션(`~/axe-cli/axe <svc> guide`)을 읽고, 세션 제목을 맞추고, "준비됐습니다. 어떤 작업을 해볼까요?" 로 사람의 첫 작업을 기다린다. **대상이 없는 화면에서도 선다** — 오리엔테이션 항목은 refetch 와 독립이라 서비스만 알면 서고, 카드는 '그 서비스 데이터 조회로 바로 들어가는 프롬프트' 가 된다. 그 상태의 카드 안 안내 권장 문안 = "이 화면에는 대상이 없습니다. <svc> 데이터 조회로 바로 들어가는 프롬프트를 내줍니다." (카드 안 안내이지 복사 텍스트가 아니다). 소비자마다 다른 말을 세우면 같은 셸의 같은 자리가 서비스마다 다른 제품처럼 보이므로 문안을 계약이 권한다. |
.axe-prompt-card__line | stable | 카드 미리보기 한 줄(헤더·선언·대상·현재 상태 등). 계약 텍스트의 개행은 조판 줄바꿈이라 여기서는 흘려보낸다 — 바이트는 __slot 과 골든이 지킨다. **미리보기는 요약이어도 되지만 __slot 은 요약하지 않는다**: 복사되는 것은 __slot 의 바이트이고, 그 바이트는 골든 두 벌(6-플레이스홀더 템플릿)에 값을 치환한 결과다(0.40.0). 규율문 6줄은 삭제됐다 — 그 규칙의 SoT 는 `axe <svc> guide` 출력이고, 복사 텍스트가 CLI 소유 문안을 되풀이하면 낡은 사본이 남는다. |
.axe-prompt-card__slot | stable | 복사 원본 상자. **보이고 선택 가능해야 한다** — display:none 버퍼로 만들면 클립보드 API 가 막힌 환경에서 사용자에게 남는 경로가 0 이 된다. 담기는 문자열이 곧 골든 벡터의 바이트라 개행을 보존한다. |
.axe-prompt-card__copy | stable | 복사 트리거. 비활성은 disabled 가 아니라 aria-disabled 다 — disabled 는 포커스를 뺏어 포커스 불변 규약을 깬다. |
.axe-prompt-card__status | stable | role="status" live region. 성공·실패 **둘 다** 여기로 announce 된다. aria-describedby 대상의 textContent 변경은 SR 이 읽지 않으므로 이 노드가 필요하다. |
.axe-prompt-card__notice | stable | 카드 안 고지 한 문단. 복사 타깃 **바깥**이라 복사 텍스트에 들어가지 않는다. |
.axe-session-head | stable | 목록 머리 행 — 제목 왼쪽, 액션 아이콘 오른쪽. 선택 요소다. 제목은 기존 .axe-workspace-context__section-title 을 그대로 쓰되 그 아래 여백을 이 행이 대신 가져간다 — 제목이 자기 margin 을 들고 있으면 28px 아이콘과 세로 중심이 어긋난다. **이 자리에서 그 제목은 제목 위계다(0.38.5)** — base .axe-workspace-context__section-title 은 .axe-workspace-context__eyebrow 와 선언을 공유해(mono·text-xs·uppercase·자간·muted) 페이지 섹션 캡션에 맞춰져 있는데, 세션 레일 머리는 캡션이 아니라 그 레일의 제목이라 그대로 두면 한글 '세션' 이 mono·초소형·muted 로 눌리고 영문은 SESSIONS 로 대문자화돼 위계가 어긋난다. 그래서 .axe-session-head 스코프에서만(특정도 (0,2,0) 이라 base __section-title (0,1,0) 을 이긴다) eyebrow 공유 선언을 덮어 sans·text-sm·semibold·--workspace-text 로 세운다(레일 행 이름 .axe-session-item__name · .axe-workspace-context__title 과 같은 눈금). **다른 자리의 __section-title(페이지 섹션 캡션)은 불변** — 이 오버라이드는 .axe-session-head 안에서만 성립한다. **타이틀 요소는 소비자가 문서 개요에 맞게 정한다(h2·p·div 등)** — 계약은 클래스와 head 행 배치이지 태그가 아니다(예제의 <h2> 는 예시일 뿐, 실제로 cortex=p · gate=div · blueprint=h2). 세션 목록 블록의 '태그까지 계약' 규칙은 목록(ul/li/a/span)에 걸리는 것이고 head 타이틀 태그는 그 예외다: 같은 어휘가 어떤 화면에서는 섹션 제목이고 어떤 화면에서는 레일 라벨이라, 태그를 고정하면 한쪽의 heading 개요가 반드시 틀어진다. |
.axe-session-head__actions | stable | 머리 행 오른쪽의 아이콘 묶음 <div>. **<span> 이 아니다** — <span> 은 phrasing content 만 담을 수 있어 안의 <details>(생성 폼)가 마크업을 무효로 만든다(<a> 안에 <button> 을 넣지 않는 것과 같은 근거). display 는 CSS 가 정하므로 잃는 것이 없다. 행 액션(.axe-session-item__actions)과 달리 hover 로 숨지 않는다 — 목록이 비어 있을 때도 '새 세션' 은 눌려야 하고, 숨은 생성 경로는 없는 것과 같다. |
.axe-session-head__button | stable | 머리 행의 28×28 아이콘 버튼. 레일 brand 행(검색·접기 토글)의 28px 리듬을 받는다 — bar 의 44px 아이콘 버튼은 화면 크롬의 치수라 여기 놓으면 한 줄 제목이 버튼 높이에 끌려 구역 간격이 무너진다. <button>·<a>·<summary> 어느 태그로도 설 수 있게 list-style 과 ::-webkit-details-marker 를 지운다. :disabled 는 opacity 로 물러난다 — author color 를 준 순간 UA 회색은 지므로 흐림을 이쪽이 직접 말해야 하고, opacity 라야 currentColor 로 그린 svg 까지 함께 물러난다. hover 는 :not(:disabled) 로 막는다(누를 수 없는 것이 hover 에 반응하면 거짓 affordance). 네이티브 button[disabled] 는 포커스를 받지 못해 키보드 사용자가 닿지 못하므로, 비활성 사유는 인접한 가시 텍스트가 져야 한다(레일의 안내 문구처럼). 사유를 버튼 자신이 지고 포커스로도 발견돼야 하면 aria-disabled 를 택한다 — :disabled 와 [aria-disabled="true"] 에 **같은 시각**을 주므로 그 선택이 재료를 잃는 일이 되지 않고, <a> 로 세운 버튼은 :disabled 가 성립하지 않아 aria-disabled 가 유일한 표기이기도 하다. **단 aria-disabled 는 상태를 전달할 뿐 아무것도 막지 않는다** — 클릭 핸들러·링크 네비게이션·<summary> 토글이 그대로 산다. 킷은 시각과 의미까지만 주고 **활성 억제는 소비자 의무**다(핸들러 진입 가드 + 링크·summary 면 기본동작 방지). 억제 구현 없이 aria-disabled 만 붙이는 것은 금지 — '못 누른다' 고 말해 놓고 눌리는 표면이 된다. 킷이 pointer-events: none 을 깔지 않는 것도 같은 이유다: 마우스만 막고 키보드 활성은 남기는 반쪽 억제라, 억제한 줄 알고 넘어가게 만든다. |
.axe-session-head__new | stable | [+] 버튼이 여는 생성 폼의 <details> 래퍼. JS 0 인 CSS-only 소비자가 팝오버를 여닫을 수 있는 유일한 수단이고, 상태가 DOM 에 있으므로 React 소비자도 같은 마크업을 쓴다(두 렌더러가 갈라지지 않는다). position:relative 는 __new-panel 의 앵커다. |
.axe-session-head__new-panel | stable | 생성 폼 팝오버 <form>. .axe-workspace-disclosure__panel 과 정신은 같되(네이티브 details · JS 0) 조판이 다르다 — 저쪽은 화면 가장자리의 fixed 전면 서랍이고 이쪽은 트리거에 앵커된 240px 소형 팝오버다. 오른쪽 끝에 맞춰 왼쪽으로 연다(머리 액션은 항상 레일 오른쪽 끝이라 오른쪽으로 열면 잘린다). **여닫힘은 author 가 정본으로 말한다**: 닫힘은 display:none 으로 명시하고 상자는 [open] 에서만 세운다 — 닫힌 <details> 를 감추는 것은 UA 스타일시트인데 UA origin 은 author 에 지므로, 패널에 display 를 무조건 주면 그 UA 규칙을 뚫어 닫힌 채로 떠 있는 엔진이 생긴다(감춤 수단도 엔진마다 content-visibility / slot display 로 갈린다). 이 계약은 __new-panel 이 언제나 .axe-session-head__new 의 직계 자식임을 전제한다. 입력·제출 재료는 주지 않는다 — .axe-input · .axe-btn 을 조립한다. |
.axe-session-list | stable | 협업 세션 목록 <ul>. 자식은 <li>(.axe-session-item) 만. 우측 컨텍스트 레일(**ContextPanel@1 슬롯 ⓪**)과 작업면 인덱스 양쪽에 같은 어휘로 선다. **슬롯 ⓪ = 세션은 우측 패널 최상단**(0.38.3 가산 개정): 선택적이되 존재하면 우측 컨텍스트 패널의 **첫 시각 요소**다 — .axe-workspace-context 의 첫 자식이거나 같은 셸 호스트(.axe-app-shell__toc · .axe-workspace-disclosure__panel) 안에서 그 앞 형제. 세션 구역보다 먼저 나오는 시각 요소는 없다: 배너·헤더·프롬프트 카드·페이지 섹션 전부 뒤다. 기존 3슬롯(①help ②prompt ③dock)의 상호 순서는 불변이며 ⓪ 은 그 앞에 서는 새 불변식이다. 전용 슬롯 클래스는 만들지 않는다 — 자리가 곧 계약이고 재료는 이 목록과 .axe-session-head 다. 계약이 자리를 잡아 주지 않는 동안 소비자 넷이 각자 다른 높이에 세션을 세웠고(실측 2026-08-30: index=유일 내용 · blueprint=배너·헤더·프롬프트 카드 뒤 · cortex=페이지 섹션 뒤 · gate=슬롯① 말미), 같은 셸의 같은 자리가 서비스마다 다르면 그것은 네 개의 제품이다. 앞-형제 갈래의 높이 부작용(패널 min-height:100% + 형제 높이 = 상시 스크롤)은 킷 CSS 의 :not(:first-child) 가드가 되돌리므로 **소비자 조치는 없다**. **여백도 두 자리 모두 킷이 준다(0.38.4) — 소비자는 슬롯 ⓪ 에 로컬 padding 을 세우지 않는다.** 앞-형제 갈래는 .axe-workspace-context__body 로 감싸 그 클래스의 var(--space-4) 를 받고(패널 밖이라 그 클래스의 flex:1 1 auto 가 아무것도 먹지 않는다), 첫-자식 갈래는 킷이 세션 어휘에 직접 준다 — 패널 직계 자식인 .axe-session-head · 이 목록 · 빈-줄 __summary, 또는 그것들을 담은 래퍼(:has(> .axe-session-head))가 padding-inline: var(--space-4) 를 받고 run 의 양 끝이 세로 여백을 든다. **첫-자식 갈래에서 __body 로 감싸지 말 것**: flex:1 1 auto 라 패널 안 첫 자식으로 서면 남는 높이를 통째로 먹어 ①help 를 바닥으로 밀어낸다(그 클래스가 패널 안에 정당하게 서는 경우는 그것이 패널의 본문 영역 자체일 때뿐이고, 그때는 세션이 그 본문의 첫 요소로 들어간다). **빈 목록은 계약이다** — compact 레일에서 목록이 비면(설정은 되어 있고 0행) <ul> 을 빈 채로 내지 말고 그 자리에 <p class="axe-workspace-context__summary">아직 세션이 없습니다.</p> 를 세운다. 머리(.axe-session-head)는 그대로 남는다 — 생성 버튼은 목록이 비었을 때 가장 필요하다. 빈 공간은 '아직 없다' 가 아니라 **'고장났다'** 로 읽히고(레일에 구역 제목만 있고 아래가 비면 로딩 실패와 구분되지 않는다), 문안을 계약이 못 박는 이유는 소비자마다 다른 말을 세우면 같은 셸의 같은 자리가 서비스마다 다른 제품처럼 보이기 때문이다. 설정 자체가 없는 표면(세션을 서빙하지 않는 서비스)은 이 문구가 아니라 섹션 전체를 내지 않는다 — '없음' 과 '해당 없음' 은 다른 말이다. |
.axe-session-list--compact | stable | 레일용 한 줄 행 modifier. __link 를 32px 가로 행으로 눕힌다 — 컨텍스트 레일의 위치 링크(.axe-workspace-context__link)와 같은 리듬이라 그 위아래 어디에 서도 같은 눈금에 앉는다. 이 modifier 를 쓰는 행은 __meta 를 **아예 내지 않는다**(숨기는 것이 아니라 만들지 않는다) — 메타는 __link 의 title 로 옮긴다 — **title 은 메타의 정본이 아니라 포인터 사용자의 지름길이다**. 메타의 접근성 정본은 컴팩트 행이 아니라 클릭 한 번 거리의 상세·목록 페이지이고, 거기서는 같은 값이 가시 __meta 로 선다(그래서 title 이 SR·터치·키보드에 닿지 않는 것이 이 자리에서는 결손이 아니다). 행이 반드시 져야 하는 것은 이름과 상태뿐이며 그 둘은 __name 과 aria-label 이 진다. __actions 는 그대로 쓰되 **compact 안에서는 28px 리듬**이다: 이 modifier 가 액션 요소의 **높이만** 28px 로 캡하고 폭은 auto 로 되돌린 뒤 28px 바닥을 깐다(flex-basis 포함) — 아이콘 재료는 28px 정사각이 되고, 글자 재료("이름"·"복사"·"복사됨")는 제 폭대로 선다. 폭까지 못 박으면 그 라벨이 잘리고, 폭을 44px 재료에 맡기면 opacity 로만 숨는 __actions 특성상 그 폭이 항상 자리를 차지해 세션 이름을 일찍 자른다. <details><summary> 로 세운 액션도 같은 캡을 받되, 그 details 가 여는 패널 **안의** 요소는 건드리지 않는다(제출 버튼이 짓눌리면 폼이 깨진다). 기본 44px 두 줄은 작업면 목록이 계속 쓴다. **이 modifier 안에서만 완료·보관 행의 이름이 55% 로 눕는다(0.38.9)** — 도트는 불변이고 hover·:focus-visible·현재 행에서 원복하며, 기본(두 줄) 목록은 __meta 안 가시 배지가 상태를 지므로 대상이 아니다(.axe-session-item__name 항목 참조). **레일 안에서는 좌측 내비 리듬을 상속한다** — `.axe-workspace-context` 안 **어디서나**(0.38.4 — 슬롯 ⓪ 이 `__body` 래퍼 없이 평평하게 선 첫-자식 갈래를 포함한다), 그리고 패널 **밖**에서 `.axe-workspace-context__body` 를 래퍼로 빌려 쓴 앞-형제 갈래에서, 이 목록은 좌측 `.axe-workspace-rail__link` 와 같은 인셋(8px) · 행높이(30px, `--workspace-nav-row-h`) · radius(8px) · 행간(1px, `--workspace-nav-row-gap`) · resting 색(muted 로 눕고 hover 와 현재 행에서만 밝아진다)으로 다시 선다. 셸의 좌우 두 기둥이 같은 눈금을 써야 하기 때문이며(한쪽만 다른 리듬이면 그 차이가 곧 소음이다), 32px·`--radius-md`·상시 밝은 이름은 이 자리 **밖**(작업면 목록·팝오버)에서의 기본값으로 남는다. 0.38.3 까지는 스코프가 `__body` 하나뿐이라 첫-자식 갈래가 이 정제를 못 받고 좌측과 눈금이 갈렸다 — 계약이 자리를 둘 허용하면 정제도 두 자리에 서야 한다. 되무는 것은 목록의 hover pill 뿐이라 머리(.axe-session-head) 제목과 행 이름의 x 는 그대로 맞물린다. 소비자가 할 일은 없다 — 이 정렬은 셸 스코프가 자동으로 준다. |
.axe-session-item | stable | 목록의 행 <li>. 클릭면은 이 요소가 아니라 그 안의 __link 다 — 행에 hover 액션이 붙는데 <a> 안의 <button> 은 유효한 마크업이 아니라서 둘을 나눴다. |
.axe-session-item--active | stable | 현재 열린 세션 행. 배경 tint 하나로 표현하며, 셸 안에서는 entity·계정 팝업 행과 같은 재료를 받는다. 이 modifier 는 **시각만** 바꾸므로 __link 의 aria-current="page" 와 함께 준다. |
.axe-session-item__link | stable | 행의 클릭면 <a> 또는 <button>. 두 줄(__name·__meta)을 세로로 담고 44px 터치 높이를 지킨다. aria-current 가 있으면 --active 와 같은 tint 를 받는다. |
.axe-session-item__dot | stable | status 도트 <span>. 목록 행에서는 __link 의 **첫 자식**이고 언제나 aria-hidden="true" 다. 8px 상자 · 1.5px 테두리이며 기본은 빈 원(완료·미지정). **flex/grid 자식이 아닌 자리에서도 성립한다** — inline-block + vertical-align:middle 을 스스로 들고 있어 상세 페이지의 상태 한 줄(<p> 안 글 흐름)에 그대로 놓을 수 있다(선언이 없으면 <span> 기본값 inline 이라 width/height 가 무시돼 도트가 사라진다). flex·grid 문맥에서는 자식이 어차피 블록화되므로 이 선언이 아무것도 바꾸지 않는다. **색으로만 말하는 표현**이라 상태를 글로 지는 짝이 필요한데, 그 짝은 조판마다 다르다. **--compact 행**: __meta 가 없어 도트가 유일한 상태 신호이므로 __link 에 aria-label="{세션 이름} — 진행 중|완료" 가 **의무**다(메타는 title 로). **기본(두 줄) 행**: aria-label 을 **걸지 않는다** — aria-label 은 자손 텍스트를 덮으므로 걸는 순간 가시 __meta(entity·시각·게재수·배지)가 접근 이름에서 통째로 사라진다. 여기서는 자손 텍스트가 접근 이름의 정본이고 상태는 __meta 의 가시 배지가 진다. 그 배지는 **비-기본 상태(완료·보관)에만** 세운다 — **무표기가 곧 진행 중**이다(marked-exception 관례, blueprint 보드 선례). 전 행에 "진행 중" 배지를 달면 대부분의 행이 같은 말을 반복해 목록이 소음이 된다. 관례는 시각·SR 에 똑같이 적용되므로 active 행에 상태 텍스트가 없는 것은 누락이 아니라 계약이다. 색만 있는 구분은 색각 이상에서 사라지고 SR 에는 처음부터 없다. 마크업은 compact·기본 두 조판이 동일하고 CSS 가 가른다: 기본(두 줄) 행은 도트가 있으면 grid 로 전환돼 도트·이름이 첫 줄, __meta 가 둘째 줄 전폭에 선다(세로 flex 그대로면 도트가 이름 **위** 딴 줄에 선다). :has() 미지원 브라우저는 그 옛 동작으로 남는 점진 향상이다. |
.axe-session-item__dot--active | stable | 진행 중 = 채운 초록 원(--success). 상태→변형은 active→--active · done→--done · archived→--archived 로 1:1 이다. --done 과는 **채움 vs 빈 원**이라 색을 빼도 형태로 갈린다 — 이 둘만 쓰는 목록은 도트만으로도 시각이 성립한다(compact 행이라면 __link 의 aria-label 은 그래도 의무다). 기본 행에서는 **상태 배지를 달지 않는 것이 정상**이다 — 무표기가 곧 진행 중이라는 marked-exception 관례를 따르므로, 배지가 서는 것은 --done·--archived 행뿐이다. forced-colors 에서는 CanvasText 로 채워 완료(빈 원)와 형태로 갈린다. |
.axe-session-item__dot--done | stable | 완료 = 빈 원(기본값과 같은 그림). 그래도 명시 클래스를 붙이는 이유는 '상태를 따져 done 이었다' 와 '상태를 안 붙였다' 가 마크업에서 구분돼야 하기 때문이다 — 같으면 매핑이 틀려도 아무도 모른다. |
.axe-session-item__dot--archived | stable | 보관 = 채운 회색 원. 레일에는 보관 세션이 서지 않으므로 사실상 목록 페이지 전용이다. **단독 사용 금지** — --active 와 채움이 같아 색 하나로만 갈리고, 그 색은 색각 이상·흑백 인쇄·고대비 모드에서 사라진다. 보관 도트를 쓰는 행은 가시 상태 텍스트(배지 등)를 반드시 병기한다(도트는 그 텍스트의 장식이지 상태의 유일한 전달자가 아니다). |
.axe-session-item__name | stable | 세션 이름 <span>. 한 줄로 자른다 — 레일에서 두 줄로 흐르면 목록의 리듬이 무너지고, 전문은 세션 페이지 헤더에 있다. **--compact 목록에서 완료·보관 행의 이름은 55% 로 눕는다(0.38.9)** — `:has(> .axe-session-item__dot--done | --archived)` 로 도트를 보고 킷이 직접 준다(소비자 조치 없음). 점은 불변이고 상태의 정본도 그대로 도트·aria-label 이다 — 흐림은 그 위에 얹은 보조 신호다. hover·:focus-visible·현재 행([aria-current] 또는 --active)에서 원복하고, forced-colors 에서는 아예 걸지 않으며(명도 축이 없어 흐림이 소실이 된다) reduced-motion 에서는 전환만 사라진다. 기본(두 줄) 목록은 대상이 아니다 — 거기서는 __meta 안 가시 배지가 상태를 진다. |
.axe-session-item__meta | stable | entity 코드 · 상대시간 · 게재수 <span>. flex 라 .axe-badge 를 안에 넣어도 같은 줄에 서고, 넘치는 만큼은 잘린다(항목마다 말줄임 위치를 협상하지 않는다). |
.axe-session-item__actions | stable | __link 의 형제로 서는 행 액션 묶음 <div>(이름변경·프롬프트 복사). 머리의 __actions 와 같은 이유로 <span> 이 아니다 — 이름변경도 <details> 로 열 수 있다. 선택 요소 — 없어도 레이아웃이 깨지지 않으므로 CSS-only 소비자는 생략한다. 포인터가 있는 환경에서만 opacity 로 숨는다(display 로 접으면 hover 마다 이름 폭이 널뛴다). 버튼 재료는 이 계약이 주지 않으며, --compact 안에서는 그 재료의 높이가 28px 로 캡되고 폭은 auto + 28px 바닥이 된다(아이콘=정사각, 글자=제 폭). 직계 액션과 직계 <details> 의 summary 만 캡 대상이고, 그 details 가 여는 패널 안은 제외된다. **재료는 텍스트 라벨이 아니라 아이콘을 쓴다** — React 는 @axe/ui 의 RenameIcon · CopyIcon 을 import 하고 CSS-only 는 예제의 d= 경로를 복사한다(28px 리듬 · 16px 글리프). 글자 라벨("이름"·"복사")도 서기는 하지만, __actions 는 opacity 로만 숨으므로 **숨은 동안에도 폭을 상시 점유한다** — 라벨 두 개면 실측 ~100px 이 모든 행에서 세션 이름의 말줄임을 그만큼 앞당긴다(아이콘 두 개는 56px). 동작은 aria-label 이 밝힌다 — 아이콘만 남기고 접근 이름까지 버리면 그 행의 액션은 SR 사용자에게 이름 없는 버튼 둘이 된다. |
.axe-session-post | stable | 세션 게재물 카드 <article> — 로컬 Claude Code/Codex 가 올린 대화 기록 1건. |
.axe-session-post__head | stable | 게재물 머리 <header>. 감싸도록(wrap) 둔다 — origin 라벨이 길어질 때 시각을 밀어내는 것보다 줄을 하나 더 쓰는 편이 읽힌다. |
.axe-session-post__author | stable | 게재 시점의 표시명(이름 또는 이메일). |
.axe-session-post__origin | stable | 어느 로컬에서 한 작업인지(예 claude-code@mac-mini). **자리만 주는 클래스** — 배지로 보이려면 .axe-badge 를 함께 붙인다(배지 선언이 뒤에 와 재료는 배지가 이긴다). 배지 없이 써도 mono 한 줄로 읽힌다. |
.axe-session-post__time | stable | 게재 시각 <time>. 머리 오른쪽 끝에 margin auto 로 붙어 origin 이 없어도 자리가 흔들리지 않는다. |
.axe-session-post__body | stable | 게재물 본문. 조판을 스스로 정하지 않는다 — 마크다운이면 .axe-prose 를 함께 붙이고, 이 클래스는 붙여넣은 전사가 카드를 가로로 밀어내지 않게만 막는다. |
.axe-session-post__body--plain | stable | 마크다운 렌더러가 없는 CSS-only 소비자가 원문 전사를 그대로 세울 때 — pre-wrap 이라 개행이 살고, 카드 폭은 부모 __body 규칙이 이미 지킨다. 태그는 <pre> 권장(복사 시 개행 보존). |
접근성
키보드, ARIA, 구현 노트를 함께 검토합니다.
- Tab 첫 스텝에서 .axe-skip-link 가 나타나 Enter 로 #main 본문 점프
- ≤1023px subnav: <summary> 에서 Enter/Space 로 펼침/접힘(네이티브 <details>)
- subnav 펼침 중 Esc — 닫고 summary 로 포커스 복원
- workspace disclosure는 summary에서 Enter/Space로 열고, host JS가 Esc·외부 클릭·링크 이동 시 닫은 뒤 trigger로 포커스를 복원
- picker 팝업(.axe-workspace-picker-dialog)은 native <dialog>.showModal() — Esc와 backdrop 클릭이 닫고, focus trap·포커스 복원은 브라우저가 맡는다. 서랍 안에서 열 때는 팝업을 그 서랍의 DOM 안에 둔다: trigger 가 살아 있어야 브라우저가 포커스를 돌려줄 수 있고, 바깥에 두면 서랍의 modal 장치가 팝업을 차단한다.
- 서랍 위에 팝업이 떠 있는 동안 Esc 는 팝업만 닫는다. 서랍 런타임의 Esc 훅에서 preventDefault 로 막으면 안 된다 — <dialog> 를 닫는 것이 바로 그 키의 default action 이라, 막는 순간 둘 다 Esc 에 반응하지 않는다. window 의 capture 단계에서 전파만 끊는다(document capture 보다 먼저 도착).
- 레일 토글(.axe-workspace-rail-toggle): 네이티브 <button> 이므로 Enter/Space 로 접기·펼치기. 포커스는 버튼에 남는다 — 접기는 이동이 아니라 표시 전환이라 포커스를 옮기지 않는다. 두 인스턴스 중 눌린 쪽이 사라지고 반대편이 나타나므로, 접기·펼치기 직후 Tab 순서상 토글의 위치가 레일 머리와 bar 좌측 사이를 오간다(양쪽 다 chrome 의 첫 구역이라 순서 자체는 뒤집히지 않는다).
- 복사 트리거(.axe-prompt-card__copy): 네이티브 <button>. 누른 뒤에도 포커스는 버튼에 남는다 — 훅이 focus() 를 호출하지 않고, 비활성도 disabled 가 아니라 aria-disabled 로 표현한다(disabled 는 포커스를 빼앗아 키보드 사용자를 그 자리에서 튕겨낸다).
- 도크가 폭 전환으로 다른 사본에 옮겨 붙을 때 포커스는 소비자가 복원한다 — 노드 이동은 DOM 명세상 포커스를 body 로 보낸다. 이동 전 activeElement 와 selectionStart/End 를 기록하고 이동 후 되돌리지 않으면, 작성 중이던 입력에서 커서가 사라진다.
본문은 <main id="main">, sidebar/toc 는 각각 <aside aria-label>. ≤1023px 보조 네비는 네이티브 <details>/<summary> — <summary> 가 expanded 시맨틱을 스스로 고지하므로 aria-expanded 를 수동 부여하지 않는다(이중 고지 방지). 기본 subnav는 sidebar/toc ReactNode를 데스크톱 컬럼과 모바일 패널에 두 번 렌더하지만 미디어쿼리가 항상 한쪽만 표시해 숨긴 쪽은 AX 트리에서 제외된다. 단 소비자가 직접 지정한 DOM id·htmlFor·aria-labelledby는 복제될 수 있으므로 별도 고유 id 노드 또는 subnav={false}가 필요하다. 레일 토글은 <details> 가 아니라 평범한 <button> 이라 상태를 스스로 고지하지 못한다 — 셋 다 필수다: aria-expanded(레일이 *보이면* true, 접히면 false), aria-controls=제어 대상 레일의 DOM id, aria-label(예 "사이드 패널 접기/펼치기"). 토글 인스턴스는 둘이지만 계약이 상태마다 하나만 display 하므로 숨은 쪽은 AX 트리에서 제외된다 — 둘 다 같은 레일 id 를 가리키고 aria-expanded 도 같은 값으로 동기화한다(라벨 중복은 관측 가능한 중복이 아니다). 레일이 데스크톱 컬럼과 서랍에 두 번 렌더되는 소비자는 aria-controls 가 가리키는 id 를 데스크톱 인스턴스에만 주고, 접기 토글도 그 인스턴스의 brand 행에만 둔다(서랍의 레일은 접을 대상이 아니다).
**ContextPanel@1 복사 규약 (K1–K5) — 킷은 이 표면의 JS 를 배포하지 않는다.** 셀렉터만 계약이다: 트리거 [data-axe-copy], 원본 [data-axe-copy-source="<local>"], 상태 [data-axe-copy-status], 복사 타깃 [data-axe-copy-target]. <local> 은 문서 id 가 아니라 카드 안의 로컬 이름이며(슬롯 ①② 의 id 금지와 정합), 훅은 다섯 가지를 지킨다 — K1 루트 상대 해석: trigger.closest('[data-axe-ctx]') 안에서 원본·상태를 찾고 패널 밖에서만 문서 폴백을 쓴다(그래야 기존 복사 버튼이 무변경으로 산다). K2 성공·실패 둘 다 announce: 상태 노드는 role="status" 이고, 실패를 조용히 삼키면 SR 사용자는 성공도 실패도 듣지 못한다. K3 실패는 수동 경로를 남긴다: 원본은 보이는 선택 가능 노드이며 display:none 버퍼를 쓰지 않는다. K4 포커스 불변: focus() 호출 금지, disabled 대신 aria-disabled. K5 트리거 accessible name 불변: 시각 텍스트가 '복사 → 복사함' 으로 바뀌어도 이름은 고정한다. K6(다중 인스턴스 동일 동작)은 K1–K5 의 따름정리다. **기계 강제 범위 — 이 여섯은 산문 규약이고 킷 자신은 강제하지 못한다(JS 를 배포하지 않으므로).** 0.33.0 에서 기계 강제는 컨텍스트 패널의 복사 타깃을 실제로 렌더하는 소비자 한 곳(gate)의 DOM 하네스에만 존재한다. React 소비자는 이 릴리스의 강제 대상이 아니다. 무JS(jinja/maud) 환경에서도 subnav와 workspace disclosure는 네이티브 <details> 토글로 동작한다. Host JS가 있으면 패널 링크·외부 pointerdown·Esc 닫기와 포커스 복원을 얹는다. 검색은 정적 .axe-cmdk markup만으로 모달 동작이 완성되지 않으므로 native dialog 또는 동등한 focus 관리 runtime이 필요하다. 접기 토글은 **두 자리에 같은 버튼을 렌더**한다. 데스크톱에서 레일은 sticky 로 topbar 보다 위에 서서 bar 좌측 칸(280×56)을 덮으므로, 펼침 상태에서 그 칸의 버튼은 보이지도 눌리지도 않는다. 그래서 펼침용 토글은 레일 자신의 brand 행(검색 뒤)에 살고 접히면 레일과 함께 사라지며, 그때 비로소 열린 bar 좌측 칸의 인스턴스가 이어받는다. ≥1024px 에서 정확히 하나가 보이고, 그 아래에서는 둘 다 감춰진다(서랍이 그 일을 한다). 레일 접기는 CSS-only kit 에 런타임이 없다 — 소비자가 구현해야 하는 몫은 셋뿐이다: (1) 클릭 시 셸 루트의 data-rail-collapsed 를 토글하고 버튼의 aria-expanded 를 그 반대값으로 맞춘다, (2) localStorage["axe-rail-collapsed:<app>"] 에 "1"/"0" 으로 남긴다(<app> = 서비스 slug — 한 도메인 아래 여러 콘솔이 서로의 선택을 덮지 않게 하는 네임스페이스), (3) 서버 렌더 소비자는 셸 루트 여는 태그 **직후** 에 parser-blocking inline script 로 그 값을 읽어 속성을 미리 복원한다(아래 CSS-only 예제). 복원을 나중으로 미루면 첫 프레임이 펼친 폭으로 그려졌다가 접히는 것이 보인다. localStorage 접근은 try/catch 로 감싼다 — 차단된 환경에서 콘솔 전체가 죽으면 안 된다.