본문으로 건너뛰기
레이아웃 / app-shell

AppShell

페이지 전체 레이아웃 chrome — 슬롯 골격과 Design/Gate 공용 workspace 표면.
레이아웃app-shellStatic specimenapp shell앱셸layout레이아웃
01 · Specimen

먼저 보고, 그다음 계약을 읽습니다

페이지 전체 레이아웃 chrome — 슬롯 골격과 Design/Gate 공용 workspace 표면.

Workbench 불러오는 중…
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 이라 클래스·속성을 주지 않은 셸의 기하는 종전과 같다.

02 · Use

예제

SSOT에 등록된 실제 API 기준 snippet입니다. standalone 배지만 독립 실행 단위이며, fragment는 주변 state·handler 문맥을 생략합니다.

01docs varianttsxfragment
docs variant
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>
02dashboard variant (toc 없음)tsxfragment
dashboard variant (toc 없음)
import { AppShell, Sidebar } from "@axe/ui";<AppShell  variant="dashboard"  sidebar={<Sidebar items={navItems} value={cur} onValueChange={setCur} />}  subnavLabel="탐색">  <section>…대시보드 본문…</section></AppShell>
03CSS-only 골격 (비-React)htmlfragment
CSS-only 골격 (비-React)
<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>
04넓은 캔버스 + 접히는 레일 (React)tsxfragment
넓은 캔버스 + 접히는 레일 (React)
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>
05접히는 레일 — pre-paint 복원 + 토글 배선 (CSS-only)htmlfragment
접히는 레일 — pre-paint 복원 + 토글 배선 (CSS-only)
<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>
06rail brand 행 — entity 칩 + 검색 (CSS-only)htmlfragment
rail brand 행 — entity 칩 + 검색 (CSS-only)
<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>
07서비스 전환 메뉴 — 두 줄 옵션 + 현재 항목 체크 (CSS-only)htmlfragment
서비스 전환 메뉴 — 두 줄 옵션 + 현재 항목 체크 (CSS-only)
<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>
08stacked identity footer — entity 행 + 계정 행 + 계정 메뉴 (CSS-only)htmlfragment
stacked identity footer — entity 행 + 계정 행 + 계정 메뉴 (CSS-only)
<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>
09context rail — ContextPanel@1 슬롯 스택 (⓪ → ① → ② → ③ → ④)htmlfragment
context rail — ContextPanel@1 슬롯 스택 (⓪ → ① → ② → ③ → ④)
<!-- 인스턴스 핸들은 **셸이 소유하는 호스트**에 붙는다. 패널 콘텐츠에는     인스턴스에 의존하는 바이트가 없어야 두 사본이 바이트 동일해진다. --><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 · &lt;2번에서 읽은 대상의 이름&gt;" 으로 맞춰라 (set_session_title 도구가 있으면; 없으면 건너뛴다). 첫 작업을 받으면 "gate · &lt;작업 한 줄&gt;" 로 바꾼다.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>
10협업 세션 — 목록(레일)과 게재물(작업면)htmlfragment
협업 세션 — 목록(레일)과 게재물(작업면)
<!-- 세션 섹션 = 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>
03 · React

Props

TSX 소스가 진실입니다. 주요 export 컴포넌트의 public API만 노출합니다.

이름타입필수기본값설명
variant"docs" | "dashboard" | "landing""docs"그리드 골격 선택. data-shell 속성으로 CSS 가 분기.
topbarReact.ReactNode상단 바 슬롯(보통 <TopNav>).
sidebarReact.ReactNode좌측 네비 슬롯(보통 <Sidebar>). landing 에서는 무시.
tocReact.ReactNode우측 목차 슬롯(보통 <Toc>). variant="docs" 에서만 표시.
footerReact.ReactNode하단 슬롯(보통 <Footer>).
contentClassNamestring본문 <main> 에 추가 className.
contentAs"main" | "div""main"본문 태그. 다른 문서 안에 specimen으로 중첩할 때만 div.
contentIdstring"main"본문 id와 skip-link target.
showSkipLinkbooleantrue스킵 링크 렌더 여부. 중첩 specimen에서는 false.
sidebarLabelstring"Primary"sidebar <aside> 의 aria-label.
tocLabelstring"On this page"toc <aside> 의 aria-label.
subnavReact.ReactNode | false≤1023px disclosure 내용. 기본 = sidebar 재사용. 명시 DOM id가 있는 sidebar는 고유 id의 별도 노드를 넘기거나 false로 끈다.
subnavLabelReact.ReactNode"메뉴"≤1023px disclosure summary 라벨.
childrenReact.ReactNode본문 — <main id="main"> 안에 렌더.
data-rail-collapsed"" | undefined루트 <div> 로 전파되는 boolean DOM 속성(별도 prop 아님 — ...rest 경유). 존재 = 레일 접힘, 부재 = 펼침이며 "false" 로는 끌 수 없다. React 는 collapsed ? "" : undefined 로 쓴다. ≥1024px 에서만 효과가 있고(그 아래는 서랍 담당), 이 속성을 한 번도 주지 않는 셸의 기하는 이 계약 도입 전과 같다.
...restReact.HTMLAttributes<HTMLDivElement>나머지는 루트 <div> 로 전파.
Toc.itemsTocItem[]필수{ id, label, level?: 2 | 3 } 배열. level=2 top / 3 sub(들여쓰기), 기본 2.
Toc.titleReact.ReactNode"On this page"목차 상단 라벨. string 이면 <nav> aria-label 로도 사용.
Toc.activeIdstring현재 활성 heading id(외부 스크롤 spy 가 전달). 해당 링크에 aria-current="location".
04 · Any stack

