Chart placeholder

The box the trend plot is drawn inside. The box is this component; the line inside it is markup. On two of the four screens it stopped being a placeholder at stage 07, and on those two it stopped being FRAMED on 2026-08-19: a fill and a hairline are what you draw when there is nothing to draw. The name it keeps is the one the registry knows it by.

Atom4 grey pages4 places1 axis: what is inside itno states, not interactive

Anatomy

One zone and one content slot, which is what makes it an atom. Everything below is drawn by design/system/components/chart-placeholder.css through the same entry point a product screen loads, so this page cannot drift from the screens: there is no second copy of the CSS here to drift from. The SVG in the box is the markup from History and trends, copied without a change except the two token names, which the screens wrote in kit.css's vocabulary until step 6 moved them onto the system's.

MayJunJul

Why every stroke carries vector-effect="non-scaling-stroke". The SVG is written with preserveAspectRatio="none", so the 300 by 100 viewBox is stretched unevenly to fill whatever width the box is given. Without the effect the stroke stretches with it, and a gridline that is one pixel at 360px is a different thickness at 1280. With it, the geometry scales and the ink does not.

Measured on this page: the box renders 898 by 220, the border still takes its 1px on every side and paints nothing, and the SVG inside it fills 100% by 100% of the content box. It read 780 until 2026-08-19 and the change is the point: the component declared a max-width of its own for one day, and the cap now sits on the screen that holds it, so on this stand the box is simply as wide as the stand and in the product it is the 780 of the screen's content box. It was 168 TALL here until 2026-08-18, for a different reason that still holds: the stand is not a size container, so the container query below could not fire, and the specimen showed the phone's height on a desktop. The label text sits at 11px in the muted tier, 5.78:1 on paper in the light theme and 6.4:1 in the dark, where it read 5.37 and 5.77 against the recessed fill it used to stand on.

Behaviour at width

From --bp-tablet, 47.5rem: the height goes to 220px. One declaration, and it is the only component in the system whose adaptation is vertical. A chart is the one thing on a wide screen that genuinely uses the width, and a band that stays phone-height inside a 980px pane reads as a placeholder for something missing rather than as a chart.

And the 220 had never once rendered past 900. The founder pointed at this box and said it was not beautiful; it was also not there. Past the desktop point .app is a grid at height: 100dvh and the screen inside it is a flex column, so the screen has a DEFINITE height and every flex child with the default flex-shrink: 1 is compressed to fit. A block with height: auto has its content as an automatic minimum and cannot lose anything; this box declares a height, so it is the only element in the whole coloured product that can. Measured on the live screen before the fix: 162.31px of 220 at 1440 x 1000, 62.31 at 1440 x 900, and 2px at 1024 x 768, where the month labels stood under nothing at all. It depended on the window's HEIGHT, which is why five stages of width sweeps never found it. The repair is in the shell and not here, because it is a fact about a pane that scrolls rather than about a chart.

The band has a measure now, and so does the label row under it. The threshold above is the register's own, and the width used to be undeclared: the chart took 100% of whatever column it stood in, which past the desktop point is the whole pane. That made it 1140 by 220 at a 1440 window, a 5.2 to 1 band holding one line, standing next to a sentence that stops at its own reading measure around 500. The plot did not look wrong because of what was in it. It takes --container-page, 780, which is the measure the shell itself hands a block between the two points, so this is the screen's own number and not a new one: 3.5 to 1 at the cap and 3.3 at a 1024 window. The label row takes it too, because the two only read as one object while they are the same width. See Width.

Variants and sizes

There is no modifier class on this component, and there is one axis: the frame is drawn or it is not, and :has(.plot) decides it rather than a class. That is the same principle the rest of the page runs on, taken one step further: what differs between the four places this box stands is what is inside it, that is markup rather than CSS because the data is different on every screen, and since 2026-08-19 the material follows the content instead of being declared beside it. The matrix below is therefore read from the four pages rather than from the file, and it is the honest inventory of what a person actually sees.

What is in the box

Draw the frame honestly, and put in it only what is true. An empty frame is a real answer; a decorative line that stands for nothing is not

The markup Where it stands
A drawn plot 3 gridlines
+ 1 path, 3 points
History and trends. Drawn at stage 07, because the grey renders [chart: monthly total, May to Jul] as literal text, and a build note printed on a coloured screen is the wireframe convention outliving its stage. The three points are the same three figures the sentence above the box states in words
The frame alone 3 gridlines
no path
the Pro gate, and it is not the same object one state later. The gridlines are there and the line is not, above the person's own five category labels. Every source in the references blurs a decorative chart behind its paywall; a blur is a lie about our own product, and an honest empty frame persuades better (node 5.12.4)
A literal string [chart: ...] A line in the backlog, found by opening all four. History and trends, empty still renders [chart: waiting for a third month] and History and trends, loading still renders [chart], both as visible text in the coloured product. Stage 07 drew the two above and left these two, and the box centres its content precisely because the fallback exists. Neither is a defect in this component: the fix is markup on two screens, and it belongs to the same step that moves the screens onto system classes

