Exclusivobeta
Dorian <mv-dorian>
Frescura del contenido visible de un vistazo: envuelve cualquier contenido (cabecera de una página de documentación, artículo del centro de ayuda, tabla de precios, política) y queda exactamente como está, mientras un pequeño retrato enmarcado a su lado envejece con el tiempo transcurrido desde su última revisión. La proporción de edad (tiempo desde updated / lifespan) se asigna a cinco etapas (fresh, settled, aging, stale, decrepit, con umbrales configurables) y controla un busto procedural con semilla dibujado en SVG inline: cada elemento tiene su propia cara y, a medida que envejece, los colores se apagan, el barniz amarillea, aparecen arrugas finas, los rasgos y los hombros se caen, el pelo encanece, el marco dorado se cubre de pátina y grietas, y el polvo se acumula en una viñeta, todo de forma continua. El retrato es un botón que abre un panel con la etapa, las fechas exactas, un indicador de edad frente a la vida útil, el responsable y una acción “Marcar como revisado” que rejuvenece el retrato con una transición suave y un destello. La variante badge añade “Envejeciendo · Revisado hace 7 meses”; ubicaciones inline-start, inline-end, corner o cualquier elemento marcado con data-dorian-anchor.
| Categoría | Visualización de datos |
|---|---|
| Tipo | Web Component (<mv-dorian>) |
| Estado | beta |
| Kit | Cifras que se explican solas |
| Keywords | exclusive, culture, freshness, staleness, content-review, last-updated, review-cycle, knowledge-base, documentation, policy, governance, age, portrait, generative, svg, procedural, popover, relative-time |
When to use
- Help-center or internal docs articles must show at a glance which ones are overdue for a review
- A policy, pricing sheet or runbook page should make its own staleness obvious to readers and to its owner
- Content owners need a lightweight in-place “Mark as reviewed” action next to the content they maintain
- A list of documents should be scannable by freshness without adding a dedicated column of dates
Avoid when
- Only a short status label is needed, with no age progression or review action → use Badge instead
- The content changes continuously (live data, feeds) and has no review cycle to measure against
- Hundreds of items render at once on a dense table, where a date column sorts and scans better → use Data table instead
Instalación
node scripts/add.mjs dorian --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["dorian"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/dorian/dorian.js, components/dorian/dorian.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-dorian updated="2026-03-02" lifespan="1y" owner="Legal">
<h2>Data retention policy</h2>
<p>How long we keep customer records, backups and logs.</p>
</mv-dorian>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="dg-demo" style="width:min(100%,46rem);margin-inline:auto">
<style>
#dg-demo { display:grid; gap:1.75rem }
#dg-demo .dg-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#dg-demo .dg-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
#dg-demo .dg-head h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
#dg-demo .dg-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#dg-demo .dg-list { list-style:none; margin:0; padding:.25rem 0 }
#dg-demo .dg-list li + li { border-top:1px solid var(--mv-border) }
#dg-demo .dg-list mv-dorian { align-items:center; padding:.75rem 1.25rem }
#dg-demo .dg-list .mv-dorian-trigger { min-width:12.25rem }
#dg-demo .dg-article { display:flex; align-items:center; gap:.875rem; min-width:0; color:inherit; text-decoration:none }
#dg-demo .dg-icon { display:grid; place-items:center; flex:none; width:2.25rem; height:2.25rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
#dg-demo .dg-icon svg { width:1.05rem; height:1.05rem }
#dg-demo .dg-article strong { display:block; font-size:.875rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
#dg-demo .dg-article:hover strong { text-decoration:underline; text-underline-offset:.2em; text-decoration-color:var(--mv-border-strong) }
#dg-demo .dg-meta { display:block; margin-top:.125rem; color:var(--mv-fg-muted); font-size:.75rem }
#dg-demo .dg-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); color:var(--mv-fg-muted); font-size:.8125rem }
#dg-demo .dg-actions { display:flex; gap:.5rem; flex-wrap:wrap }
#dg-demo .dg-policy { padding:1.5rem }
#dg-demo .dg-policy mv-dorian { --mv-dorian-size:88px; --mv-dorian-gap:1.5rem; align-items:center }
#dg-demo .dg-kicker { margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
#dg-demo .dg-policy h2 { margin:.3rem 0 .4rem; font-size:1.5rem; line-height:1.2; letter-spacing:-.02em }
#dg-demo .dg-policy p.dg-lede { margin:0; max-width:34rem; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.55 }
#dg-demo .dg-tags { display:flex; gap:.375rem; flex-wrap:wrap; margin-top:.75rem }
#dg-demo .dg-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)); gap:1rem }
#dg-demo .dg-grid .dg-card { padding:1.125rem 1.25rem }
#dg-demo .dg-grid mv-dorian { --mv-dorian-size:40px }
#dg-demo .dg-grid h4 { margin:0 0 .25rem; padding-inline-end:3.25rem; font-size:.9375rem }
#dg-demo .dg-grid p { margin:0; padding-inline-end:3.25rem; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#dg-demo .dg-grid .dg-price { margin-top:.75rem; padding:0; color:var(--mv-fg); font-size:1.25rem; font-weight:600; letter-spacing:-.02em }
#dg-demo .dg-grid .dg-price small { color:var(--mv-fg-muted); font-size:.8125rem; font-weight:400 }
</style>
<!-- 1 · Policy page header, large portrait -->
<section class="dg-card dg-policy" aria-label="Policy header">
<mv-dorian id="dg-policy" data-days="152" lifespan="1y" owner="Amara Nwosu, Legal" seed="data-retention">
<p class="dg-kicker">Policy · Version 3.2</p>
<h2>Data Retention Policy</h2>
<p class="dg-lede">How long we keep customer records, backups and logs, and how deletion requests are handled across every region we operate in.</p>
<div class="dg-tags">
<span class="mv-badge" data-variant="secondary">Legal</span>
<span class="mv-badge" data-variant="outline">Applies to all workspaces</span>
</div>
</mv-dorian>
</section>
<!-- 2 · Knowledge base, badge variant -->
<section class="dg-card" aria-labelledby="dg-kb-title">
<div class="dg-head">
<div>
<h3 id="dg-kb-title">Help Center · Billing & accounts</h3>
<p>Each article ages against a 6-month review cycle.</p>
</div>
<span class="mv-badge" data-variant="outline">5 articles</span>
</div>
<ul class="dg-list" id="dg-kb">
<li><mv-dorian variant="badge" placement="inline-end" data-days="3" owner="Priya Raman">
<a class="dg-article" href="#"><span class="dg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg></span>
<span style="min-width:0"><strong>Update your payment method</strong><span class="dg-meta">Billing · 3 min read · 4,812 views</span></span></a>
</mv-dorian></li>
<li><mv-dorian variant="badge" placement="inline-end" data-days="61" owner="Marcus Chen">
<a class="dg-article" href="#"><span class="dg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h4"/></svg></span>
<span style="min-width:0"><strong>Download invoices and receipts</strong><span class="dg-meta">Billing · 2 min read · 2,390 views</span></span></a>
</mv-dorian></li>
<li><mv-dorian variant="badge" placement="inline-end" data-days="152" owner="Sofia Alvarez">
<a class="dg-article" href="#"><span class="dg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
<span style="min-width:0"><strong>Change your plan or seat count</strong><span class="dg-meta">Accounts · 4 min read · 1,975 views</span></span></a>
</mv-dorian></li>
<li><mv-dorian variant="badge" placement="inline-end" data-days="243" owner="Daniel Okafor">
<a class="dg-article" href="#"><span class="dg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span style="min-width:0"><strong>Set up single sign-on with SAML 2.0</strong><span class="dg-meta">Security · 7 min read · 864 views</span></span></a>
</mv-dorian></li>
<li><mv-dorian variant="badge" placement="inline-end" data-days="730" owner="Hannah Weber">
<a class="dg-article" href="#"><span class="dg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg></span>
<span style="min-width:0"><strong>Migrate from the legacy v1 API</strong><span class="dg-meta">Developers · 9 min read · 512 views</span></span></a>
</mv-dorian></li>
</ul>
<div class="dg-foot">
<span id="dg-log" aria-live="polite">Open a portrait to see its dates, owner and review action.</span>
<div class="dg-actions">
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="dg-forward">Fast-forward 3 months</button>
<button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="dg-reset">Reset</button>
</div>
</div>
</section>
<!-- 3 · Cards, portrait in the corner -->
<div class="dg-grid">
<article class="dg-card">
<mv-dorian placement="corner" data-days="18" lifespan="90d" owner="Revenue team">
<h4>Enterprise pricing sheet</h4>
<p>Per-seat pricing, volume tiers and the annual discount grid.</p>
<p class="dg-price">$38 <small>per seat / month</small></p>
</mv-dorian>
</article>
<article class="dg-card">
<mv-dorian placement="corner" data-days="420" lifespan="1y" owner="Site Reliability">
<h4>Incident response runbook</h4>
<p>Severity levels, paging rotation and the customer comms template.</p>
<p class="dg-price">SEV-1 <small>page within 5 minutes</small></p>
</mv-dorian>
</article>
</div>
<script type="module">
const root = document.getElementById("dg-demo");
const all = [...root.querySelectorAll("mv-dorian")];
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return iso(d); };
let offset = 0;
const reset = () => {
offset = 0;
for (const el of all) { el.removeAttribute("now"); el.setAttribute("updated", daysAgo(Number(el.dataset.days))); }
};
reset();
const log = document.getElementById("dg-log");
const title = (el) => el.querySelector("strong, h2, h4")?.textContent.trim() ?? "Item";
root.addEventListener("mv-review", (e) => { log.textContent = `“${title(e.target)}” marked as reviewed.`; });
root.addEventListener("mv-stage", (e) => { log.textContent = `“${title(e.target)}” is now ${e.detail.label.toLowerCase()}.`; });
document.getElementById("dg-forward").addEventListener("click", () => {
offset += 91;
const now = new Date(); now.setDate(now.getDate() + offset);
for (const el of all) el.setAttribute("now", now.toISOString());
log.textContent = `Viewing the list as of ${now.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}.`;
});
document.getElementById("dg-reset").addEventListener("click", () => {
reset();
log.textContent = "Back to today.";
});
</script>
</div>Referencia cultural
El retrato de Dorian Gray, Oscar Wilde (1890, novela). Dorian sigue joven e impecable mientras su retrato oculto envejece y lleva en su lugar las marcas del tiempo. Aquí, el propio contenido sigue intacto mientras un pequeño retrato a su lado envejece con el tiempo transcurrido desde su última revisión, y una revisión le devuelve la juventud.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
updated | date (YYYY-MM-DD | ISO 8601 | epoch ms) | Date of the last review. A plain YYYY-MM-DD is read as a local calendar date. Missing or invalid: “Never reviewed”, fully aged (decrepit). | |
lifespan | duration | 180d | How long the content stays trustworthy after a review: 36h, 90d, 26w, 6mo (or 6m), 1y; a bare number means days. The age ratio is (now − updated) / lifespan. |
stages | string | 0.25,0.5,0.8,1 | Four ascending ratio thresholds separating fresh | settled | aging | stale | decrepit. Ratio ≥ the last one (1 by default: past the lifespan) is decrepit. |
stage-labels | string | Fresh,Settled,Aging,Stale,Decrepit | Five visible labels for the stages (badge, panel, accessible name), comma-separated. |
seed | string | Seed of the procedural face. Without it, the face is derived from a hash of the text content at first render, so each item gets its own stable face. | |
placement | inline-start | inline-end | corner | inline-start | Where the portrait sits relative to the content: a column before or after it, or absolutely positioned in the top-end corner (leave room with padding on the content). Ignored when an element inside carries data-dorian-anchor: the portrait is then appended into it. |
variant | portrait | badge | portrait | portrait: the framed portrait alone. badge: a compact bordered chip with a smaller portrait, the stage (dot + label) and “Reviewed 7 months ago”. |
owner | string | Person or team responsible for reviewing the content, shown in the panel. | |
label | string | Freshness | Prefix of the accessible name and panel kicker (“Freshness: aging, …”). |
now | date | Freezes the clock at this date (tests, screenshots, server-rendered snapshots, “as of” views). Without it, the real time is used and re-checked every minute and on tab return. | |
readonly | boolean | Hides the “Mark as reviewed” action (readers who cannot review). | |
data-dorian-anchor | marker attribute | On a descendant: the portrait button is appended into that element (e.g. a card’s meta line) instead of beside the content. | |
data-stage | fresh | settled | aging | stale | decrepit | Set by the component on the host (styleable). |
Properties
| Name | Tipo | Description |
|---|---|---|
age | { ratio, stage, label, days, updated, due, lifespan, visual, now } | Read-only snapshot: ratio (0…∞), stage key and label, days since the review, updated and due as Date (null if never reviewed), lifespan in days, visual age 0-1 used by the portrait (1 − e^(−1.1·ratio)). |
updated / lifespan / stages / seed / placement / variant / owner / now / readonly | reflected | Every attribute is mirrored as a property; changing one re-renders (age changes are tweened). |
Methods
| Name | Description |
|---|---|
review() | Marks the content as reviewed now (or at the frozen now): sets updated to an ISO timestamp, rejuvenates the portrait and emits mv-review. |
open() / close() | Opens (focus moves into it) or closes the details panel. |
refresh() | Recomputes age, stage and labels immediately (it also happens every minute, on tab return and on attribute changes). |
Events
| Name | Description |
|---|---|
mv-review | “Mark as reviewed” (or review()). detail: { previous: Date | null, updated: Date }. Persist it on your side; set updated back if the save fails. |
mv-stage | The stage changed after the first render: time crossed a threshold while the page was open, updated, lifespan or now changed, or a review happened. detail: { stage, previous, ratio, days, label }. |
Content structure
| Name | Description |
|---|---|
(content) | Any content; it is moved into a .mv-dorian-body wrapper and never altered. |
CSS classes
| Name | Description |
|---|---|
mv-dorian-trigger | The portrait button (aria-expanded, aria-controls) holding .mv-dorian-frame and, in the badge variant, .mv-dorian-text (.mv-dorian-stage + .mv-dorian-when). |
mv-dorian-frame / mv-dorian-svg | Frame box (4:5) and the procedural SVG inside it; data-size="lg" in the panel. |
mv-dorian-body | Wrapper of the original content. |
mv-dorian-attic | Details panel (Popover API, non-modal role=dialog): -attic-head, -kicker, -attic-stage, -attic-summary, -gauge (-meter, -meter-fill, -meter-tick, -meter-caption), -facts (dl), -review button. |
CSS variables
| Name | Tipo | Default | Description |
|---|---|---|---|
--mv-dorian-size | 44px (34px in badge) | Portrait width; the height follows a 4:5 ratio. | |
--mv-dorian-attic-size | 72px | Width of the large portrait in the panel. | |
--mv-dorian-gap | var(--mv-space-4) | Space between portrait and content (inline placements). | |
--mv-dorian-inset | 0 | Offset from the corner (corner placement). | |
--mv-dorian-gild | soft gold (light-dark) | Artistic: the frame’s gilding when fresh. Set it to var(--mv-border-strong) for a plain neutral frame. | |
--mv-dorian-patina | muted olive (light-dark) | Artistic: what the gilding turns into with age (also the chips on the frame). | |
--mv-dorian-age | output | Set on the host: visual age 0-1, usable to style the content itself (e.g. a faint border). |
Accessibility
The stage is never conveyed by the picture alone: the portrait is a real <button> (aria-haspopup="dialog", aria-expanded, aria-controls) whose accessible name spells it out (“Freshness: aging, last reviewed 7 months ago, review due in 2 months” / “…, review overdue by 3 months”), the badge variant prints the stage label and relative time next to a colored dot, and the panel repeats stage, dates, lifespan share and owner in text. The procedural SVG is aria-hidden. The panel is a non-modal role="dialog" labelled by its stage heading, shown with the Popover API (top layer) and positioned next to the portrait; opening moves focus into it, Tab reaches “Mark as reviewed”, Escape or an outside click closes it and Escape returns focus to the portrait, and tabbing out closes it. After a review a polite role=status announces “Marked as reviewed. Freshness: fresh.”; stage crossings are only announced while the panel is open so a page full of items stays quiet. Visible focus rings on the portrait and the button; full touch support (plain taps). Relative times use Intl.RelativeTimeFormat en-US, dates Intl.DateTimeFormat. prefers-reduced-motion and [data-motion="reduce"]: no rejuvenation tween, glint or bounce, the portrait switches instantly to its new age; the panel appears without motion.