.axe-* 클래스 계약

React 밖에서도 같은 표면을 그리는 공개 계약입니다. stable은 minor 버전 안에서 이름을 바꾸지 않습니다.

클래스안정성용도
.axe-app-shellstable루트 <div>. data-shell="docs|dashboard|landing" 로 그리드 분기.
.axe-skip-linkstable본문(#main)으로 건너뛰는 스킵 링크(포커스 시 노출).
.axe-app-shell__topbarstable상단 바 영역(topbar + ≤1023px subnav 포함).
.axe-app-shell__mainstablesidebar|content|toc 그리드 행.
.axe-app-shell__sidebarstable좌측 네비 슬롯 <aside>(sticky/scroll).
.axe-app-shell__contentstable본문 <main id="main">. variant 별 max-width.
.axe-app-shell__tocstable우측 목차 슬롯 <aside>(docs 전용).
.axe-app-shell__footerstable하단 footer 슬롯.
.axe-app-shell__toc-titlestableToc 상단 라벨.
.axe-app-shell__toc-liststableToc 링크 <ul>.
.axe-app-shell__toc-linkstableToc 앵커 링크. 활성 시 aria-current="location".
.axe-app-shell__toc-link--h3stableh3(level 3) Toc 링크 — 들여쓰기 변형.
.axe-app-shell__subnavstable≤1023px 보조 네비 <details> disclosure(≥1024px display:none).
.axe-app-shell__subnav-summarystabledisclosure <summary> 토글.
.axe-app-shell__subnav-labelstabledisclosure summary 라벨 span.
.axe-app-shell__subnav-panelstabledisclosure 펼침 패널(네비 복제 렌더).
.axe-app-shell__subnav-tocstablesubnav 패널 안 목차 래퍼(docs + toc 있을 때).
.axe-app-shell__subnav-chevroninternalsubnav summary chevron(장식, [open] 회전은 CSS).
.axe-workspace-shellstableDesign/Gate 공용 neutral workspace root. AppShell docs variant와 함께 사용.
.axe-workspace-shell--widestable셸 루트의 opt-in modifier — 작업면 최대 폭 캡(--workspace-content-max)을 none 으로 푼다. 거터(--app-shell-gutter)는 유지되므로 full-bleed 가 아니라 '넓은 캔버스'다. 표 밀도가 목적 그 자체인 콘솔용.
.axe-workspace-barstable56px workspace top bar.
.axe-workspace-bar__leftstabletop bar 좌측 rail track.
.axe-workspace-bar__centerstablebounded center route track.
.axe-workspace-bar__rightstable검색과 context action track.
.axe-workspace-bar__routesstabledesktop 중앙 route navigation.
.axe-workspace-bar__mobile-brandstablerail이 접힐 때 표시하는 mobile product identity.
.axe-workspace-bar__icon-buttonstable44px menu/context icon trigger.
.axe-workspace-rail-togglestable레일 접기 토글 표식 — 자리가 둘이고 상태마다 하나만 보인다. 펼침용은 .axe-workspace-rail__brand 안(검색 뒤, brand 행의 28px 아이콘 리듬을 계약이 준다), 접힘용은 .axe-workspace-bar__left 안(.axe-workspace-bar__icon-button 과 함께 주면 44px 재료를 받는다). 기본은 display:none 이고 ≥1024px 에서만 해당 인스턴스가 켜진다.
.axe-workspace-disclosurestablenative details 기반 navigation/context sheet root.
.axe-workspace-disclosure--navigationstable≤1023px 좌측 navigation disclosure.
.axe-workspace-disclosure--contextstable<1440px 우측 context disclosure.
.axe-workspace-disclosure__panelstableviewport 고정 disclosure panel.
.axe-workspace-railstablefull-height primary rail flex root.
.axe-workspace-drawerstablerail 을 담은 서랍(모바일 시트·drawer) 표면 표식. 안의 rail 직계 부품은 배경·테두리를 버리고 서랍 하나의 면으로 읽히며, 이음매는 스크롤 영역과 identity footer 사이 1px 한 줄만 남는다. React Sheet(.axe-sheet__content)는 이 규칙을 자동으로 받으므로 표식이 필요 없다 — 서랍을 직접 짓는 maud/jinja 소비자를 위한 클래스.
.axe-workspace-rail__brandstable56px entity/search header.
.axe-workspace-rail__bodystablerail 내부의 유일한 세로 scroll 영역.
.axe-workspace-rail__identitystable45px 고정 identity footer (1px rule + 8 + 28px chip + 8). 계정 칩 하나만 담는 단일 행 — 담을 수 있는 것은 rail composition 계약(contracts/rail-selectors/contract.json)이 정한다.
.axe-workspace-rail__identity--stackedstable77px 2행 identity footer — entity 행 위, account 행 아래. entity 축이 없는 소비자(layer)는 단일 행으로 남으므로 opt-in.
.axe-workspace-identitystableidentity footer의 남는 폭을 채우는 28px identity 칩. 열림은 [open](부모 details)·[aria-expanded="true"]·[data-state="open"] 병렬 수신.
.axe-workspace-identity--entitystablestacked footer의 entity 행 칩 — 선행 18px 마크 + 후행 chevron(위로 열리므로 기본 180° 회전). 라벨은 regular weight.
.axe-workspace-identity__avatarstable18px brand-tone identity avatar.
.axe-workspace-identity__labelstable말줄임 처리되는 identity label.
.axe-workspace-identity-menustablerail 하단에서 위로 열리는 native identity menu.
.axe-workspace-identity-menu__panelstableidentity action panel — 272px(--workspace-menu-w) · 4px 패딩 · 22px 라벨 · 24px 행 · 12px 안쪽선 구분선. native <details> 자식이면 위로 열리는 위치를 스스로 갖고, portal 이면 host popper 가 위치를 잡는다.
.axe-workspace-identity-menu__submenustableidentity 메뉴의 서브패널(언어·표시 등). 같은 재료, min-width 168px.
.axe-workspace-rail__navstable30px row와 1px seam을 소유하는 navigation.
.axe-workspace-rail__group-labelstable대메뉴 또는 비상호작용 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__linkstablehover/active/focus가 정렬된 navigation link.
.axe-workspace-entitystablerail brand 행의 entity 칩. <details> root, 또는 trigger 자신(<a>/<button>). 열림은 [open]·[aria-expanded="true"]·[data-state="open"] 병렬 수신.
.axe-workspace-entity--staticstable전환할 entity 가 없는 단일-도메인 소비자용 정적 칩. 같은 칩 스킨, chevron·hover/open·cursor 없음.
.axe-workspace-entity__labelstable말줄임 처리되는 entity 칩 라벨.
.axe-workspace-entity__menustableentity/서비스 전환 메뉴 표면 — 272px(--workspace-menu-w) · 4px 패딩 · radius 12. portal 로 셸 밖에 렌더돼도 같은 재료가 되도록 workspace 변수를 자체 선언.
.axe-workspace-entity__rowstable메뉴 옵션 한 행 — copy | 우측 슬롯(meta 또는 현재 항목 체크 svg) 2열 grid. 두 줄 옵션이면 행이 52px 로 자란다. Radix 소비자는 .axe-dropdown-menu__item-label 이 같은 격자를 받는다.
.axe-workspace-entity__copystable옵션 이름(strong, 20px)과 설명(small, 18px) 스택 — 사이 2px.
.axe-workspace-entity__metastable옵션 우측 mono 짧은 식별자.
.axe-workspace-searchstabletext 또는 data-icon-only 검색 trigger. rail brand 행과 top bar 우측에서 같은 elevated 칩.
.axe-workspace-search-dialogstablestatic CommandPalette markup을 native dialog로 호스트하는 workspace 검색 표면.
.axe-workspace-picker-dialogstablerail 전환기(서비스·entity·계정)의 접힌-폭 팝업 — 검색과 같은 native <dialog> 표면. top layer라 --workspace-* 를 자체 선언한다. Esc·backdrop 클릭은 native, 닫기 버튼은 close().
.axe-workspace-pickerstablepicker 팝업 패널 — 420px · radius 16 · shadow-xl. 검색 팝업(.axe-cmdk)과 같은 floating surface 위계.
.axe-workspace-picker__headerstable팝업 제목(<strong>)과 44px 닫기 버튼 2열 행.
.axe-workspace-picker__closestable44px borderless 팝업 닫기 버튼.
.axe-workspace-picker__liststable팝업 안의 유일한 세로 scroll 영역.
.axe-workspace-picker__labelstable팝업 목록의 group caption(<p>). role="group" + aria-labelledby 로 묶을 때 이름 노드가 된다.
.axe-workspace-picker__itemstable44px 이상 터치 행(button 또는 a). 선행/후행 <svg> 는 제 치수를 지키고 라벨(<span> 또는 .axe-workspace-entity__copy)이 남는 폭을 갖는다. 현재 항목은 aria-current 로 표시.
.axe-workspace-contextstablefull-height 우측 context rail.
.axe-workspace-context__headerstable현재 업무의 제목과 요약.
.axe-workspace-context__eyebrowstablecontext overline.
.axe-workspace-context__titlestablecontext title.
.axe-workspace-context__summarystablecontext 설명. **레일 안 빈 목록의 안내 문구도 이 재료다** — 세션 목록이 비면(설정은 되어 있고 0행) 목록 자리에 <p class="axe-workspace-context__summary">아직 세션이 없습니다.</p> 를 세운다(.axe-session-list 항목 참조). **레일 전용이다(0.38.7)** — 캔버스(작업면)의 보조 고지 한 줄("최근 50건만 표시" 류 절단 고지)에 이 클래스를 빌려 쓰지 말 것. 그 자리의 재료는 `.axe-section__note` 다: 이름이 자리를 말하지 않으면 다음 사람이 레일 리듬을 캔버스로, 캔버스 리듬을 레일로 끌고 온다.
.axe-workspace-context__tabsstable균등 분할 context tab row.
.axe-workspace-context__tabstable현재 tab 하단 indicator trigger/link.
.axe-workspace-context__bodystablecontext panel content.
.axe-workspace-context__section-titlestablecontext 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__linkstable32px context location link.
.axe-workspace-context__footerstableContextPanel@1 슬롯 ④ — 패널을 닫는 고정 하단 한 줄(margin-top: auto). 정본 문안은 화면 이동 단축키 안내(⌘K)다. 서면 **패널 직계 자식의 마지막**이다(0.39.0 닫힌 목록 — ⓪sessions → ①help → ②prompt → ③dock → ④footer). **선다/안 선다는 재량이 아니라 조건이다(0.39.0)**: 명령 팔레트(⌘K)를 서빙하는 소비자(blueprint·gate·cortex)는 필수, 팔레트가 없는 소비자는 생략 — 열리지 않는 단축키 안내는 거짓 어포던스다. 배너·프로모션·상태 배지의 자리가 아니다.
.axe-workspace-context__helpstableContextPanel@1 슬롯 ① — 도움말. 항상 존재 · 접힘 없음. 키 0·동의 0·네트워크 0 인 상태에서도 렌더된다. **최상단이 아니다** — 슬롯 ⓪(세션 구역)이 존재하면 그것이 패널의 첫 시각 요소이고 ① 은 그 아래다(0.38.3 가산 개정). ⓪ 이 없는 표면에서는 종전대로 ① 이 첫 요소다.
.axe-workspace-context__promptstableContextPanel@1 슬롯 ② — 프롬프트 카드 1장이 사는 자리. 조건부이며, 비면 노드를 내지 않는다(빈 카드·placeholder 금지).
.axe-workspace-context__dockstableContextPanel@1 슬롯 ③ — 하단 sticky 도크. in-flow 라 ①② 를 덮지 않는다(덮는 순간 그것은 도크가 아니라 탭이다). chat 을 서빙하는 소비자에만 존재하고, 문서당 노드는 1개다.
.axe-prompt-cardstable슬롯 ② 의 카드. 외부 AI 에 붙여넣을 블록의 미리보기·복사면. 복사 텍스트는 **핸드셰이크형**이다(0.40.0 · D-ops-114) — 붙여넣고 엔터만 치면 에이전트가 오리엔테이션(`~/axe-cli/axe <svc> guide`)을 읽고, 세션 제목을 맞추고, "준비됐습니다. 어떤 작업을 해볼까요?" 로 사람의 첫 작업을 기다린다. **대상이 없는 화면에서도 선다** — 오리엔테이션 항목은 refetch 와 독립이라 서비스만 알면 서고, 카드는 '그 서비스 데이터 조회로 바로 들어가는 프롬프트' 가 된다. 그 상태의 카드 안 안내 권장 문안 = "이 화면에는 대상이 없습니다. <svc> 데이터 조회로 바로 들어가는 프롬프트를 내줍니다." (카드 안 안내이지 복사 텍스트가 아니다). 소비자마다 다른 말을 세우면 같은 셸의 같은 자리가 서비스마다 다른 제품처럼 보이므로 문안을 계약이 권한다.
.axe-prompt-card__linestable카드 미리보기 한 줄(헤더·선언·대상·현재 상태 등). 계약 텍스트의 개행은 조판 줄바꿈이라 여기서는 흘려보낸다 — 바이트는 __slot 과 골든이 지킨다. **미리보기는 요약이어도 되지만 __slot 은 요약하지 않는다**: 복사되는 것은 __slot 의 바이트이고, 그 바이트는 골든 두 벌(6-플레이스홀더 템플릿)에 값을 치환한 결과다(0.40.0). 규율문 6줄은 삭제됐다 — 그 규칙의 SoT 는 `axe <svc> guide` 출력이고, 복사 텍스트가 CLI 소유 문안을 되풀이하면 낡은 사본이 남는다.
.axe-prompt-card__slotstable복사 원본 상자. **보이고 선택 가능해야 한다** — display:none 버퍼로 만들면 클립보드 API 가 막힌 환경에서 사용자에게 남는 경로가 0 이 된다. 담기는 문자열이 곧 골든 벡터의 바이트라 개행을 보존한다.
.axe-prompt-card__copystable복사 트리거. 비활성은 disabled 가 아니라 aria-disabled 다 — disabled 는 포커스를 뺏어 포커스 불변 규약을 깬다.
.axe-prompt-card__statusstablerole="status" live region. 성공·실패 **둘 다** 여기로 announce 된다. aria-describedby 대상의 textContent 변경은 SR 이 읽지 않으므로 이 노드가 필요하다.
.axe-prompt-card__noticestable카드 안 고지 한 문단. 복사 타깃 **바깥**이라 복사 텍스트에 들어가지 않는다.
.axe-session-headstable목록 머리 행 — 제목 왼쪽, 액션 아이콘 오른쪽. 선택 요소다. 제목은 기존 .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__actionsstable머리 행 오른쪽의 아이콘 묶음 <div>. **<span> 이 아니다** — <span> 은 phrasing content 만 담을 수 있어 안의 <details>(생성 폼)가 마크업을 무효로 만든다(<a> 안에 <button> 을 넣지 않는 것과 같은 근거). display 는 CSS 가 정하므로 잃는 것이 없다. 행 액션(.axe-session-item__actions)과 달리 hover 로 숨지 않는다 — 목록이 비어 있을 때도 '새 세션' 은 눌려야 하고, 숨은 생성 경로는 없는 것과 같다.
.axe-session-head__buttonstable머리 행의 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__newstable[+] 버튼이 여는 생성 폼의 <details> 래퍼. JS 0 인 CSS-only 소비자가 팝오버를 여닫을 수 있는 유일한 수단이고, 상태가 DOM 에 있으므로 React 소비자도 같은 마크업을 쓴다(두 렌더러가 갈라지지 않는다). position:relative 는 __new-panel 의 앵커다.
.axe-session-head__new-panelstable생성 폼 팝오버 <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-liststable협업 세션 목록 <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--compactstable레일용 한 줄 행 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-itemstable목록의 행 <li>. 클릭면은 이 요소가 아니라 그 안의 __link 다 — 행에 hover 액션이 붙는데 <a> 안의 <button> 은 유효한 마크업이 아니라서 둘을 나눴다.
.axe-session-item--activestable현재 열린 세션 행. 배경 tint 하나로 표현하며, 셸 안에서는 entity·계정 팝업 행과 같은 재료를 받는다. 이 modifier 는 **시각만** 바꾸므로 __link 의 aria-current="page" 와 함께 준다.
.axe-session-item__linkstable행의 클릭면 <a> 또는 <button>. 두 줄(__name·__meta)을 세로로 담고 44px 터치 높이를 지킨다. aria-current 가 있으면 --active 와 같은 tint 를 받는다.
.axe-session-item__dotstablestatus 도트 <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--activestable진행 중 = 채운 초록 원(--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--donestable완료 = 빈 원(기본값과 같은 그림). 그래도 명시 클래스를 붙이는 이유는 '상태를 따져 done 이었다' 와 '상태를 안 붙였다' 가 마크업에서 구분돼야 하기 때문이다 — 같으면 매핑이 틀려도 아무도 모른다.
.axe-session-item__dot--archivedstable보관 = 채운 회색 원. 레일에는 보관 세션이 서지 않으므로 사실상 목록 페이지 전용이다. **단독 사용 금지** — --active 와 채움이 같아 색 하나로만 갈리고, 그 색은 색각 이상·흑백 인쇄·고대비 모드에서 사라진다. 보관 도트를 쓰는 행은 가시 상태 텍스트(배지 등)를 반드시 병기한다(도트는 그 텍스트의 장식이지 상태의 유일한 전달자가 아니다).
.axe-session-item__namestable세션 이름 <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__metastableentity 코드 · 상대시간 · 게재수 <span>. flex 라 .axe-badge 를 안에 넣어도 같은 줄에 서고, 넘치는 만큼은 잘린다(항목마다 말줄임 위치를 협상하지 않는다).
.axe-session-item__actionsstable__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-poststable세션 게재물 카드 <article> — 로컬 Claude Code/Codex 가 올린 대화 기록 1건.
.axe-session-post__headstable게재물 머리 <header>. 감싸도록(wrap) 둔다 — origin 라벨이 길어질 때 시각을 밀어내는 것보다 줄을 하나 더 쓰는 편이 읽힌다.
.axe-session-post__authorstable게재 시점의 표시명(이름 또는 이메일).
.axe-session-post__originstable어느 로컬에서 한 작업인지(예 claude-code@mac-mini). **자리만 주는 클래스** — 배지로 보이려면 .axe-badge 를 함께 붙인다(배지 선언이 뒤에 와 재료는 배지가 이긴다). 배지 없이 써도 mono 한 줄로 읽힌다.
.axe-session-post__timestable게재 시각 <time>. 머리 오른쪽 끝에 margin auto 로 붙어 origin 이 없어도 자리가 흔들리지 않는다.
.axe-session-post__bodystable게재물 본문. 조판을 스스로 정하지 않는다 — 마크다운이면 .axe-prose 를 함께 붙이고, 이 클래스는 붙여넣은 전사가 카드를 가로로 밀어내지 않게만 막는다.
.axe-session-post__body--plainstable마크다운 렌더러가 없는 CSS-only 소비자가 원문 전사를 그대로 세울 때 — pre-wrap 이라 개행이 살고, 카드 폭은 부모 __body 규칙이 이미 지킨다. 태그는 <pre> 권장(복사 시 개행 보존).
비-React 소비 노트
그리드 분기는 클래스가 아니라 루트의 data-shell="docs|dashboard|landing" 속성이 결정한다 — CSS-only 로 쓸 때 반드시 이 속성을 준다. ≤1023px 보조 네비는 네이티브 <details> 라 JS 없이도 토글되며, 데스크톱 컬럼과 동일 markup 을 subnav 패널에 복제해 넣으면 된다. entity 칩은 두 shape 를 모두 받는다 — <details class="axe-workspace-entity"> + <summary>(무JS), 또는 trigger 자신이 .axe-workspace-entity 인 <button>(팝오버 런타임). 열림 신호는 [open]·[aria-expanded="true"]·[data-state="open"] 를 병렬로 받으므로 프레임워크를 바꿔도 CSS 는 그대로다. chevron 은 trigger 의 svg 자식이 있으면 그것을 회전시키고 없으면 같은 자리에 CSS 글리프를 그린다 — 둘이 동시에 그려지지 않는다. 전환할 entity 가 하나뿐인 소비자는 비-인터랙티브 요소에 .axe-workspace-entity--static 을 더해 같은 칩을 정적으로 받는다. 전환 패널은 <details> shape 에서만 암묵 스타일된다(`details.axe-workspace-entity > :not(summary)`); 그 외 shape(포털·팝오버)는 패널에 .axe-workspace-entity__menu 를 명시해야 한다. 넓은 캔버스는 클래스 하나(.axe-workspace-shell--wide)로 끝나지만 접기는 상태라 JS 가 필요하다 — kit 은 CSS 만 싣고, 소비자는 (1) data-rail-collapsed 토글 + aria-expanded 동기화, (2) localStorage["axe-rail-collapsed:<app>"] 영속화, (3) 셸 루트 여는 태그 직후의 parser-blocking inline script 로 pre-paint 복원 — 이 셋을 구현한다(위 CSS-only 예제가 그대로 복사 가능한 레퍼런스다). 접힘 규칙은 전부 ≥1024px 미디어 블록 안에 있어 그보다 좁은 폭에서는 속성이 남아 있어도 무효다(서랍 동작 불변).
05 · Inclusive

접근성

키보드, ARIA, 구현 노트를 함께 검토합니다.

Keyboard
  • 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 를 기록하고 이동 후 되돌리지 않으면, 작성 중이던 입력에서 커서가 사라진다.
ARIA

본문은 <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 행에만 둔다(서랍의 레일은 접을 대상이 아니다).

Notes

**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 로 감싼다 — 차단된 환경에서 콘솔 전체가 죽으면 안 된다.

06 · Judgment

권장 · 지양

권장
  • 본문 스크롤 대상 랜드마크로 <main id="main"> 를 유지(스킵 링크 타깃).
  • 문서 안에 AppShell을 specimen으로 중첩할 때만 contentAs="div" + 고유 contentId + showSkipLink={false}를 함께 사용.
  • toc 는 variant="docs" 에서만 의미가 있다 — dashboard/landing 에는 넘기지 말 것.
  • sidebar 자손에 명시 DOM id/htmlFor/aria-labelledby가 있으면 고유 id를 쓰는 별도 subnav 노드를 넘기거나 subnav={false}로 중복 렌더를 피할 것.
  • sidebar 를 넘기면 ≤1023px subnav 가 자동으로 그 네비를 재사용한다.
  • 전환할 entity 가 없으면 로컬 CSS 로 칩을 흉내 내지 말고 .axe-workspace-entity--static 을 쓴다 — 같은 스킨에 거짓 affordance 만 빠진다.
  • entity 메뉴를 portal 로 띄우는 소비자는 패널에 .axe-workspace-entity__menu 를 준다 — 셸 밖으로 나가도 같은 재료·치수를 유지한다.
  • rail 을 서랍에 옮겨 그릴 때는 부품마다 테두리를 지우지 말고 서랍 루트에 .axe-workspace-drawer 를 준다 — 기둥에서는 리듬이던 선이 서랍에서는 상자가 되고, 이웃 둘이 각자 그으면 1px 이 2px 이 된다. React Sheet 소비자는 자동 적용된다.
  • context rail 은 ContextPanel@1 의 슬롯 스택으로 짠다: ⓪ 세션(선택) → ① __help(항상) → ② __prompt(조건부) → ③ __dock(조건부). 슬롯이 비면 그 노드를 아예 내지 않는다 — 빈 카드나 placeholder 를 채우지 말 것.
  • 세션 구역(⓪)을 렌더하는 소비자는 그것을 우측 패널의 **첫 시각 요소**로 세운다 — .axe-workspace-context 의 첫 자식이거나 같은 셸 호스트 안에서 그 앞 형제다. 배너·헤더·프롬프트 카드·페이지 섹션을 세션 위에 두지 말 것: 같은 셸의 같은 자리가 서비스마다 다른 높이에 서면 사용자에게는 네 개의 다른 제품이다(0.38.3 이전 실측이 정확히 그랬다). ①②③ 의 상호 순서는 그대로다.
  • 슬롯 ⓪ 에 **로컬 padding 을 세우지 않는다** — 두 자리 모두 킷이 여백과 행 정제를 준다(0.38.4). 앞-형제 갈래는 .axe-workspace-context__body 로 감싸고(패널 밖이라 그 클래스의 flex:1 1 auto 가 무해하다), 첫-자식 갈래는 아무것도 감싸지 않아도 킷이 head/list/빈-줄에 직접 준다. 첫-자식 갈래를 __body 로 감싸는 것은 **금지** — flex:1 1 auto 가 남는 높이를 통째로 먹어 ①help 가 바닥으로 밀린다. 여백을 소비자가 세우기 시작하면 같은 슬롯이 서비스마다 다른 값을 쓰게 되고, 그것이 디자인 SoT 가 갈라지는 첫 걸음이다. **셸 호스트가 자체 세로 패딩을 주면 킷이 그것을 슬롯 ⓪ 에 양보한다(0.38.5)** — base .axe-app-shell__toc 는 문서 TOC 용 세로 패딩(--space-5)을 들고 있어 세션 run 이 서면 호스트 24px + 슬롯 16px = 40px 로 상단이 이중이 됐다. .axe-app-shell__toc:has(.axe-session-head) · .axe-workspace-disclosure__panel:has(.axe-session-head) 가 padding-block: 0 으로 그 세로만 눕히고 가로 인셋은 유지한다(세션 머리 제목·행 x 무변경). 워크스페이스 셸 toc 는 이미 padding:0, 서랍 패널은 세로 0 이라 무변경 — 세 호스트가 상단 단일 층으로 수렴한다. **그 상단 인셋 = 좌측 내비와 같은 토큰 var(--space-2)(0.38.6)** — 하단·가로 인셋은 슬롯을 이웃과 떼는 간격이지만 상단 인셋은 셸의 두 기둥이 같은 높이에서 시작하는가를 정하고, 좌측 내비(.axe-workspace-rail__nav)가 브랜드 행 아래 padding-block-start: var(--space-2) 로 열리므로 우측도 같은 토큰을 쓴다. 0.38.5 까지는 16 이라 세 갈래(gate 평평 · blueprint __body 래퍼 · 서랍) 모두 세션 머리 제목이 상단바 아래 18.5px 에서 시작해 좌측 리듬의 2배였다(1440×900 실측 top 74.5 → 66.5). 앞-형제 갈래는 __body 자신의 padding 에서 상단을 받으므로 :is(.axe-app-shell__toc, .axe-workspace-disclosure__panel) > .axe-workspace-context__body:has(> .axe-session-head) 가 padding-block-start 만 덮는다 — __body 는 범용 본문 클래스라 전역으로 줄이지 않는다. **하단·가로 인셋 · 행 높이(30px) · 액션 버튼 히트영역(28px)은 불변**이고 .axe-session-head 는 터치 타깃이 아니라 구역 머리다(높이는 min-height 가 아니라 내용 28px + 인셋의 합). 소비자 조치는 없다.
  • 인스턴스 핸들 data-axe-ctx="toc|drawer" 는 패널을 담는 **셸 소유 요소**에 준다. 패널 콘텐츠 마크업에는 인스턴스에 의존하는 바이트를 남기지 않는다 — 그래야 두 사본이 바이트 동일해지고, 복사 훅이 closest('[data-axe-ctx]') 로 자기 사본을 찾을 수 있다.
  • 복사 원본은 보이는 선택 가능 노드로 둔다(.axe-prompt-card__slot). 클립보드 API 는 권한·컨텍스트로 막힐 수 있고, 그때 남는 유일한 경로가 손으로 긁어 복사하는 것이다.
  • 복사 성공과 실패를 **둘 다** .axe-prompt-card__status(role="status") 로 announce 한다. 성공만 알리는 훅은 SR 사용자에게 무응답과 실패를 구분해 주지 않는다.
  • 두 줄 옵션(이름 + 한 줄 설명)은 .axe-workspace-entity__row > .axe-workspace-entity__copy 로 짠다 — 행이 52px 로 자라고 우측 슬롯이 현재 항목 체크 svg 를 받는다.
  • 메뉴 글리프는 16px viewBox 24 · stroke 1.6 한 벌로 통일한다. React 는 @axe/ui 의 CheckIcon/EntityIcon/LanguageIcon/DisplayIcon/HelpIcon/LogoutIcon/SwitchAccountIcon/ChevronIcon/PanelLeftIcon(또는 MenuIconFrame 으로 직접 합성)을 import 하고, CSS-only 는 위 예제의 d= 경로를 그대로 복사한다.
  • 레일을 접을 수 있어야 하는 표면에서만 토글을 렌더한다 — 토글이 없으면 data-rail-collapsed 를 쓸 주체가 없고, 그것이 곧 '접히지 않는 표면' 의 표현이다(별도 잠금 플래그를 만들지 말 것).
  • 접기를 채택하면 토글을 *두 자리 모두* 에 렌더한다 — 레일 brand 행(펼침용)과 bar 좌측 칸(접힘용). 하나만 두면 그 상태에서만 접거나 펼칠 수 있어 한쪽으로 갇힌다.
  • 접힘 상태는 셸 루트 속성 하나가 진실이다 — 버튼의 aria-expanded 와 localStorage 는 그 진실의 반영이지 두 번째 상태가 아니다. 세 곳을 각자 갱신하지 말고 속성을 바꾼 뒤 나머지를 그 값에서 파생시킨다.
  • 표가 목적인 콘솔은 .axe-workspace-shell--wide 로 캡을 풀고 남는 폭을 쓴다 — 소비처에서 --workspace-content-max 를 임의 숫자로 다시 잡는 것보다 의도가 드러난다.
  • 협업 세션 목록은 행(.axe-session-item = <li>)과 클릭면(.axe-session-item__link = <a>/<button>)을 나눠 짠다 — 행 액션은 링크의 **형제**로 서야 하고, <a> 안의 <button> 은 유효한 마크업이 아니다.
  • 현재 열린 세션에는 행의 --active 와 링크의 aria-current="page" 를 둘 다 준다 — 어느 쪽이든 tint 는 칠하지만, 시각만 있고 aria 가 없으면 SR 사용자에게 현재 위치가 사라진다.
  • status 도트(.axe-session-item__dot)의 상태는 글로도 져야 하되 **조판마다 다른 짝**을 쓴다: --compact 행은 __meta 가 없으므로 링크에 aria-label="{세션 이름} — 진행 중|완료" 를 주고, 기본(두 줄) 행은 **aria-label 을 걸지 않고** __meta 안 가시 배지가 상태를 지게 한다(기본 행에 aria-label 을 걸면 자손 텍스트를 덮어 보이는 메타가 접근 이름에서 사라진다). 그 배지는 완료·보관에만 세운다 — 무표기가 곧 진행 중이고(marked-exception), 전 행에 "진행 중" 을 달면 목록이 소음이 된다.
  • 레일에서 세션 목록이 비면(설정은 되어 있고 0행) 빈 <ul> 대신 <p class="axe-workspace-context__summary">아직 세션이 없습니다.</p> 를 목록 자리에 세운다 — 빈 공간은 '아직 없다' 가 아니라 '고장났다' 로 읽히고, 문안을 통일해야 같은 셸의 같은 자리가 서비스마다 다른 제품처럼 보이지 않는다. 머리(.axe-session-head)는 남긴다 — 생성 버튼은 목록이 비었을 때 가장 필요하다.
  • 행 액션(.axe-session-item__actions)은 텍스트 라벨이 아니라 **아이콘 재료**로 짠다 — React 는 RenameIcon · CopyIcon, CSS-only 는 예제의 d= 경로를 .axe-workspace-bar__icon-button 에 얹는다(compact 안에서 28px 정사각으로 캡된다). __actions 는 opacity 로만 숨어 **숨은 동안에도 폭을 점유**하므로, 글자 라벨 두 짝은 실측 ~100px 을 모든 행에서 먹어 세션 이름의 말줄임을 그만큼 앞당긴다(아이콘 두 짝은 56px). 동작은 aria-label 이 밝힌다.
  • **압축 목록(--compact)에서 완료·보관 행의 이름은 킷이 55% 로 눕힌다(0.38.9) — 점(.axe-session-item__dot)은 불변이고 소비자 조치는 없다.** 레일은 '지금 무엇이 돌고 있나' 를 먼저 말해야 하는데 끝난 행이 같은 밝기로 서면 그 답이 목록 전체에 흩어지고, 행 스캔에서 8px 도트는 글자 덩어리보다 늦게 읽힌다 — 그래서 글자 쪽에서 한 단계 물러난다. **상태의 정본은 여전히 도트와 aria-label 이며 흐림은 그 위에 얹은 보조 신호다**: 순서를 뒤집어 흐림이 상태를 지게 하면 상태가 다시 '색·명도로만 말하는 표현' 이 되어 위 규약이 세운 짝이 무너진다(도트를 같이 흐리지 않는 것도 같은 이유 — 정본은 밝기를 잃지 않는다). 킷이 셋을 원복한다: hover(포인터 환경) · :focus-visible · 현재 행([aria-current] 또는 --active) — 상호작용 중인 행이 물러난 채로 있으면 응답하지 않는 것처럼 읽히고, 완료된 세션을 열어 둔 상태는 매일 있는 갈래다. **강제색 모드(forced-colors: active)에서는 흐림을 걸지 않는다** — 그 팔레트에는 명도 축이 없어 55% 가 '물러난 글자' 가 아니라 '사라진 글자' 이고, 상태는 그 모드가 형태 대비로 다시 세운 도트가 이미 진다. prefers-reduced-motion 에서는 전환만 사라지고 흐림은 남는다(움직임을 줄이라는 요청이지 상태 표현을 지우라는 요청이 아니다). **기본(두 줄) 목록은 대상이 아니다** — 거기서는 __meta 안 가시 배지가 상태를 글로 이미 지므로 이름까지 흐리면 같은 말을 두 번 하면서 대비만 잃는다. 흐림은 배지가 설 자리가 없는 조판의 대타다.
지양
  • AppShellSubnav 를 직접 import 하지 말 것 — 내부 전용(barrel 미노출), subnav/subnavLabel props 로 합성.
  • subnav <summary> 에 aria-expanded 를 수동으로 붙이지 말 것(네이티브가 이미 고지).
  • entity 칩·검색 trigger 를 서비스 로컬 CSS 로 다시 그리지 말 것 — 칩 기하·열림 전환·chevron 은 계약이 소유한다.
  • 메뉴 표면(전환 메뉴·identity 패널·서브패널)의 폭·패딩·행 높이·구분선 안쪽선을 로컬로 다시 잡지 말 것 — (pointer: coarse) 44px 복원까지 계약이 함께 소유하므로, 로컬 재구현은 그 복원을 조용히 놓친다.
  • data-rail-collapsed="false" 로 펼침을 표현하지 말 것 — presence 로만 읽는 boolean 속성이라 "false" 도 접힘이다. 펼침은 속성 제거(React 는 undefined).
  • 접기를 소비처에서 .axe-app-shell__sidebar { display: none } 으로 흉내 내지 말 것 — 트랙이 남아 본문 왼쪽에 280px 빈 칸이 생기고, ≤1023px 서랍까지 함께 죽는다.
  • 레일이 데스크톱 컬럼과 서랍에 두 번 렌더되는데 양쪽에 같은 id 를 주지 말 것 — aria-controls 의 대상이 둘이 되고 접기는 데스크톱 인스턴스만의 일이다. 서랍 쪽 레일의 brand 행에는 접기 토글도 두지 않는다.
  • 펼침용 토글을 bar 좌측 칸에만 두지 말 것 — 데스크톱 레일은 sticky 로 topbar 위에 서서 그 칸을 덮으므로, 버튼이 DOM 에는 있는데 보이지도 눌리지도 않는다(계약이 그 인스턴스를 접힘 상태에서만 표시하는 이유).
  • context 패널 슬롯 ①② 안에 DOM id 를 쓰지 말 것(aria-labelledby/-controls/-describedby 포함) — 패널이 한 문서에 두 벌 렌더되므로 id 가 곧바로 중복된다. 라벨은 aria-label 로, 짝 찾기는 [data-axe-copy-source] 로 한다. id 가 필요한 것은 문서당 하나뿐인 슬롯 ③ 이며 그때 접두는 axe-ctx-dock- 다.
  • 복사 훅에서 document.getElementById 로 원본·상태 노드를 찾지 말 것 — 문서 순서상 첫 사본이 잡히고, 넓은 화면에서 그것은 **보이지 않는 사본**이다. 기준점은 언제나 trigger.closest('[data-axe-ctx]') 다.
  • 도크를 오버레이로 띄우지 말 것 — 오버레이하는 순간 그것은 도크가 아니라 탭이고 슬롯 순서 불변식이 깨진다. 그리고 폭이 바뀔 때 도크를 **복제하지 말 것**: 노드 하나를 활성 호스트로 appendChild 로 옮긴다(복제하면 진행 중인 스트림·작성 중 입력이 둘로 갈린다).
  • data-axe-dock 이라는 속성명을 쓰지 말 것 — data-axe-dock-host 의 부분문자열이라 문자열 카운트 기반 계약 테스트가 조용히 과다 계수한다. 노드는 data-axe-dock-root, 자리는 data-axe-dock-host 다.
검색창을 열면 컴포넌트 인덱스를 불러옵니다.