The two, at full width

The matrix shows them at the width of a table cell, which is enough to tell them apart and not enough to read. Here is the same box on the two screens that carry a drawn one, at the width a screen gives it. Read them as a pair: the gate is not the plot with something taken away from it visually, it is the plot with the data absent, and everything that is true without the data is still on screen.

MayJunJul

History and trends, Pro. Three gridlines and a three point path. The x positions of the points line up with the three month labels underneath.

StreamingSoftwareMusicFitnessNews

The Pro gate, and the labels under it are the person's own five categories rather than a sample. Same frame, same gridlines, no path. Nothing is blurred, nothing is faked, and the sentence beside it says what Pro adds.

Height

Set by the width the SCREEN is given, never by the importance of the data. There is no tall variant and no short one

Value Where
Mobile and narrow168pxthe base, and the height every one of the four boxes has at 360px
Past a 760 container220pxa container query, not a media query: the box grows when the screen it stands on is GIVEN the width, which is the browser window only while nothing stands between body and .app. The three container steps cannot become variables, because a custom property is not valid inside a @container condition, so they are documented rather than tokenized
On this page168px alwaysSaid out loud rather than hidden: the container query above needs an ancestor with container-type: inline-size, and the product declares that on the stage wrapping a screen. A component page has no size container, so the boxes here stay at their mobile height however wide the window is. Honest, and not patched: a stand page never bends the system to look better

The line was not petrol, and the refusal held for a year of stages before the founder lifted it. It read: "It is --text-body, the same ink as a sentence. Petrol is spent on exactly three jobs in this product, the primary action, the current selection and the trust line, and a data line would be a fourth claim on the one raised voice this system allows." That was right, and a builder could not have overturned it - see petrol's fourth job for the day it was granted and for the test it had to pass. The gridlines are quieter than the line at --line-container: they exist to let the eye read a slope, not to be looked at.

2026-08-18: the line stopped being a ruler

The founder, on this page: “ти считаешь це гарним? сделай нормальним и современним, а не как будто вайрфрейми ми перенесли”. Fair. The box had been repaired that morning - a height that had never applied past 900, a 5.2 to 1 ratio nobody chose, a plot 6 per cent inside its own labels - and what was left inside it was still a dead straight segment, which is the one shape that always reads as a placeholder. It was straight for a stated reason: the copy above names May and July and nothing else, and the file refused to invent a June figure on a screen about money.

The refusal was right and the premise was wrong: June is not missing, it is derivable from this same screen. The “What moved” list under the chart says ChatGPT Plus is new since June, $20.00 a month, and 172.90 + 20.00 is exactly 192.90. Netflix's $2.50 is dated Jul 28 on the price-change screen and has not been charged yet, so it is not in July's total. The three months are 172.90, 192.90, 192.90: the total jumped when one subscription arrived and has held since. Nothing is invented - the middle value is read off two facts the screen already states, and the shape it makes is the one the copy above the chart calls “the shape of it”.

And the curve eases into the plateau rather than turning a corner, because a monthly total is a level and not an event: the value did not spike on a day, it stepped up and stayed. M0 80 C 45 80, 100 28, 150 28 L300 28 - one cubic into June's x, then flat to July's. May sits two units above the bottom gridline and the plateau six below the top one, so the plot uses the band instead of hugging its middle.

What was built and rejected, so it is not tried again. Round end dots on the two measured months: under preserveAspectRatio="none" a circle is drawn as an ellipse, and vector-effect fixes a stroke's WIDTH and not a cap's shape - measured, the dots came out 23 by 13 at a 1140 box. The zero-length round-cap trick fails the same way. A petrol line would transform it and is not the builder's: D-Concept spends petrol inside a screen's content on the primary action, the current selection and the trust line, and a data line is none of the three. It is a founder's call and it is written down rather than taken.

2026-08-18: the chart answers a pointer

The founder, with a dashboard chart as the reference: “нам би надо сделать его активнім, что им можно будет пользоваться, двигать мишкой и смотреть сколько когда было затрат”, and, fairly, “почему ты, если не знаешь как рисовать, не смотришь это в интернете”. The reference has four things this chart did not: a value axis, dashed rules, a hover readout and a marked point. It has all four now.

