익스클루시브

Curtain <mv-curtain>

잘 다듬어진 모든 수치에 “How is this calculated?”를 더합니다. 차트, KPI, 점수, 가격, AI 답변을 감싸면 손잡이가 커튼처럼 화면을 옆으로 걷어(부드러운 주름 그림자가 있는 클립 와이프, 동작 줄이기 설정에서는 단순한 크로스페이드) 같은 자리에 무대 뒤를 드러내며, 레이아웃이 튀지 않습니다.

문화적 레퍼런스

오즈의 마법사, 빅터 플레밍(MGM, L. 프랭크 바움의 1900년 소설 원작)(1939년, 영화). 작은 개가 커튼을 걷어 내자, 위대하고 강력한 마법사가 실은 레버를 조작하는 평범한 남자라는 사실이 드러납니다. 인상적인 쇼 뒤에는 단순하고 들여다볼 수 있는 장치가 있었던 것입니다. UI에서는 잘 다듬어진 어떤 수치든 그 자리에서 걷어 내어, 데이터, 수식, 출처, 최신성, 필터 등 그것이 어떻게 만들어졌는지 보여 줄 수 있습니다.

작동 방식

잘 다듬어진 모든 수치에 “How is this calculated?”를 더합니다. 차트, KPI, 점수, 가격 또는 AI 답변을 감싸면 손잡이가 시각 요소를 커튼처럼 옆으로 걷어 내어(부드러운 접힘 그림자가 있는 클립 와이프, 동작 줄이기 설정에서는 단순한 크로스페이드) 같은 영역 안에서 레이아웃 이동 없이 그 이면을 보여 줍니다. 탭은 제공한 내용만 보여 줍니다. Data(data 속성, JSON 스크립트 또는 슬롯에 넣은 <table>로 만드는 접근성 있는 정렬 가능 표로, 통화, 퍼센트, 숫자, 날짜를 Intl로 서식화하며, 스프레드시트 수식 주입에 안전한 RFC 4180 CSV 내보내기 포함), Method(복사 버튼이 있는 코드 블록 안의 수식 또는 쿼리, 그리고 풍부한 설명), Sources(링크, 세부 정보, 상대 및 절대 업데이트 시각, “data as of” 줄, stale-after를 넘기면 표시되는 오래됨 경고), Filters입니다. 시각 요소 아래의 레일은 최신성을 늘 보이게 하고(“Updated 2 hours ago · Billing ledger +2”), corner 변형은 작은 KPI 타일에 맞으며 오래된 데이터를 점으로 표시합니다. mv-reveal은 취소 가능하며, waitUntil(promise)로 이면을 지연 로드합니다(스켈레톤, 오류, 재시도 포함). 닫혀 있는 동안에는 차트의 접근성 있는 대체 콘텐츠 역할도 합니다. 자동 생성된 요약(범위, 최댓값과 최솟값, 최신 값)이 aria-describedby로 연결되고, 표의 정적 사본은 스크린 리더가 계속 읽을 수 있습니다.

카테고리데이터 표시
유형Web Component (<mv-curtain>)
상태안정
키트스스로 설명하는 숫자
함께 설치되는 항목button
Keywordsexclusive, culture, explainability, transparency, provenance, data-table, chart-accessibility, kpi, metrics, formula, sql, sources, freshness, csv-export, lazy-load, disclosure, dashboard, trust, audit

When to use

  • A dashboard chart or KPI must let finance, auditors or customers check the numbers, formula and source behind it
  • A price, score, risk rating or AI answer needs a “show your work” view without leaving the page
  • A chart needs an accessible text and table alternative that sighted users can also open and export
  • The breakdown behind a headline figure is expensive and should be fetched only when someone asks for it

Avoid when

  • You still need to draw the chart itself: this wraps an existing visual → use Chart instead
  • The table is the main content, with search, selection, pagination or bulk actions → use Data table instead
  • A one-line definition of a term is enough; a full backstage would be heavier than the question → use Tooltip instead

설치

