익스클루시브
Mirror Rank <mv-mirror-rank>
스스로를 정직하게 설명하고 순위를 올리려면 무엇이 필요한지 알려 주는 랭킹입니다. 리더보드(영업, 기여자), 판매자 또는 검색 순위, 신용, 건강, SEO, 보안 점수에 적합합니다.
문화적 레퍼런스
백설 공주(Schneewittchen), 그림 형제 (1812, 동화). 세상에서 누가 가장 아름다운지 물으면 마법 거울은 항상 진실하게 답하며, 누가 1등인지 밝힘으로써 묻는 이가 왜 더 이상 1등이 아닌지도 암시합니다. UI에서는 랭킹이 어떤 항목에 대해서든 똑같이 진실하게 답합니다. 누가 얼마나 앞서 있는지, 그 격차가 무엇으로 이루어졌는지, 어떤 부분은 결코 바뀔 수 없는지, 그리고 격차를 좁힐 가장 작은 구체적인 변화가 무엇인지 알려 줍니다.
작동 방식
스스로를 정직하게 설명하고 순위를 올리려면 무엇이 필요한지 알려 주는 랭킹입니다: 리더보드(영업, 기여자), 판매자 또는 검색 순위, 신용, 건강, SEO 또는 보안 점수. 요인(가중치, 범위, 단계, 방향, 단위, 선택적 비용)과 요인 값을 가진 항목, 또는 요인별 내역을 반환하는 직접 작성한 score(values) 함수를 제공하면, 랭킹을 항목별 누적 기여 막대가 있는 순서 목록으로 렌더링하고, 선택된 항목(기본값은 보는 사람의 항목)에 대해 미러 패널을 표시합니다. 이 패널은 점수를 실제 표로 요인별로 분해하고(값, 최대 대비 점수, 목표 위치 눈금, 부호가 있는 차이), 다음 순위와의 격차(“2.4 points behind Amara Okafor (#4)”)와 그 격차를 좁힐 가장 작은 구체적인 변화(“Close 3 more deals” 또는 “Bring in $60k more revenue” 또는 “Respond 3 h faster”)를 보여 줍니다. 이 변화는 선언된 각 범위 안에서 요인별 변화량을 시험해 계산하고 노력 순으로 정렬하며, 단일 변화로 부족하면 두 요인 조합이나 탐욕적 조합을 쓰고, 범위 안에서 충분한 것이 없으면 “out of reach”라고 분명하게 판정합니다. 제안에 호버하거나 포커스하면 모든 막대와 격차 미터에 미치는 효과를 미리 보여 주고, 누르면 고정됩니다. 1위는 대신 1위를 잃게 만들 가장 작은 하락을 봅니다. 짧은 텍스트가 “why is #1 first?”에 답하며(2위 대비 주요 우위, 2위가 앞서는 부분, 격차), 정직 모드는 아무도 바꿀 수 없는 요인(근속 기간, 계정 연령)에 빗금과 라벨을 붙이고 격차나 우위 중 얼마가 그 요인에서 오는지 알려 줍니다. 다음 순위나 1위를 목표로 삼을 수 있고, 긴 목록은 상위 N개와 선택된 항목 주변으로 제한하며, 선택은 취소 가능한 mv-select-entry로, 계산된 모든 계획은 mv-what-it-takes로 추적할 수 있습니다.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-mirror-rank>) |
| 상태 | 안정 |
| 키트 | 스스로 설명하는 숫자 |
| Keywords | exclusive, culture, leaderboard, ranking, score, score-breakdown, explainable, what-if, gap, next-rank, gamification, seller-rank, search-ranking, credit-score, health-score, transparency, table, list |
When to use
- A leaderboard or seller ranking should tell each person why they are where they are and the smallest step that moves them up
- A composite score (credit, account health, SEO, security posture) must show its breakdown and the cheapest ways to raise it
- A marketplace or search ranking needs a transparent “why is my listing here?” view for sellers, including factors they cannot change
- Gamified progress should motivate with concrete, reachable targets instead of an opaque number
Avoid when
- People just need to sort and filter many rows by any column, with no scoring model behind the order → use Data table instead
- Progress is measured against your own previous attempt rather than against other people → use Ghost Run instead
- The ranking model is secret or adversarial (fraud, spam scores): explaining it would teach people to game it
설치
node scripts/add.mjs mirror-rank --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["mirror-rank"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, components/mirror-rank/mirror-rank.js, components/mirror-rank/mirror-rank.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-mirror-rank you="aiko" label="Q3 sales leaderboard">
<script type="application/json">{
"factors": [{ "key": "deals", "label": "Deals won", "min": 0, "max": 30 }, { "key": "csat", "label": "CSAT", "min": 60, "max": 100, "unit": "%" }],
"entries": [{ "id": "priya", "name": "Priya Raman", "values": { "deals": 22, "csat": 94 } }, { "id": "aiko", "name": "Aiko Tanaka", "values": { "deals": 20, "csat": 92 } }, { "id": "mei", "name": "Mei Chen", "values": { "deals": 14, "csat": 87 } }]
}</script>
</mv-mirror-rank>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="mr-demo" style="width:min(100%,76rem);margin-inline:auto">
<style>
#mr-demo .mr-top { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap; margin:0 0 1rem }
#mr-demo .mr-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#mr-demo .mr-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#mr-demo .mr-controls { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap }
#mr-demo .mr-controls .mv-choice { gap:.625rem; font-size:.8125rem }
#mr-demo .mr-foot { display:flex; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
#mr-demo .mr-foot code { font-family:var(--mv-font-mono); font-size:.6875rem; color:var(--mv-fg) }
</style>
<div class="mr-top">
<div>
<h3 id="mr-title">Q3 sales leaderboard</h3>
<p id="mr-sub">Account executives · updated Sep 24, 2026</p>
</div>
<div class="mr-controls">
<mv-segmented id="mr-scenario" value="sales" aria-label="Scenario">
<button value="sales">Sales team</button>
<button value="search">Search ranking</button>
</mv-segmented>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="mr-honest" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Honest mode</span></span>
</label>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="mr-week">Simulate next week</button>
</div>
</div>
<mv-mirror-rank id="mr-rank" you="aiko" honest label="Q3 sales leaderboard">
<script type="application/json">
{
"factors": [
{ "key": "deals", "label": "Deals won", "weight": 30, "min": 0, "max": 30, "step": 1, "unit": ["deal", "deals"], "improve": "Close {n} more {unit}", "slip": "{n} fewer {unit} won" },
{ "key": "revenue", "label": "Revenue closed", "weight": 25, "min": 0, "max": 600, "step": 10, "prefix": "$", "unit": "k", "improve": "Bring in {value} more revenue", "slip": "Revenue {value} lower" },
{ "key": "response", "label": "Lead response", "weight": 20, "better": "lower", "min": 1, "max": 24, "step": 0.5, "unit": "h", "improve": "Respond {value} faster", "slip": "Responses {value} slower" },
{ "key": "csat", "label": "CSAT", "weight": 15, "min": 60, "max": 100, "step": 1, "unit": "%", "improve": "Lift CSAT by {n} points", "slip": "CSAT {n} points lower" },
{ "key": "tenure", "label": "Tenure", "weight": 10, "min": 0, "max": 60, "step": 1, "unit": "mo", "fixed": "Accrues with time on the team" }
],
"entries": [
{ "id": "priya", "name": "Priya Raman", "meta": "EMEA · Enterprise", "values": { "deals": 22, "revenue": 480, "response": 2, "csat": 94, "tenure": 38 } },
{ "id": "marcus", "name": "Marcus Olsen", "meta": "Nordics · Mid-market", "values": { "deals": 24, "revenue": 410, "response": 3.5, "csat": 88, "tenure": 52 } },
{ "id": "amara", "name": "Amara Okafor", "meta": "West Africa · SMB", "values": { "deals": 19, "revenue": 360, "response": 1.5, "csat": 96, "tenure": 20 } },
{ "id": "diego", "name": "Diego Fernández", "meta": "LATAM · Enterprise", "values": { "deals": 17, "revenue": 450, "response": 4, "csat": 90, "tenure": 44 } },
{ "id": "aiko", "name": "Aiko Tanaka", "meta": "APAC · Mid-market", "values": { "deals": 20, "revenue": 400, "response": 4.5, "csat": 92, "tenure": 14 } },
{ "id": "lucas", "name": "Lucas Moreau", "meta": "France · SMB", "values": { "deals": 16, "revenue": 300, "response": 2.5, "csat": 89, "tenure": 30 } },
{ "id": "sofia", "name": "Sofia Rossi", "meta": "Southern Europe · Mid-market", "values": { "deals": 15, "revenue": 340, "response": 5, "csat": 93, "tenure": 26 } },
{ "id": "jonas", "name": "Jonas Weber", "meta": "DACH · Enterprise", "values": { "deals": 13, "revenue": 390, "response": 6.5, "csat": 85, "tenure": 48 } },
{ "id": "mei", "name": "Mei Chen", "meta": "Greater China · SMB", "values": { "deals": 14, "revenue": 260, "response": 2, "csat": 87, "tenure": 9 } },
{ "id": "omar", "name": "Omar Haddad", "meta": "Middle East · Mid-market", "values": { "deals": 11, "revenue": 280, "response": 7.5, "csat": 84, "tenure": 16 } }
]
}
</script>
</mv-mirror-rank>
<div class="mr-foot">
<span>Pick anyone in the list to see their mirror · hover a suggestion to preview it, press it to pin</span>
<span id="mr-event" aria-hidden="true"></span>
</div>
<script type="module">
const rank = document.getElementById("mr-rank");
await customElements.whenDefined("mv-mirror-rank");
const $ = (id) => document.getElementById(id);
const clone = (x) => JSON.parse(JSON.stringify(x));
const sales = { factors: clone(rank.factors), entries: clone(rank.entries) };
// Next week: Aiko closes three deals and answers leads faster; the others move a little.
const nextWeek = sales.entries.map((e) => {
const bump = { aiko: { deals: 3, response: -1.5, revenue: 40 }, amara: { deals: 1 }, diego: { revenue: 20 }, marcus: { deals: 1, csat: 1 }, lucas: { deals: 2 }, mei: { deals: 1 } }[e.id] ?? {};
const values = { ...e.values };
for (const [k, d] of Object.entries(bump)) values[k] = +(values[k] + d).toFixed(1);
return { ...e, values };
});
// Search scenario: the marketplace's own (non-linear) relevance score, explained.
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const search = {
factors: [
{ key: "match", label: "Query match", min: 0, max: 100, step: 1, unit: "%", maxPoints: 30, improve: "Raise query match by {n} points" },
{ key: "rating", label: "Rating", min: 1, max: 5, step: 0.1, decimals: 1, maxPoints: 25, improve: "Lift the rating by {n}", format: (v) => `${v.toFixed(1)} / 5` },
{ key: "reviews", label: "Reviews", min: 0, max: 5000, step: 10, unit: ["review", "reviews"], maxPoints: 15, improve: "Collect {n} more {unit}" },
{ key: "price", label: "Price vs median", better: "lower", min: -30, max: 30, step: 1, maxPoints: 12, improve: "Cut the price by {n}%", format: (v) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v)}%` },
{ key: "shipping", label: "Delivery time", better: "lower", min: 1, max: 7, step: 1, unit: ["day", "days"], maxPoints: 10, improve: "Deliver {value} sooner" },
{ key: "history", label: "Seller history", min: 0, max: 48, step: 1, unit: "mo", maxPoints: 8, fixed: "Account age, grows on its own" },
],
score: (v) => {
const parts = {
match: 30 * clamp(v.match / 100, 0, 1),
rating: 25 * clamp((v.rating - 3.5) / 1.5, 0, 1),
reviews: 15 * Math.min(1, Math.log10(1 + Math.max(0, v.reviews)) / Math.log10(5001)),
price: 12 * clamp((30 - v.price) / 60, 0, 1),
shipping: 10 * clamp((7 - v.shipping) / 6, 0, 1),
history: 8 * Math.min(1, v.history / 48),
};
return { total: Object.values(parts).reduce((a, b) => a + b, 0), parts };
},
entries: [
["harrowfield", "Harrowfield Audio", "Pulse ANC Buds · $119.00", 95, 4.7, 3200, -5, 2, 60],
["kestrel", "Kestrel Sound", "Glide Earbuds 2 · $99.00", 90, 4.6, 1800, 0, 1, 40],
["quill", "Quill & Co.", "Halo Mini · $129.00", 88, 4.8, 950, 6, 2, 36],
["harbor", "Harbor Tech", "WaveFit Pro · $89.00", 92, 4.4, 2600, -10, 3, 52],
["aurelia", "Aurelia Goods", "Calm Buds · $94.00", 85, 4.5, 1400, -4, 2, 30],
["juniper", "Juniper Direct", "AirLite Pro · $79.99", 80, 4.6, 420, -8, 3, 14],
["pine", "Pine Street Electronics", "Tempo X · $104.00", 86, 4.3, 2100, 4, 2, 48],
["mistral", "Mistral Supply", "Breeze Buds · $74.00", 78, 4.5, 700, -12, 4, 26],
["quokka", "Quokka Gear", "Hop Buds · $97.00", 82, 4.2, 1100, 2, 2, 20],
["sable", "Sable Audio Lab", "Studio Pods · $139.00", 75, 4.7, 300, 10, 3, 18],
["tidewater", "Tidewater Outlet", "Surf Buds · $64.00", 70, 4.1, 1600, -15, 5, 44],
["orbit", "Orbit Electronics", "Nova Lite · $84.00", 72, 4.0, 500, -2, 4, 10],
].map(([id, name, meta, match, rating, reviews, price, shipping, history]) => ({ id, name, meta, values: { match, rating, reviews, price, shipping, history } })),
};
let week = 0;
const show = (scenario) => {
const isSales = scenario === "sales";
rank.score = isSales ? null : search.score;
rank.factors = isSales ? sales.factors : search.factors;
rank.entries = isSales ? (week ? nextWeek : sales.entries) : search.entries;
rank.you = isSales ? "aiko" : "juniper";
rank.selected = null;
rank.target = "next";
rank.limit = isSales ? 0 : 4;
rank.label = isSales ? "Q3 sales leaderboard" : "Search results for wireless earbuds";
$("mr-title").textContent = isSales ? "Q3 sales leaderboard" : "Search ranking · “wireless earbuds”";
$("mr-sub").textContent = isSales ? "Account executives · updated Sep 24, 2026" : "Seller dashboard · why your listing sits where it does";
$("mr-week").hidden = !isSales;
};
$("mr-scenario").addEventListener("mv-change", (e) => { week = 0; $("mr-week").textContent = "Simulate next week"; show(e.detail.value); });
$("mr-honest").addEventListener("change", (e) => { rank.honest = e.target.checked; });
$("mr-week").addEventListener("click", (e) => {
week = week ? 0 : 1;
e.currentTarget.textContent = week ? "Back to this week" : "Simulate next week";
rank.entries = week ? nextWeek : sales.entries;
});
const log = (a) => {
const t = a.target ? ` · target ${a.target.name}` : "";
$("mr-event").replaceChildren(Object.assign(document.createElement("code"), { textContent: "mv-what-it-takes" }), ` ${a.suggestions.length} suggestion${a.suggestions.length === 1 ? "" : "s"}${t}`);
};
rank.addEventListener("mv-what-it-takes", (e) => log(e.detail));
if (rank.analysis) log(rank.analysis);
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
you | string (entry id) | The viewer's entry: tagged “You” in the list and panel, selected by default, always kept visible when limit hides rows. An entry with you: true works too. | |
selected | string (entry id) | Entry shown in the mirror panel. Defaults to you, then to #1. Reflects user selections. | |
target | "next" | "first" | number | next | Rank to aim for: the next rank up, first place, or a given rank (the lowest-scoring entry at or above it). The panel offers a Next rank / First place toggle when the two differ. |
honest | boolean | Names what can't be changed: fixed factors are hatched in every bar, labeled “Can't change” with their reason in the breakdown, and the panel and the “why #1” text say how many points of a gap or a lead come from them. Fixed factors are never suggested, with or without it. | |
limit | number | 0 | Show only the top N rows plus the selected entry, its neighbors and the viewer; hidden runs collapse into “Show 12 more” buttons. 0 shows everything. |
max-suggestions | number | 3 | How many suggestions (or slips for the leader) the panel lists. |
precision | number (0-4) | 1 | Decimals of scores and points. |
scale | number | 100 | Built-in scoring: the score of an entry that is best on every factor. Weights are shares of it. |
label | string | Ranking | Accessible name of the ranking list. |
locale | BCP 47 tag | Number and list formatting. Defaults to the closest lang attribute, then en-US. | |
heading-level | 1-6 | 3 | aria-level of the panel, suggestions and “why #1” headings. |
Properties
| Name | 유형 | Description |
|---|---|---|
factors | Factor[] | { key, label, weight (default 1), better: "higher" | "lower", min, max (normalization bounds, default: the data's), range: [lo, hi] (values reachable in a what-if, default [min, max]), step (smallest meaningful change), maxChange, cost (effort of one unit of change; default 1 / range width, so crossing the whole range costs 1), unit ("h", "%", ["deal", "deals"]; %, °, ×, k, M, B attach without a space), prefix ("$"), decimals, format(value), fixed (true or the reason, e.g. "Accrues with time on the team"), improve / slip (templates with {n}, {unit}, {value}, {label}, {from}, {to}), color, maxPoints (custom score: a factor's maximum points) }. Also read from a <script type="application/json"> child ({ factors, entries }). |
entries | Entry[] | { id, name, meta (subtitle), avatar (image URL), you, values: { [factorKey]: number } } (values may also sit directly on the entry). Replace the array (or call refresh()) to update: rows that move slide to their new place. |
score | (values, entry) => number | { total, parts } | Your own scoring instead of the built-in weighted, normalized sum. Return parts ({ [factorKey]: points }) to get the breakdown and “why #1” detail. Suggestions call it with modified values and assume it never drops when a factor gets better (binary search per factor). |
strings | Partial<Record<string, string>> | Overrides for every visible and announced text (en-US defaults with {placeholders}), e.g. behind, takesTitle, locked, whyLead, improveUp, announceSelect. |
ranking | { id, name, rank, score, parts, entry }[] | Read-only, best first; ties share a rank. |
analysis | object | Read-only: the plan shown in the panel, same shape as the mv-what-it-takes detail. |
selected / you / target / honest / limit / maxSuggestions / precision / scale / label / locale / headingLevel | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
select(id) | Shows an entry in the panel without emitting mv-select-entry. Returns false for an unknown id. |
whatItTakes(id?, { target? }) | Computes the plan for any entry and target without showing it. Returns the mv-what-it-takes detail shape. |
explainLeader() | Returns the “why is #1 first?” text. |
refresh() | Recomputes after entries or factors were mutated in place. |
Events
| Name | Description |
|---|---|
mv-select-entry | The user picked an entry (click, Enter or Space). detail: { id, entry, rank, score, previous }. Cancelable: preventDefault() keeps the current selection (e.g. other people's breakdowns are private). |
mv-what-it-takes | A new plan was computed for the panel (selection, data, target or honest mode changed). detail: { id, entry, rank, score, mode: "climb" | "lead" | "alone", target: { id, name, rank, score } | null, tie, gap, reachable, bestScore, locked: { points, factors } | null, suggestions: [{ kind: "single" | "combo" | "slip", text, score, rank, gain, cost, changes: [{ key, label, from, to, delta, text }] }] }. |
mv-preview | A suggestion is previewed (hover, keyboard focus) or pinned (press), or the preview ended. detail: { suggestion | null, pinned }. |
Content structure
| Name | Description |
|---|---|
script[type="application/json"] | Optional declarative data: { "factors": [...], "entries": [...] }. Properties set from code win. |
CSS classes
| Name | Description |
|---|---|
mv-mirror-rank-board / -list / -row / -entry | The ranking. Rows carry data-rank, data-selected and data-you; the entry button has aria-current="true" when shown in the panel. |
mv-mirror-rank-stack / -seg | Stacked contribution bar of a row, one segment per factor (data-fixed when hatched). |
mv-mirror-rank-why | “Why is #1 first?” block under the list. |
mv-mirror-rank-panel | The mirror panel (data-mode climb | lead | alone, data-preview while a suggestion is previewed). |
mv-mirror-rank-meter | Gap meter: score fill, hatched stretch to the target, target mark, preview ghost. |
mv-mirror-rank-suggestion | A suggestion button (aria-pressed when its preview is pinned, data-previewing while previewed). |
mv-mirror-rank-breakdown | The breakdown table; rows carry data-factor, data-fixed and data-changed during a preview. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-mirror-rank-color | var(--mv-accent) | Tint of the factor shades, selection, meter and toggles. |
--mv-mirror-rank-up | var(--mv-success) | Previewed gains, positive differences and rank-up badges. |
--mv-mirror-rank-down | var(--mv-danger) | Previewed losses (leader slips) and negative differences. |
Accessibility
The ranking is a real ordered list (role="list" kept despite list-style: none) named by label; each entry is a button whose name reads the rank, name, “You”, subtitle and score in order (“Rank 5 of 10: Aiko Tanaka, You, APAC · Mid-market, 68.0 pts”), with aria-current="true" on the one shown in the panel and aria-controls pointing to it. The list is one tab stop with a roving tabindex: Up/Down, Home and End move, Enter or Space selects (manual activation, so arrowing through never floods announcements). Selecting announces a polite summary: rank, score, gap and the quickest way up (or the closest risk for the leader). The panel is a labelled region with real headings (level set by heading-level); the breakdown is a <table> with a caption, column and row headers, and every bar is decorative (aria-hidden) because each row states its value, points out of the maximum and signed difference as text. Suggestions are a list of buttons (aria-pressed when pinned, described by a hint); the before/after values are written out (“Lead response 4.5 h to 1.5 h”) and the new rank is spoken. Nothing relies on color: fixed factors are hatched and labeled “Can't change” with their reason, differences carry +/− signs, the target is a tick plus “#4” text, gains and losses are striped. The Aim for toggle is a group of aria-pressed buttons; Escape clears a preview; focus is kept on the same control across re-renders. Reduced motion (OS or data-motion="reduce"): rows jump to their new place and bars change instantly. Forced colors: bars and fills switch to CanvasText/Highlight with outlines, the selection and pressed controls get Highlight outlines.