The gridlines left the markup and became the frame's own background. Three dashes drawn by the box cost nothing, are identical on every state by construction, and put the y positions where the axis labels read them from - one owner for 10 / 50 / 90 instead of two copies in four files that can drift. Dashed rather than solid is the whole difference between a scale and a set of bars. They are --line-container and were --line-divider for a few hours the same day: the softening was right for three SOLID rules across an uncapped 1140px box, which read as banding, and both halves of that reason went when the rules became dashed and the band took a cap. Measured, the softening had started costing the thing they exist for: --line-divider is 1.09:1 on the recessed fill and a 1px dash at 1.09 is not there on a 1x display, which turns the empty, locked and loading frames - where the rules ARE the picture - back into the grey slab the founder called out. --line-container is 1.16:1: still a hairline, and there. background-origin: content-box keeps them out of the axis gutter, and the fill still covers the whole box because a background COLOUR always paints the padding box whatever the origin says.

The value axis appears only where there is something to label. :has(.yaxis) is what keeps the locked, empty and loading frames full width: a gutter of empty space beside a frame with nothing in it is worse than no gutter. The x labels take the same inset from the same custom property. That property is declared on the shell and not on the box, and the reason is a bug caught before it shipped: the label row is the chart's SIBLING, and a custom property inherits downward and never sideways - written on .chart, the row's own var(--chart-gutter) resolved to nothing, the declaration was dropped as invalid, and the months sat 40px left of the points they name.

The cursor is built by behaviour.js and never by the markup, and that is the whole of the enhancement. With no script the screen is a plot, its months and its axis, and role="img" still describes it. The script adds a layer, and only where the box carries data-points. It also recomputes the path from that same array and writes it back over the authored one, so the curve a person hovers and the number the card shows cannot disagree: measured, the generated d is character-for-character the authored one.

The dot is an HTML circle and not an SVG one, which is what made this possible at all. The plot is stretched with preserveAspectRatio="none", so an SVG circle is drawn as an ellipse - measured, 23 by 13 at a 1140 box - and vector-effect fixes a stroke's width, not a cap's shape. A box outside the SVG is stretched by nothing, so border-radius: 50% is a circle at every width: measured, 10 by 10 at 390 and at 1440. --cx and --cy are set in per cent of the plot area, the one coordinate system both sides agree on. The card flips under the point when the point is high, because the frame clips, and is clamped horizontally for the same reason.

And it is reachable without a pointer. The box takes tabindex from the script rather than from the markup, because the markup is right when the script does not run: an element that can be focused and does nothing is worse than one that cannot. Arrow keys walk the months, Home and End jump to the ends, Escape puts the reading away.

2026-08-18: petrol's fourth job, and the wash that fades

The founder: “давай теперь черним поменяем на брендовий, чуть градиентов добавим”. Until that sentence the line was --text-body, and this page, the component and tokens.css all carried the same refusal in the same words: petrol is spent on the primary action, the current selection and the trust line, and a data line would be a fourth claim on the one raised voice the system allows. The refusal was right and the founder is the only person who could lift it. They did, so D-Concept is amended in CLAUDE.md, DESIGN.md's One Voice Rule now names four jobs, and the ground is D-Plot in docs/decisions.md.

It survives the rule it amends, and that was the test rather than a wave-through. The fourth job is not a fourth PLACE: a screen carrying a plotted line carries exactly one, and no screen in the product carries a plot and a filled action inside the same zone - History and trends puts its only filled button below the trend list, and the Pro gate has no line at all. Counted rather than assumed: 1 element, 1 screen. The cursor's vertical rule stays --line-container for the same reason - it is neither the line nor the number read off it.

The wash fades, and it is a MASK rather than an SVG gradient. A <linearGradient> lives in <defs> and is referenced by id, so every screen carrying a chart would carry a copy of the paint in its own markup - the exact thing this component spent a commit removing an hour earlier - and two charts on one page would need two ids or share one silently. A mask is one CSS declaration on the class the system already owns: no id, no <defs>, no markup. It sits behind @supports, so the fallback is real: where masking is missing the area keeps the flat --plot-area tint and the chart is the one it was five minutes earlier.

2.5 and not 2, and it is not drift. A near-black line at 2px carries by value and a petrol one does not, because the accent sits far closer to the recessed fill than the ink did: measured, --plot-line is 5.78:1 on the recessed fill in the light theme and 6.15:1 in the dark, against the ink's 10-and-up. Half a pixel is what puts the line back at the weight it read at the day before. It gained a little of that back on 2026-08-19, when the fill stepped off a plotted chart: the same line is 6.23:1 on paper and 6.82:1 in the dark. The 2.5 stays, because it is now what the line weighs on the state that has no fill either way.