node scripts/add.mjs curtain --out ./src/marvelous

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["curtain"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/curtain/curtain.js, components/curtain/curtain.css.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

<mv-curtain label="Net revenue retention, Q3 2026" handle-label="How is NRR calculated?" updated="2026-09-24T08:00:00Z">
  <strong>112%</strong>
  <pre slot="method">NRR = (Starting MRR + Expansion − Contraction − Churn) ÷ Starting MRR</pre>
</mv-curtain>

기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:

<div id="cu-demo" style="width:min(100%,56rem);margin-inline:auto">
  <style>
    #cu-demo { display:grid; gap:1rem; align-content:start }
    #cu-demo .cu-card { min-width:0; padding:1.125rem 1.25rem 0.75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #cu-demo .cu-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:.875rem }
    #cu-demo .cu-title { margin:0; font-size:.9375rem; font-weight:600; letter-spacing:-.01em }
    #cu-demo .cu-sub { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #cu-demo .cu-figure { display:flex; align-items:baseline; gap:.625rem; flex-wrap:wrap; margin:0 0 .5rem }
    #cu-demo .cu-figure strong { font-size:1.75rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
    #cu-demo .cu-up { color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
    #cu-demo .cu-up b { color:var(--mv-fg); font-weight:600 }
    #cu-demo .cu-chart { width:100%; height:auto; overflow:visible }
    #cu-demo .cu-chart .grid line { stroke:var(--mv-border); stroke-width:1 }
    #cu-demo .cu-chart .grid line.base { stroke:var(--mv-border-strong) }
    #cu-demo .cu-chart text { fill:var(--mv-fg-muted); font:11px var(--mv-font-sans); font-variant-numeric:tabular-nums }
    #cu-demo .cu-chart .bars path { fill:color-mix(in oklab, var(--mv-accent) 58%, var(--mv-surface)); transition:fill var(--mv-duration-fast) }
    #cu-demo .cu-chart .bars path[data-latest] { fill:var(--mv-accent) }
    #cu-demo .cu-chart .bars path:hover { fill:var(--mv-accent-hover, var(--mv-accent)) }
    #cu-demo .cu-chart text.cu-direct { fill:var(--mv-fg); font-weight:600 }
    #cu-demo .cu-kpis { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:1rem }
    #cu-demo .cu-kpi { display:grid; gap:.25rem; padding:.125rem 0 .25rem }
    #cu-demo .cu-kpi-label { color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500 }
    #cu-demo .cu-kpi-value { font-size:2.25rem; font-weight:650; letter-spacing:-.03em; line-height:1.1; font-variant-numeric:tabular-nums }
    #cu-demo .cu-kpi-foot { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; color:var(--mv-fg-muted); font-size:.75rem }
    #cu-demo .cu-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.75rem 1rem; border:1px dashed var(--mv-border); border-radius:var(--mv-radius-lg) }
    #cu-demo .cu-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); flex:1 1 18rem }
    #cu-demo .mv-choice { font-size:.8125rem }
    @media (max-width:44rem) { #cu-demo .cu-kpis { grid-template-columns:minmax(0,1fr) } }
  </style>

  <!-- Main: a revenue chart with the query, sources and filters behind it -->
  <section class="cu-card" aria-labelledby="cu-rev-title">
    <div class="cu-head">
      <div>
        <h3 class="cu-title" id="cu-rev-title">Net revenue</h3>
        <p class="cu-sub">Monthly, January to August 2026</p>
      </div>
      <span class="mv-badge" data-variant="secondary">USD</span>
    </div>
    <p class="cu-figure"><strong>$1,384,700</strong><span class="cu-up"><b>+4.0%</b> vs July</span></p>

    <mv-curtain id="cu-revenue" label="Net revenue by month, Jan-Aug 2026" filename="net-revenue-2026">
      <svg class="cu-chart" viewBox="0 0 640 236" role="img" aria-label="Bar chart of monthly net revenue, January to August 2026">
        <g class="grid" aria-hidden="true">
          <line x1="52" x2="632" y1="20" y2="20"/><line x1="52" x2="632" y1="84" y2="84"/><line x1="52" x2="632" y1="148" y2="148"/><line class="base" x1="52" x2="632" y1="212" y2="212"/>
          <text x="44" y="24" text-anchor="end">$1.5M</text><text x="44" y="88" text-anchor="end">$1.0M</text><text x="44" y="152" text-anchor="end">$0.5M</text><text x="44" y="216" text-anchor="end">$0</text>
        </g>
        <g class="bars">
          <path d="M69.3 212V72.1q0 -4 4 -4h30q4 0 4 4V212z"><title>Jan 2026: $1,124,300</title></path>
          <path d="M141.8 212V68.6q0 -4 4 -4h30q4 0 4 4V212z"><title>Feb 2026: $1,151,800</title></path>
          <path d="M214.3 212V63.2q0 -4 4 -4h30q4 0 4 4V212z"><title>Mar 2026: $1,193,400</title></path>
          <path d="M286.8 212V65.4q0 -4 4 -4h30q4 0 4 4V212z"><title>Apr 2026: $1,176,900</title></path>
          <path d="M359.3 212V57.5q0 -4 4 -4h30q4 0 4 4V212z"><title>May 2026: $1,238,600</title></path>
          <path d="M431.8 212V51.2q0 -4 4 -4h30q4 0 4 4V212z"><title>Jun 2026: $1,287,200</title></path>
          <path d="M504.3 212V45.6q0 -4 4 -4h30q4 0 4 4V212z"><title>Jul 2026: $1,331,500</title></path>
          <path d="M576.8 212V38.8q0 -4 4 -4h30q4 0 4 4V212z" data-latest><title>Aug 2026: $1,384,700</title></path>
        </g>
        <text class="cu-direct" x="595.8" y="27" text-anchor="middle" aria-hidden="true">$1.38M</text>
        <g aria-hidden="true">
          <text x="88.3" y="230" text-anchor="middle">Jan</text><text x="160.8" y="230" text-anchor="middle">Feb</text><text x="233.3" y="230" text-anchor="middle">Mar</text><text x="305.8" y="230" text-anchor="middle">Apr</text>
          <text x="378.3" y="230" text-anchor="middle">May</text><text x="450.8" y="230" text-anchor="middle">Jun</text><text x="523.3" y="230" text-anchor="middle">Jul</text><text x="595.8" y="230" text-anchor="middle">Aug</text>
        </g>
      </svg>

      <script type="application/json" slot="data">
        {
          "columns": [
            { "key": "month", "label": "Month", "type": "date" },
            { "key": "net", "label": "Net revenue", "type": "currency", "format": { "maximumFractionDigits": 0 } },
            { "key": "newCustomers", "label": "New customers", "type": "number" },
            { "key": "churned", "label": "Churned MRR", "type": "currency", "format": { "maximumFractionDigits": 0 } },
            { "key": "refunds", "label": "Refunds", "type": "currency", "format": { "maximumFractionDigits": 0 } }
          ],
          "rows": [
            ["2026-01", 1124300, 38, 18400, 6210],
            ["2026-02", 1151800, 41, 16900, 5480],
            ["2026-03", 1193400, 47, 21300, 7930],
            ["2026-04", 1176900, 35, 24800, 9120],
            ["2026-05", 1238600, 52, 15200, 4870],
            ["2026-06", 1287200, 49, 17600, 5560],
            ["2026-07", 1331500, 56, 14900, 6040],
            ["2026-08", 1384700, 61, 15900, 5310]
          ]
        }
      </script>
      <div slot="method">
        <p>Net revenue is recognized subscription and usage revenue for the month, minus refunds and credit notes, converted to USD at each month’s closing rate. Taxes are excluded.</p>
        <pre data-language="sql">SELECT date_trunc('month', recognized_at) AS month,
       SUM(amount_usd) - SUM(refund_usd)   AS net_revenue
FROM   finance.fct_revenue_lines
WHERE  recognized_at &gt;= DATE '2026-01-01'
  AND  recognized_at &lt;  DATE '2026-09-01'
  AND  NOT is_test_account
GROUP  BY 1
ORDER  BY 1;</pre>
      </div>
    </mv-curtain>
  </section>

  <!-- KPIs: the real formula behind a headline figure; data lazy-loaded on reveal -->
  <div class="cu-kpis">
    <section class="cu-card" aria-label="Net revenue retention">
      <mv-curtain id="cu-nrr" label="Net revenue retention, Q3 2026" handle-label="How is NRR calculated?" tab="method">
        <div class="cu-kpi">
          <span class="cu-kpi-label">Net revenue retention</span>
          <span class="cu-kpi-value">112%</span>
          <span class="cu-kpi-foot"><span class="mv-badge" data-variant="success">+3.4 pts</span> vs Q2 2026 · trailing 12 months</span>
        </div>
        <div slot="method">
          <p>Revenue kept and grown from customers who were already paying 12 months ago. New customers are excluded, so the figure isolates expansion versus losses.</p>
          <pre data-language="formula">NRR = (Starting MRR + Expansion − Contraction − Churn) ÷ Starting MRR
    = ($1,240,000 + $186,400 − $21,700 − $15,900) ÷ $1,240,000
    = $1,388,800 ÷ $1,240,000
    = 112.0%</pre>
        </div>
      </mv-curtain>
    </section>

    <section class="cu-card" aria-label="Average deal size">
      <mv-curtain id="cu-deal" label="Average deal size, Q3 2026" handle="corner" stale-after="24h" tab="sources">
        <div class="cu-kpi">
          <span class="cu-kpi-label">Average deal size</span>
          <span class="cu-kpi-value">$48,200</span>
          <span class="cu-kpi-foot">Closed-won, Q3 2026 to date · 57 deals</span>
        </div>
        <div slot="method">
          <p>Sum of first-year contract value of closed-won deals, divided by the number of those deals. Renewals and add-ons are excluded.</p>
          <pre data-language="formula">Average deal size = $2,747,400 ÷ 57 deals = $48,200</pre>
        </div>
      </mv-curtain>
    </section>
  </div>

  <div class="cu-controls">
    <p class="cu-log" id="cu-log">Pull back a curtain to see how a figure is made.</p>
    <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cu-motion"> Reduce motion</label>
  </div>

  <script type="module">
    const log = document.getElementById("cu-log");
    const say = (text) => { log.textContent = text; };
    const ago = (h) => new Date(Date.now() - h * 36e5).toISOString();

    // Revenue chart: sources and filters from app data.
    const revenue = document.getElementById("cu-revenue");
    revenue.updated = ago(2.2);
    revenue.sources = [
      { label: "Billing ledger", href: "https://billing.example.com/", detail: "Invoices, refunds and credit notes", updated: ago(2.2) },
      { label: "Finance warehouse · fct_revenue_lines", detail: "dbt model, rebuilt hourly", updated: ago(2.6) },
      { label: "Month-end FX rates", detail: "Closing rates, 14 currencies", updated: ago(24 * 23) },
    ];
    revenue.filters = [
      { label: "Period", value: "Jan 1 to Aug 31, 2026" },
      { label: "Currency", value: "USD, converted at month-end closing rates" },
      { label: "Region", value: "All regions" },
      { label: "Excluded", value: "Test accounts, internal workspaces, taxes" },
    ];

    // NRR: the breakdown is fetched only when someone looks behind the figure.
    const nrr = document.getElementById("cu-nrr");
    nrr.updated = ago(0.4);
    let nrrLoaded = false;
    nrr.addEventListener("mv-reveal", (e) => {
      if (nrrLoaded) return;
      say("mv-reveal · loading the NRR breakdown…");
      e.detail.waitUntil(new Promise((resolve) => setTimeout(() => {
        nrrLoaded = true;
        resolve({
          data: {
            columns: [
              { key: "segment", label: "Segment" },
              { key: "start", label: "Starting MRR", type: "currency", format: { maximumFractionDigits: 0 } },
              { key: "expansion", label: "Expansion", type: "currency", format: { maximumFractionDigits: 0 } },
              { key: "contraction", label: "Contraction", type: "currency", format: { maximumFractionDigits: 0 } },
              { key: "churn", label: "Churn", type: "currency", format: { maximumFractionDigits: 0 } },
              { key: "nrr", label: "NRR", type: "percent" },
            ],
            rows: [
              ["Enterprise", 720000, 132000, 9800, 4200, 1.1639],
              ["Mid-market", 380000, 44600, 7900, 6300, 1.08],
              ["SMB", 140000, 9800, 4000, 5400, 1.0029],
            ],
          },
          sources: [
            { label: "Subscription ledger", detail: "MRR snapshots on Oct 1, 2025 and Sep 23, 2026", updated: ago(0.4) },
            { label: "CRM · Accounts", href: "https://crm.example.com/", detail: "Segment assignment", updated: ago(5) },
          ],
          filters: [
            { label: "Cohort", value: "Customers paying on Oct 1, 2025 (1,284 accounts)" },
            { label: "Window", value: "Trailing 12 months, ending Sep 23, 2026" },
            { label: "Excluded", value: "New logos, one-off services" },
          ],
        });
        say("Breakdown loaded · 3 segments");
      }, 700)));
    });

    // Average deal size: data older than the 24 h freshness budget is flagged.
    const deal = document.getElementById("cu-deal");
    deal.updated = ago(31);
    deal.sources = [{ label: "CRM · Opportunities", href: "https://crm.example.com/", detail: "Stage = Closed Won, first-year ACV", updated: ago(31) }];
    deal.data = {
      columns: [
        { key: "segment", label: "Segment" },
        { key: "deals", label: "Deals", type: "number" },
        { key: "acv", label: "First-year ACV", type: "currency", format: { maximumFractionDigits: 0 } },
        { key: "avg", label: "Average", type: "currency", format: { maximumFractionDigits: 0 } },
      ],
      rows: [
        ["Enterprise", 14, 1573600, 112400],
        ["Mid-market", 22, 858000, 39000],
        ["SMB", 21, 315800, 15038],
      ],
    };
    deal.filters = [{ label: "Stage", value: "Closed Won" }, { label: "Close date", value: "Jul 1 to Sep 23, 2026" }];

    for (const el of [revenue, nrr, deal]) {
      el.addEventListener("mv-reveal", (e) => { if (!log.textContent.startsWith("mv-reveal")) say(`mv-reveal · ${el.label}`); });
      el.addEventListener("mv-export", (e) => say(`mv-export · ${e.detail.filename} (${e.detail.rows.length} rows)`));
      el.addEventListener("mv-sort", (e) => say(`mv-sort · ${e.detail.key ?? "original order"} ${e.detail.direction ?? ""}`));
      el.addEventListener("mv-copy", (e) => say(`mv-copy · ${e.detail.ok ? "copied" : "failed"}`));
    }

    const motion = document.getElementById("cu-motion");
    motion.checked = document.documentElement.dataset.motion === "reduce";
    motion.addEventListener("change", () => {
      if (motion.checked) document.documentElement.dataset.motion = "reduce";
      else delete document.documentElement.dataset.motion;
    });
  </script>
</div>

API

Attributes

Name유형DefaultDescription
openbooleanfalseBackstage shown. Setting it runs the same path as the handle (mv-reveal, which may veto it and removes the attribute again). Reflected by the component.
labelstringVisualizationName of the figure (“Net revenue by month, Jan-Aug 2026”): accessible name of the visual group, backstage label (“How … is calculated”), table caption, auto-summary and default CSV filename.
tabdata | method | sources | filtersdataTab shown when the backstage opens. Falls back to the first tab that has content; reflected when the user picks another tab.
sidestart | endstartDirection the visual is pulled: toward the inline start (default) or end. Mirrors naturally in RTL layouts.
handlebar | cornerbarbar: a rail under the visual with freshness and a text handle. corner: an icon-only round handle floating over the top-end corner, for compact tiles (freshness moves to the Sources tab; a dot flags stale data).
handle-labelstringHow is this calculated?Text of the handle while closed (e.g. “How is NRR calculated?”). While open it reads “Hide details”.
updatedISO date | epoch msWhen the data was last refreshed. Shown relative in the rail (“Updated 2 hours ago”, refreshed every 30 s to 5 min) with the absolute date in a <time> title, and as “Data as of …” in the Sources tab.
stale-afterduration ("24h", "90m", "7d", ms)Freshness budget. Past it the host gets data-stale, the rail shows a “Stale” pill (a dot on the corner handle) and the Sources tab explains it.
summarystringScreen-reader summary of the visual. Without it (and without slot="summary") one is generated from the data: row count, range of the first numeric column with where its extremes are, and the latest value.
localeBCP 47 tagen-USLocale for numbers, currencies, dates, relative times and sorting.
currencyISO 4217 codeUSDDefault currency of currency columns (a column may set its own).
filenamestringCSV filename (.csv added). Default: the slugified label.
data-stateclosed | opening | open | closingSet by the component (styleable). data-side, data-handle and data-stale are set too.

Properties

Name유형Description
data{ columns?, rows } | object[]The data behind the visual. columns: string keys or { key, label?, type?: "text" | "number" | "currency" | "percent" (fractions) | "date", format?: Intl options, currency?, unit?, align? }; rows: objects or arrays in column order. Types are inferred when omitted (numbers, ISO dates such as "2026-08"). Reading returns plain copies.
methodstring | { title?, note?, code?, language? } | ArrayFormula or query blocks rendered in the Method tab (code blocks get a language tag and a copy button). Adds to any slot="method" content.
sourcesArray<string | { label, href?, detail?, updated? }>Where the figure comes from. Links open in a new tab (rel="noopener noreferrer"), updated shows relative + absolute time. The first one is named in the rail.
filtersArray<{ label, value }> | Record<string, string>Filters and exclusions applied to the figure (period, region, excluded accounts…), shown as a definition list; the tab shows their count.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (handle, handleClose, panel ({label}), tabs, data, method, sources, filters, rows ({count}), exportCsv, copy, copied, copyFailed, loading, error, retry, updated ({time}), asOf ({date}), stale, staleText, caption, sortedAsc/sortedDesc ({column}), exported, opened/closed ({label}), summaryAuto, summaryRows…). English defaults.
state"closed" | "opening" | "open" | "closing"Current phase (read-only).

Methods

NameDescription
show({ tab?, focus? })Pulls the curtain back (emits the cancelable mv-reveal). Returns Promise<boolean>: false if vetoed; resolves once lazy content has settled.
hide({ focus? }) / toggle(force?)Lets the curtain fall back over the visual (focus returns to the handle when it was inside).
sortBy(key, direction)Sorts the table ("ascending" | "descending"; null key or direction restores the original order). Stable, blanks last, locale-aware for text.
toCSV()Returns the data as CSV: raw values, current sort order, CRLF line endings, text cells starting with = + - @ prefixed with an apostrophe.
exportCSV()Emits the cancelable mv-export, then downloads the CSV (UTF-8 with BOM, opens cleanly in spreadsheet apps). Returns false if cancelled or there is no data.

Events

NameDescription
mv-revealCancelable, before the backstage opens (and on Retry, with retry: true). detail: { tab, retry, waitUntil(promise) }. Call waitUntil() synchronously to lazy-load: the backstage opens at once with a skeleton (aria-busy), then the resolved object ({ data, method, sources, filters, summary, updated }, any subset) is merged in; a rejection shows an error with Try again. preventDefault() keeps the curtain closed.
mv-reveal-errorA waitUntil promise rejected. detail: { error }.
mv-concealThe backstage started closing.
mv-tabThe user picked another tab. detail: { tab }.
mv-sortThe table sort changed. detail: { key, direction } (both null for the original order).
mv-exportCancelable, before the CSV download. detail: { csv, filename, columns, rows }. preventDefault() to export yourself (server-side XLSX, a signed URL…).
mv-copyA code block was copied. detail: { text, ok }.

Content structure

NameDescription
(default)The visual: an SVG chart, an <mv-chart>, a KPI tile, an image, an AI answer… It stays exactly as authored; it is only wrapped and hidden (inert) while the backstage shows.
data<script type="application/json" slot="data"> with the data object, or a <table slot="data"> (header cells become columns; data-value on a cell gives the raw sortable value, data-type on a header cell sets the type). A slotted table is parsed and hidden; the component renders its own.
method / sources / filtersAny rich content moved into that tab after the generated part. Every <pre> inside slot="method" becomes a code block with a copy button (language from data-language or a language-* class on its <code>).
summaryYour own screen-reader summary of the visual (replaces the generated one).

CSS classes

NameDescription
mv-curtain-frame / -stage / -edge / -backstageThe shared cell, the wrapped visual, the fold shadow that rides the moving edge, and the backstage panel (-head, -tabs, -tab, -count, -panel, -status).
mv-curtain-table / -toolbar / -scrollData tab: sortable table (sticky header, first column as row headers, numbers end-aligned, aria-sort), row count and sort note, Export CSV button, focusable scroll region.
mv-curtain-code / -code-bar / -lang / -copy / -preMethod code blocks.
mv-curtain-sources / -src / -asof / mv-curtain-filters / -filterSources list (icon, name, meta), the “Data as of” line with stale callout, and the filters definition list.
mv-curtain-rail / -fresh / -dot / -stale / -handleThe rail under the visual (or the corner handle).

CSS variables

NameDefaultDescription
--mv-curtain-radiusvar(--mv-radius-lg)Corner radius of the frame and backstage.
--mv-curtain-max-height24remTallest the backstage may grow beyond the visual’s own height; longer content scrolls inside it.
--mv-curtain-stage-bgvar(--mv-surface)Background given to the visual while it slides (so the drape is opaque). Match it to the surface the visual sits on.
--mv-curtain-backstagevar(--mv-bg-subtle)Backstage background.
--mv-curtain-foldcolor-mix(in oklab, var(--mv-fg) 18%, transparent)Color of the fold shadow on the moving edge.
--mv-curtain-fresh / --mv-curtain-stalevar(--mv-success) / var(--mv-warning)Freshness dot, and the stale pill, dot and callout.

Accessibility

The handle is a real disclosure <button> with aria-expanded and aria-controls pointing to the backstage, a labelled region (“How Net revenue retention is calculated”); the corner variant keeps the text as its aria-label (with “(Stale)” when the data is past its budget). Opening from the handle moves focus to the active tab, or to the busy backstage while lazy data loads and then to the tab; Escape anywhere inside, or the handle again, closes it and returns focus to the handle. Opening, closing, sorting, exporting and copying are announced in a polite live region. Tabs follow the ARIA tabs pattern (role tablist/tab/tabpanel, roving tabindex, Arrow keys, Home and End, automatic activation); tabs without content are not rendered. The data table has a caption, column headers with sort buttons and aria-sort on the sorted column only, and the first column as row headers; its scroll area is a focusable, labelled region. While the backstage shows, the visual is inert and aria-hidden. While closed, the visual is wrapped in a named group described (aria-describedby) by a summary, generated from the data when you don't provide one, and a static, control-free copy of the table follows it, visually hidden, so screen-reader users always have the numbers: the component doubles as the accessible alternative for the chart. Freshness uses <time datetime> with the absolute date as title; stale data is flagged with words, not only color. External source links announce that they open in a new tab. Reduced motion (OS or data-motion="reduce"): a 180 ms crossfade replaces the drape, with no sliding or height motion, and the loading shimmer stops. Forced colors: borders, the selected tab and the freshness dot use system colors; the fold shadow is removed.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고