Building a result renderer
How to add a renderer that draws a tool result from the shared .mvr-* vocabulary, register it in the dispatch chain, and get the recommendations panel for free.
Last updated 2026-08-06
Summary#
A renderer is the function that turns one tool's API payload into the markup a customer reads. Every tool run ends in window.mvRenderOld(res, data, type, query), which walks a fixed dispatch chain and calls the first renderer that matches the tool type. This page is how to write one that fits the system instead of adding to the debt.
The rule the design system asks for is short: renderers describe, the shell composes. The rule the code enforces today is narrower, because the shell function does not exist yet. Read the status section before you start, then follow the steps. Done correctly, a new renderer inherits the verified-source header, the Get Recommendations panel, the exec-summary tab bar, the export engine and the six-language translation pass without writing a line for any of them.
Purpose#
The audit behind The design system found roughly thirty-nine of fifty-five tools shipping a bespoke renderer that hand-coded its own layout, spacing and color inline. That is why the app read as fifty-five products. The prime directive is that only the content of a result changes between tools, never the frame.
A renderer built from the shared vocabulary also costs less to maintain. The shared layer already knows how a KPI tile looks in both themes, how a table behaves on a phone, what an empty state says, and how the entrance animation staggers. Every one of those you rebuild is one you now own forever.
Status: the shell function does not exist#
docs/DESIGN-CONSTITUTION.md describes mvRenderResultShell(el, view), a single function that would take a plain view object and compose the whole result. A repository-wide search finds that name only inside that document. Constitution Phase 1 has not shipped.
What exists today is the .mvr-* CSS vocabulary, 103 class names across css/mvr.css and css/mvr-premium.css, and renderers in dashboard.html that emit .mvr-* markup by hand. So the practical instruction is: build your markup out of the shared primitives, keep the data-shaping and the markup-building in separate functions, and your renderer will be trivially convertible when the shell lands.
Warning: two components named in the constitution's table were never implemented. .mvr-card is not defined in any stylesheet, and the table class is .mvr-table, not .mvr-tbl. There are stray uses of both wrong names in dashboard.html that style nothing. Use .mvr-table, and use .mvr-section plus your own content for a section rather than .mvr-card.
Requirements#
- The tool already returns data from
/api/toolsor/api/premium-ai. Adding the backend half is Adding a new tool. - The tool's view in
dashboard.htmlcarries the three contract elements:input[data-in],button[data-run="<tool_type>"]withdata-kind="ai"ordata-kind="tool", anddiv.mv-results[data-res]. - Node 24 for the verification scripts.
- You have read the six laws in The design system. Every one applies to renderer output.
Permissions#
dashboard.html belongs to the main branch. If you are on redesign, do not edit it. The file is 7.2 MB with CRLF line endings and two developers share the repository, so take turns: two people editing it at once produces a merge nobody can resolve. Stage explicit paths and never git add -A. The full ownership rules are in Code conventions and file ownership.
Navigation Path#
dashboard.html → the <script> block defining window.mvRenderOld → the BES map inside it.
Step-by-Step Guide#
1. Choose your dispatch slot#
mvRenderOld tries five things in order and stops at the first hit:
| Order | Mechanism | Called as |
|---|---|---|
| 1 | BES, a map of about forty tool types to a named premium renderer | W[name](res, data, opts) |
| 2 | AIINTEL: prompt_research, ai_competitor_research, brand_performance, ai_questions, prompt_tracking, media_monitoring | mvRenderAiIntelPremium(type, res, data) |
| 3 | BATCH6: ad_clarity, pla_research | mvRenderBatch6Extras(type, res, data) |
| 4 | INLINE, twelve tools mapped to mv_inline_* functions | W[name](res, data, query) |
| 5 | mvRenderAny(res, data, {}), the generic JSON renderer |
If nothing matches, the last resort writes a card reading No legacy renderer for <type>. Seeing that string in the browser means your registration did not take.
For a new standalone tool, use BES. The second element of each entry is the options object passed as the third argument, and it is one of four shapes: {idPrefix, query}, {idPrefix}, {query}, {brand}, or null to call the renderer with two arguments only.
2. Write the renderer as two functions#
Keep the data work and the markup work apart. That separation is the whole point, and it is what makes the eventual move to a real shell mechanical.
// 1. describe: pure, testable, no DOM, no strings of markup
function keywordPulseView(data) {
return {
hero: { domain: data.domain, source: data._dataSource },
metrics: [
{ label: 'Keywords', value: data.total, sub: 'tracked', tone: 'neutral' },
{ label: 'Top 10', value: data.top10, sub: 'positions', tone: 'good' },
{ label: 'Lost', value: data.lost, sub: 'this month', tone: 'bad' }
],
rows: data.keywords || []
};
}
// 2. compose: builds .mvr-* markup only
window.mvRenderKeywordPulsePremium = function (res, data, opts) {
var v = keywordPulseView(data || {});
if (!v.rows.length) {
res.innerHTML = '<div class="mvr"><div class="mvr-empty">' + ICON_INFO +
'<div>No ranking data for that domain yet.</div></div></div>';
return;
}
res.innerHTML = '<div class="mvr">' + head(v.hero) + kpis(v.metrics) + table(v.rows) + '</div>';
};3. Compose from the primitives#
The container res already carries class="mvx" and a data-theme attribute, both set by mvRenderOld before it dispatches. Your markup goes inside a <div class="mvr"> wrapper.
| Need | Markup |
|---|---|
| Hero header | .mvr-head containing .mvr-fav (favicon), .mvr-id with .mvr-domain and .mvr-meta |
| Metric row | .mvr-kpis of .mvr-kpi, each with .mvr-kpi-top + .mvr-kpi-label, .mvr-kpi-val, .mvr-kpi-sub |
| Section heading | .mvr-section with an inline SVG, .mvr-section-t, optional .mvr-section-sub |
| Table | <table class="mvr-table">, class="num" on numeric cells for right alignment and tabular figures |
| Status badge | .mvr-tier plus one of .good, .warn, .bad, .info, .brand, .neutral |
| Callout | .mvr-insight for a finding, .mvr-note for a footnote |
| Buttons | .mvr-cta primary, .mvr-ghost secondary, .mvr-chip for chips |
| Empty state | .mvr-empty, with .is-error for the error variant |
| Loading | .mvr-skel-row with .mvr-spin, .mvr-skel-grid of .mvr-skel-tile |
| Entrance animation | .mvr-anim on a block, .mvr-stagger on its parent |
.mvr-table styling is scoped under .mvx, so it only takes effect inside a result container. Four rules govern everything you write:
- No
style=. Add a class. If two tools need it, add it tocss/mvr.cssonce, not to your renderer. - No raw hex. Color comes from
css/tokens.cssor from.mvr-tier. - No emoji. Inline SVG only.
- No
<div class="glass-card">. That is the legacy frame, and 382 occurrences remain indashboard.htmlwaiting to be removed.
Escape every value that came from an API. window._esc is available globally, defined in js/mv-polyfills.js, along with _tonePal and _pal. Those three are referenced by bare name across the premium renderers, which is why that file must not be reordered relative to the inline blocks.
4. Register it#
Add one entry to the BES map inside window.mvRenderOld:
keyword_pulse: ["mvRenderKeywordPulsePremium", iq],iq is {idPrefix:"", query:q}, ip is {idPrefix:""}, qq is {query:q} and br is {brand:q}. Pick the one your renderer reads.
5. Do not attach the shared panels yourself#
A function named verified() runs after every successful dispatch path. It calls mvAttachVerifiedHeader(res, data, {}) and then mvGuideAttach(res, type, data, query). So the verified-source header and the Get Recommendations panel land on your tool automatically. mvAttachVerifiedHeader returns immediately unless the payload carries _dataSource or _dataSourceVerified, and it will not add a second header if one is present.
6. Add a recommendations rule#
The panel appears only if a rule produces findings. Rules live in js/mv-guide-rules.js, keyed by the same tool type the API uses, so adding a tool means adding an entry there rather than editing the monolith.
keyword_pulse: {
label: "Keyword Pulse",
context: function (d, q) { return "the keyword profile of " + (str(d && d.domain) || q); },
viewMap: V_TECH,
build: function (d) {
var out = [];
if (has(d.lost) && n(d.lost) > 0)
out.push({ sev: "high", t: n(d.lost) + " keywords lost a top-10 position this month", area: "Keywords" });
return out;
}
}A finding is { sev: 'high' | 'med' | 'low', t, area }. finalize sorts by severity, caps the list at six and assigns ids. area drives the Fix this in <area> deep link through viewMap.
The one hard rule: a rule fires only on evidence. A finding built from a field the payload never contained sends a customer to fix something the tool did not measure, which is worse than no panel. Every accessor must be null-safe, and a rule that finds nothing returns [] so no button is drawn.
7. Keep the strings translatable#
Every label you write is UI. The extractor reads template-literal markup inside <script> blocks, which is where most result labels live, so plain text is picked up automatically. Concatenations such as n + ' keywords' are not, and are handled instead by the dynamic result translator, which covers a fixed selector list including .mvr-section-t, .mvr-kpi-label, .mvr-kpi-sub, .mvr-tier, .mvr-empty, .mvr-insight and .mvr-note. Staying inside those classes is what makes a sentence translate. Details and the four-command pipeline are in Localization system.
8. Run the gates#
node tests/design-lint.mjs # no tracked count may rise
node tests/chkblk.mjs dashboard.html # 0 broken inline script blocks
node tests/guard.mjs dashboard.html # no </script> inside a template literal
node tests/guide-rules.mjs # every rule, three ways
node tests/guide-wiring.mjs # the panel actually reaches the DOM
BASE_URL=… node tests/smoke.mjs --tools=keyword_pulsetests/smoke.mjs exists because seventeen tools once "worked", in that the button clicked and the spinner showed, while the renderer silently threw a ReferenceError. Run it against a deployed preview before you call this done. All of the scripts are catalogued in Verification scripts.
Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
The result shows No legacy renderer for <type> | The BES key does not match the data-run value, or the function name is misspelled | The map key is the tool type the API uses, and mvRenderOld checks typeof W[name] === "function" before calling |
| The spinner never clears | Your renderer threw | mvRenderOld catches, logs [mv] renderer failed for <type>, then falls back to mvRenderAny. Open the console |
| A generic JSON dump appears instead of your layout | Same as above, or dispatch reached step 5 | Check the console for the caught error |
_esc is not defined | Script order changed | js/mv-polyfills.js must stay immediately after css/legacy-mvx.css |
| Everything renders inside a black box in dark mode | The .mvx wrapper defines its own pure-black --bg, which children inherit | css/mvr.css already neutralises this. Confirm your markup is inside .mvr and that mvr.css is still linked after the legacy sheet |
| Design lint fails after your change | A hex, an inline style=, an emoji or an off-scale radius | Replace them. Do not run --update-baseline to clear a failure |
| Your table looks unstyled | You used .mvr-tbl | The real class is .mvr-table |
A \n search-and-replace matched nothing | dashboard.html is CRLF | Use \r?\n |
| The change does not appear in local dev | wrangler pages dev dist serves dist/ | Copy the edited file into dist/ |
| The change does not appear in production | /js/* and /css/* are served with a four-hour cache | Bump the ?v= query string, and bump CACHE_NAME in sw.js |
FAQs#
Do I have to use .mvr-*, or can I write my own classes? Use .mvr-*. If the primitive you need genuinely does not exist, add it to css/mvr.css once and use it from there. A class defined inside a renderer is a new source of truth, and that is the failure the constitution exists to stop.
Where does the export code go? Nowhere. js/mv-export.js extracts one structured model from the rendered result and covers PDF, Excel, CSV and JSON for every tool with no per-tool code. Semantic markup, real <table> elements and honest headings are what make it work.
What makes the exec-summary tab bar appear? Two renderer families are handled differently. Results built from .mvr-section markers are grouped into panes, with the first pane always titled Summary, and that needs at least two segments. Results built from titled .glass-card blocks are categorised and shown or hidden non-destructively, and that needs at least three titled cards. Using .mvr-section puts you on the better path.
Can I skip the recommendations rule? Yes, and nothing breaks: no rule means no findings, which means no button. But before js/mv-guide-rules.js existed only about eighteen of fifty-eight tools had the panel, precisely because it was optional per tool. Add the rule.
How do I test a renderer without spending credits? js/tool-samples.js holds real captured results as window.MV_TOOL_SAMPLES, used by the See example preview. It is data rather than UI and is deliberately excluded from the translation scanner, so do not add UI copy to it.
Why is mvRenderOld called "old" if it is the current entry point? It is the legacy dispatcher that the Result Shell is meant to replace. It is still the only path from a tool run to a rendered result, so treat the name as history rather than a warning.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.