2026-08-19: the frame steps off the picture, and the screen takes the measure

The founder, on the finished chart: “пока еще слабовато ... что то растянуто на всю ширину что то нет ... я бы хотел чтобы это выглядело круто, а не дёшево”. Two things were wrong and only one of them was in this component.

The width was the screen’s. Measured on the live History and trends at a 1920 window, the direct children of one screen came out at 1280 (the text block, the trend list, the action row), 780 (the chart and its months), 525 (the readout), 459 (the two muted lines) and 384 (the range control). Five right edges. The chart was the only block with a measure, because this file had given it one the day before, so the picture stopped in the middle of a list that ran to the pane. The cap moved to the pane - .app > .screen:has(.chart) in app-shell.css, the same 780 - and this component states no width at all. A cap inside a cap is the drift stage 09 existed to remove.

The material was this component’s. A grey slab with a hairline round it, holding a line, is the shape of a PLACEHOLDER, which is what this component is called for a reason and what it drew for two stages. Every chart the founder put up as a reference had no box at all: dashed rules, a value axis, a line, and the page’s own paper behind them. The picture IS the container once there is a picture, so the fill and the frame stay exactly where they still do work - the honest empty frame that node 5.12 block 8 asks for, and the loading skeleton that must not move when the numbers land - and step off the two states that plot something. :has(.plot) is the test, and it is the honest one: it asks whether there is a line in the box.

Nothing lost contrast by it, which was the thing to check rather than assume. The line reads 6.23:1 on paper against 5.78 on the fill in the light theme, 6.82:1 against 6.15 in the dark; the axis labels read 5.78:1 against 5.37 and 6.4:1 against 5.77. border-color: transparent and not border: none, so the border still takes its 1px on every side and the three background rules keep landing on the same three per cents: measured after, the box is the same 220 tall and the line has not moved.

The wash was re-cut for paper. The mask ran 0.30 to 0.02, and the 0.02 was deliberately not zero so the grey slab showed through the foot of it. With no slab, 0.02 of petrol over paper is a flat film with a visible straight edge where the closed path ends. It is 0.22 to 0 now: the same gradient read against paper, reaching nothing at the foot, so it has no edge at all.

And the line got an end. A curve that ran to the frame and stopped read as a shape cut off by the box; the cursor answers a pointer and is invisible until there is one, so at rest the plot had no terminal. The dot on the last point is what a chart of a running total puts there, and it says what the sentence above the chart says: this is where you are now. It cost the clip: the dot sits at exactly 100% and would render as a half-disc against a box that hides its overflow, so a plotted chart is overflow: visible - which it can afford, because there is no rounded fill left for a stroke to escape.

Two entries went into quality13.cjs the same day, and neither is a fix. Dropping the clip made the instrument see a geometry that was always this way: the value axis sits in the chart’s own gutter and the end dot sits ON the curve, so both cross the parent’s content box by construction. They are placed by coordinate rather than by flow, so the content box is not their frame of reference. The exclusion is one line, named at the line, and it is the third of three in that file.

When to use it

This component exists on one screen and its four states, and that is the point. DESIGN.md names the reflex the whole visual language was chosen to refuse: red numbers, alarm badges, dark dashboards, "a chart where a sentence would do". So the chart never arrives alone and never arrives first. On History and trends the readout stands above the box and says it in words: "Your monthly total went from $172.90 in May to $192.90 in July, up about $20 across three months." A person who does not read charts still leaves with the answer, and the picture is the same three figures for a person who does.

Where a person meets it: only behind History and trends, which is Pro, and on its gate, which is the one view of that screen the canonical person on Free can have. It never appears on Home. The calm view answers "what is going out" with one number, and a trend line on that screen would turn a fact into a verdict.

The rule, and the anti-rule

Draw the frame honestly

Behind the gate the frame is real, the category labels under it are the person's own, and the line is simply absent. Nothing is blurred and nothing is invented. What Pro adds is stated in a sentence beside it, which is a claim that can be checked, unlike a smudge.

Use a readout instead
Your monthly total went from $172.90 in May to $192.90 in July, up about $20 across three months.

When the answer is a number, a picture of the number is decoration. The object is the readout, .readout, a 15px sentence at a 46ch measure, or the big total, .total, when the number is the whole screen. Reach for the chart only when the shape of the change is the content, and even then the sentence goes first: on the one screen that has both, the readout is above the box, not under it.

Let the box own the height
[chart: waiting for a third month]

Empty, waiting or drawn, the box is the same HEIGHT: 168px on a phone and 220 past the tablet point. It reserves that space from the first paint, so the page does not jump when the data lands and does not shrink when there is none. The WIDTH is not its own: it is the width of the screen's content box, 780 in the product, and the component has stated no max-width since 2026-08-19. The centring is what makes the fallback string sit properly in the middle.

Never let a build note ship
[chart: monthly total, May to Jul]

A bracketed string is the wireframe placeholder convention, and it is correct in wireframes/ and wrong the moment the screen is in colour: it reads as a note to the team, printed to the person. Two of the four coloured pages still carry one, which is why it is in the matrix above as a backlog line rather than as a footnote. The rule is the same one that governs the merchant marks: filling a slot the grey wrote as text is the one thing a colour copy is allowed to add.

Limits

U15, composition. One content measure per screen, and every state of a screen ends in the same place. This component is the reason the rule exists. On 2026-08-18 it took a max-width of its own to stop the band rendering 5.2 to 1, and the next morning the founder saw what that cost: a picture stopping at 780 in the middle of a list that ran to 1280. The measure belongs to the pane. This file states no width at any point, and the screens it stands on take theirs from the shell - which the same evening the founder set to Home's 1280, so the answer to the 5.2 to 1 band is now the third HEIGHT step below and not a width at all. Full table with sources and how to check: Usage rules.

States

One, and it arrived on 2026-08-18 with the cursor. This section read "none, and that is a decision" until then, and the sentence under it is kept because the decision it records is still the ground: there is no legend, no :hover rule, no :focus-visible rule and no [disabled] in chart-placeholder.css, and the box is one object to a screen reader through role="img" and its full sentence. What CHANGED is that a pointer or an arrow key now moves a reading along the curve, and the layer that draws it is created by behaviour.js rather than declared here: .cursor.on is the whole of the state, and with no script there is none. The dot on the last point stands down while it is on. What still looks like state and is not, drawn against empty against loading, is content: it is what is inside the box, and it is decided by the screen.

The technical half

ReadsWhich tokenGrowing from
the fill, where there is no line in the box--bg-recessed--panel. The box sits back from the card it stands on rather than forward off it, which is what stops a picture from becoming the loudest thing on the screen. Since 2026-08-19 a box with a plot in it takes neither this nor the frame below: see the frame steps off the picture
the frame, same condition--line-divider--hairline-soft, and it is the softer of the two line tokens on purpose. This box is not a card and not a control: nothing about it has to be found by touch, so the 3:1 a control's edge owes does not apply
the fallback string--text-muted--muted. 5.37:1 on the recessed fill light, 5.77:1 dark
the corner--radius-smprimitive, straight: geometry has nothing to override
the plotted line, its end dot and the number read off it--plot-line--petrol light, --petrol-dark dark. Petrol's fourth job, granted by the founder on 2026-08-18 and grounded in D-Plot; this row read --text-body until then. 6.23:1 on paper light, 6.82:1 dark
the wash under the line--plot-area--petrol-tint light, --petrol-tint-dark dark. It is the fallback: where masking is supported the area is --plot-line under a 0.22-to-0 gradient instead
the three rules, and the cursor's own--line-container--hairline. Quieter than the line on purpose: they exist to let the eye read a slope, not to be looked at

Lives in design/system/components/chart-placeholder.css. Stands on 4 grey pages in 4 places, and on all four coloured ones: History and trends, the Pro gate, empty, loading.

<div class="chart" role="img" data-points="May 172.90|Jun 192.90|Jul 192.90" data-scale="160 200" aria-label="Line chart of your monthly total from May to July, the same figures as the sentence above"> <ul class="yaxis" aria-hidden="true"><li>$200</li><li>$180</li><li>$160</li></ul> <svg viewBox="0 0 300 100" preserveAspectRatio="none" aria-hidden="true" focusable="false"> <path class="area" d="M0 64.2 C50 64.2, 100 24.2, 150 24.2 ... L300 100 L0 100 Z"/> <path class="plot" d="M0 64.2 C50 64.2, 100 24.2, 150 24.2 ..." vector-effect="non-scaling-stroke"/> </svg> </div> <p class="metarow"><span>May</span><span>Jun</span><span>Jul</span></p> Geometry only: three class names and no colour. Every stroke and fill on this plot is in chart-placeholder.css since 2026-08-18, because a colour written as an SVG attribute is a screen carrying a style of its own in the one place the rollout ledger cannot see it. The gate, the empty state and the loading state are the same block with the two <path>s removed. Nothing else changes.