AI authoring guide
The dsdoc JSON format AI assistants use to build and edit DreamSheets documents — tables, tiles, charts, the formula language, and the edit workflow, in one reference.
This is the exact guide the MCP server's `describe_format` tool and AI → Copy Prompt for AI hand to an AI assistant. Using claude.ai? Download it as a Claude skill and upload it once in your Skills settings — Claude then knows DreamSheets in every chat.
You are writing a DreamSheets document. DreamSheets is a desktop spreadsheet built around a canvas of named tables, KPI tiles, and charts — not a cell grid. Your job: output one JSON object in the format below. The user will paste it into DreamSheets (AI → Import AI Output…), which builds the real document.
Output rules
- Output only the JSON object, ideally in a single ```json code block. No commentary before or after.
- Reference everything by name — never invent ids. Table and tile names share one namespace and must be unique across the whole document; column names must be unique within their table.
- Use readable names:
Filer Name, notfiler_nameorFilerName. Quote them in formulas ('Filer Name') and SQL ("Filer Name"). No symbols ($ % ( )) — units belong informatordescription. - Layout is optional: omit
rectand DreamSheets auto-places every card (usually best). A table, tile, or chart may carry an explicit"rect": { "x", "y", "w", "h" }when you want to control placement — it is honored exactly. - Omit anything you don't need. Every field except the ones marked required is optional.
The shape
{
"title": "Q3 Budget",
"tabs": [
{
"name": "Overview",
"tables": [
{
"name": "Sales",
"description": "Monthly sales by region",
"columns": [
{ "name": "Region", "type": "text" },
{ "name": "Amount", "type": "number", "format": "currency" },
{ "name": "Units", "type": "integer" },
{ "name": "Total", "formula": "@Amount * (1 + TaxRate)" }
],
"rows": [
["East", 1200.5, 30],
["West", 2200, 51]
],
"totalsRow": true
}
],
"tiles": [
{ "name": "TaxRate", "value": 0.08, "format": "percent" },
{ "name": "GrandTotal", "formula": "SUM(Sales.Amount)", "format": "currency" }
],
"charts": [
{
"name": "Sales by Region",
"type": "bar",
"table": "Sales",
"x": "Region",
"y": ["Amount", "Units"],
"yAxisTitle": "USD"
}
]
}
],
"customFunctions": [
{ "name": "Markup", "params": ["amt"], "body": "amt * 1.2" }
]
}For a simple single-tab document you may skip the tabs wrapper and put tables / tiles / charts at the top level.
Tables
name(required),description,columns(required, at least one),rows,totalsRow,summaryRows.- Columns are either data columns (
type, optionallyformat) or calculated columns (aformulainstead of a type — their values are computed). -type:text|number|integer|boolean|date|timestamp. Omit it and the type is inferred from the row data (dates as"YYYY-MM-DD"strings are detected). -format(display only):"number"|"currency"|"percent"|"date"|"bool", or an object like{ "kind": "currency", "symbol": "€", "decimals": 0 }(symboldefaults to$),{ "kind": "number", "decimals": 2, "thousands": true, "compact": true }(compactabbreviates 2800000 → 2.8M). - `rows` is a 2D array aligned with the data columns only — never include cells for calculated columns. Dates go in as
"2026-07-04"strings; percent values as fractions (8% =0.08). - `totalsRow: true` appends a "Totals" row that SUMs every numeric column.
- `summaryRows` pin custom footer formulas:
[{ "label": "Average", "cells": { "Amount": "AVERAGE(Amount)" } }]. - `descriptionHeight` (px) fixes the height of the card's description strip; omit it to auto-size.
- A column may carry a `style` for column-uniform presentation:
{ "wrap": true },{ "bold": true, "align": "right" },fontSize,italic,strikethrough. Text and background color are per-cell formatting, not part of this.
Query tables (sql)
A table with a `sql` key (alias: query) is a query table: its columns and rows come from a DuckDB SELECT over the document's other tables, and the SQL stays attached so the table can be refreshed when its sources change ("linked": false materializes it once as static data instead).
{
"name": "RevenueByRegion",
"description": "Joined + aggregated in SQL",
"sql": "SELECT s.Region, SUM(s.Amount) AS Revenue FROM \"Sales\" s GROUP BY s.Region ORDER BY Revenue DESC",
"columns": [{ "name": "Revenue", "format": "currency" }]
}- Reference tables by their exact name, double-quoted (
FROM "Q1 Sales"). Calculated columns are materialized before the query runs, so SQL can read them too. - A tile or named value is a name in SQL too:
WHERE "Date" > "Budget Month"(double quotes —'…'is text in SQL). A same-named column wins. - Omit
columns/rows— the result defines them. Acolumnsentry here only decorates a result column (format,description), matched by name; unknown names are ignored with a warning. - Query tables compile after every static table, so the
sqlmay reference tables declared later in the document — and a query table may read another query table in the same document: declaration order doesn't matter, tables are compiled by dependency. - Reach for this over a formula when you want a new table: joins,
GROUP BY, window functions, filtering, reshaping. UseLOOKUP/ calc columns when you just want one more column on a table you already have.
dataWithheld (read-only)
An exported document may show a table with a `dataWithheld` note instead of rows — its data came from a Python/R script or an external database, and was deliberately not included. The columns are real; the values simply aren't shown to you. Reason about that table by its schema, don't invent rows for it, and never emit dataWithheld yourself. An external-database table also shows its sql and connectionId — that SQL runs on the connection, not over the document; import leaves such a table alone.
Tiles (single-value KPI cards)
{ "name": "TaxRate", "value": 0.08 }— a literal number, text, or boolean. Other formulas can reference the tile by name.{ "name": "GrandTotal", "formula": "SUM(Sales.Amount)" }— a computed KPI.- Optional
format(same as column formats) anddescription. - Optional `style` for the value's presentation:
{ "color": "#1e7e34" },backgroundColor,align,fontSize,fontFamily.
Sections (titled frames)
A tab may carry `sections` — titled frames drawn behind the cards to group them visually ("Key Metrics", "Inputs"). Membership is geometric: a card belongs to whichever section's rect contains its center, so a section lists no members and a rect is required.
"sections": [
{ "title": "Key Metrics", "rect": { "x": 480, "y": 0, "w": 724, "h": 220 }, "fill": "#d4edda" }
]- Optional
fill(hex; omit for the default faint tint),style:"solid"(default) |"dotted", andautofit: trueto re-wrap the frame around its members automatically. - Sections are placed exactly where you say and are not collision geometry — they are supposed to overlap the cards they group. Give the cards inside explicit
rects too (the same optionalrectfield every table/tile/chart accepts).
Charts
table(required): the home table's name.type:bar|line|pie|scatter|area|histogram|density|violin|map(defaultbar). Accepted aliases:column→ bar,donut→ pie,point/scatterplot→ scatter,hist→ histogram; the key itself may also be spelledgeomorkind.- Channels take column names:
x(categories),y(one name, or an array for multi-series),color, and for scattersize/shape. A column from another table is written"OtherTable.Column". - Optional:
name,stacked: true(bar/area),xAxisTitle,yAxisTitle,description.
Formulas
Excel-like, but references are by name — there are no cell addresses:
@Column— this row's cell, and the ONLY spelling for it:"@Amount * 0.1". It needs a row, so it is valid in a calculated column or a per-cell formula and nowhere else (a tile or a summary cell has no row).Column— the whole column of the same table, in EVERY context:"SUM(Amount)". A whole column in a single-value slot is#TYPE!, so per-row arithmetic needs the@:"@Amount * 2", never"Amount * 2". Mixing the two is how a share of total is written:"@Amount / SUM(Amount)".Column:3/Column[expr]— one specific cell of that column (1-based).Table.Column— a column of another table:"SUM(Sales.Amount)". Self-qualifying (Sales.Amountinside Sales) means exactly the same as the bare form.TileName— a tile's value:"@Amount * (1 + TaxRate)".TileName.Title— the tile's name as text (follows renames):"\"See \" & TaxRate.Title".- Names with spaces or symbols go in single quotes — Excel's sheet-name spelling:
"SUM('Q1 Sales'.'Unit Price')","@'Unit Price' * 2","'Grand Total' * 1.1".'…'is always a NAME and"…"is always TEXT, in every position, so there is nothing to disambiguate. Renaming a table or tile rewrites its references automatically. PREVROW(Column, [default])— the previous row's value ofColumn(a roll-forward / running-total primitive). On the first row it returnsdefault, or blank (which is 0 in arithmetic) if omitted. Prefer this over an index crutch: write"PREVROW(End, 100) * 1.05", not"IF(@MonthNum=1, 100, End[@MonthNum-1]) * 1.05"— no MonthNum position column needed.ROW()— in a calculated column, the current row's 1-based number within its table (1, 2, 3, …). No arguments. Use it for a row-number column ("ROW()") or a running position; unlike Excel'sROW()it means the table row, not a worksheet row.RANDOM(min, max, [decimals], [seed])— a stable random value in[min, max], rounded todecimals(default 0 = integers). Seeded by row position, so each cell is assigned once and does not change on unrelated edits or reload (not Excel's volatileRAND). Composes like any number:"RANDOM(1, 100)","RANDOM(0, 1, 4)","RANDOM(1, 6) + RANDOM(1, 6)". Two columns with identical arguments produce identical values — pass a distinctseed("RANDOM(1, 100, 0, 2)") to reshuffle or to make an independent second column.NORMALDISTRIBUTION(x, mean, stddev, cumulative)— normal distribution density (cumulative= FALSE) or the probability a value is ≤x(cumulative= TRUE). `NORM.DIST` is the same function under Excel's spelling; both names work, and XLSX import/export translates either way.INVERSENORM(probability, mean, stddev)— the value at which the normal CDF reachesprobability(0 < probability < 1). `NORM.INV` is the same function under Excel's spelling. Compose withRANDOMto sample from a normal distribution:"INVERSENORM(RANDOM(0, 1, 6, seed), mean, stddev)".T.DIST(x, deg_freedom, cumulative)/T.INV(probability, deg_freedom)— Student's t distribution and its inverse, under Excel's own names.T.INV(0.975, df)is the critical value for a two-sided 95% interval. Excel's three-segment names (T.INV.2T,T.DIST.2T,T.DIST.RT) do not parse — a function name may carry only one dot — so writeT.INV(1 - alpha/2, df)instead.CONFIDENCE.T(alpha, standard_dev, size)— HALF the width of the(1 - alpha)confidence interval for a mean, so a bound is `"AVERAGE(Trial.Score) + CONFIDENCE.T(0.05, STDEV.S(Trial.Score), COUNT(Trial.Score))". Prefer it overCONFIDENCE.NORM(...)`, which assumes the population standard deviation is known rather than estimated.- Table access (computed indices): -
Table[Row, Column]— reads a cell at 1-based row and column indices (both computed at runtime). -CELLBYINDEX(Table, Row, Column)— explicit version of the above. -COLUMNBYINDEX(Table, Column)— in a calculated column, reads the current row at 1-based column index. -ROWBYINDEX(Table, Row)— in a calculated column, reads the current column at 1-based row index. - Operators:
+ - * /,^(power),&(text concat), comparisons= <> < > <= >=. Strings use double quotes; escape with\". -^is left-associative (2^3^2is 64), and unary minus binds looser than it:-2^2is -4 (standard math). Write(-2)^2if you mean 4. - There is no%operator — write0.05, not5%. Scientific notation is supported (1e5,1.5e-3,2E+10). - Functions:
SUM, AVERAGE, COUNT, MIN, MAX, COUNTIF(range, criterion), COUNTIFS(range1, criterion1, …), SUMIF(range, criterion, values), SUMIFS(sum_range, range1, criterion1, …), LOOKUP(needle, lookup_col, result_col, [default]), XLOOKUP(needle, lookup_col, result_col, [if_not_found]), IF(cond, then, else), IFERROR(value, fallback), CONCAT, LEN, TRIM, ROUND(n, digits). -XLOOKUPis the indexed lookup — on large tables an exact-matchXLOOKUPover a data column is served by a prebuilt index, so prefer it for big joins.LOOKUPhas the same argument order. - The plural*IFSforms AND every criterion —SUMIFS(Amount, Region, "East", Units, ">10"). NoteSUMIFS's sum range comes first (unlikeSUMIF). -SUMPRODUCT(array1, array2, …)multiplies equal-length arrays element-wise and sums (a weighted sum / dot product):SUMPRODUCT(Qty, Price). Only the plain multi-array form — theSUMPRODUCT((cond)*vals)array-arithmetic idiom is not supported; useSUMIFSfor conditional sums. - Math:
ABS(n), SQRT(n), MOD(n, divisor), INT(n), ROUNDUP(n, digits), ROUNDDOWN(n, digits). These follow Excel's semantics, not a programming language's:MOD's sign follows the divisor (MOD(-3, 2)is 1),INTfloors (INT(-2.5)is -3),ROUNDUPgoes away from zero. - AlsoLN(n), LOG(n, [base=10]), LOG10(n), EXP(n), POWER(n, p), PI(), SIGN(n), SUMSQ(...),TRUNC(n, [digits])(chops toward zero, unlikeINT),CEILING(n, [significance])/FLOOR(n, [significance])(round to a multiple, default 1), and the combinatoricsFACT(n), GAMMALN(x), COMBIN(n, k), PERMUT(n, k). A log transform is"LN(@Income)"; every out-of-domain argument (LN(0),LN(-1),FACT(-1)) is an error value, never a silent NaN. - Statistics — Excel's names, Excel's arithmetic, so results diff cleanly against a spreadsheet or R: - Spread:
STDEV.S/STDEV.P(...),VAR.S/VAR.P(...),AVEDEV(...),DEVSQ(...). The.Sforms use n−1 and need two or more numbers (one value is#DIV/0!, not 0). - Centre:MEDIAN(...),MODE.SNGL(...),GEOMEAN(...),HARMEAN(...),TRIMMEAN(array, percent). - Position:PERCENTILE.INC/PERCENTILE.EXC(array, k), `QUARTILE.INC/QUARTILE.EXC(array, quart),RANK.EQ/RANK.AVG(number, ref, [order]),PERCENTRANK(array, x, [significance]),LARGE/SMALL(array, k)..INCinterpolates onk(n−1),.EXC` onk(n+1)— pick the one the source you're matching used. - Shape:SKEW(...)(3+ values),KURT(...)(4+ values, excess kurtosis so a normal sample is ~0),STANDARDIZE(x, mean, stddev). - Counting:COUNTA(...)counts everything non-blank (text included);COUNTBLANK(...)counts the blanks. - Regression:SLOPE/INTERCEPT/RSQ(known_ys, known_xs),CORREL(array1, array2),STEYX(known_ys, known_xs)(residual SE on n−2 df; it carries no x, so it is the interval at the centre only — never build a prediction interval from it),FORECAST(x, known_ys, known_xs)(`FORECAST.LINEAR` is the same function),TREND(known_ys, [known_xs], [new_xs])(simple regression only), and the DreamSheets-onlyTRENDEQUATION(known_ys, known_xs, type, [order])returning the fitted equation as text (type:"linear"|"exp"|"log"|"power"|"poly"). These use the SAME fit the chart trendlines draw, so a tile computed from them always matches the chart. - Conditional:AVERAGEIF(range, criterion, [avg_range]), `AVERAGEIFS(avg_range, range1, criterion1, …),MINIFS/MAXIFS(value_range, range1, criterion1, …)` — same criterion syntax asSUMIF.MINIFS/MAXIFSreturn 0 when nothing matches, as in Excel. - A bare column name aggregates the whole column anywhere ("STDEV.S(Amount)"), and naming its table ("STDEV.S(Sales.Amount)") means the same thing. - Dotted names are ordinary function names here —STDEV.S(...)parses as one call. - Logic:
AND(a, …), OR(a, …), NOT(a). These reject text —AND("hello")is an error. -IFS(cond1, value1, cond2, value2, …)returns the first true condition's value (#N/Aif none — end withTRUE, fallback).SWITCH(expr, value1, result1, …, [default])compares like=(text is case-sensitive). Prefer either over deeply nestedIFs. - Error/type checks:
ISERROR(v), ISNA(v), ISBLANK(v), ISNUMBER(v), ISTEXT(v). Use these to guard, e.g.IF(ISBLANK(@Cost), 0, @Price / @Cost). Number-like text isISTEXT, notISNUMBER. - Dates:
TODAY(), NOW(), DATE(year, month, day), EDATE(serial, months), EOMONTH(serial, months)return dates;YEAR/MONTH/DAY/HOUR/MINUTE/SECOND(serial),WEEKDAY(serial, [type]), andDATEDIF(start, end, "d"|"m"|"y")return numbers. A date is a serial number (days since 1970-01-01), so date arithmetic just works:"@Date + 30"is thirty days later,"@End - @Start"is a day count. A calculated column whose formula's outermost call returns a date (the first five above) auto-formats as a date — noformatneeded. A column of=YEAR(…)stays a number;"@Date + 30"shows a bare serial until you set itsformatto"date"(root is arithmetic, not a date call), so add"format": "date"there if you want it shown as a date. - Time-value of money:
NPV(rate, value, …),XNPV(rate, values, dates),IRR(values, [guess]),XIRR(values, dates, [guess]),PV(rate, nper, pmt, [fv], [type]),FV(rate, nper, pmt, [pv], [type]),PMT(rate, nper, pv, [fv], [type]),NPER(rate, pmt, pv, [fv], [type]),RATE(nper, pmt, pv, [fv], [type], [guess]). These follow Excel's conventions exactly, including the two that surprise people: - Sign convention: money paid out is negative, money received positive.PMTon a positive loan amount returns a negative payment:"PMT(0.05/12, 360, 200000)"is -1073.64. Flip it with a leading-if you want a positive number on the card. - `NPV` discounts the first value by one period. A t=0 outlay goes outside the call:"-1000 + NPV(0.1, Cashflows)", not"NPV(0.1, Cashflows)".IRRis the opposite — its first value is t=0, so pass the outlay inside:"IRR(Cashflows)". -typeis 0 (default, payment at period end) or 1 (payment at period start). -XNPV/XIRRtake a values column and a dates column of equal length and discount ACT/365 from the first date.IRR/XIRRneed at least one positive and one negative flow, and return#EVAL!if they can't converge (Excel's#NUM!maps to#EVAL!here). - Text:
LEFT(text, [n]),RIGHT(text, [n]),MID(text, start, n)(startis 1-based),SUBSTITUTE(text, old, new, [instance])(case-sensitive;instancereplaces just that occurrence),UPPER(text),LOWER(text),HYPERLINK(url, [text])(a clickable link; the cell's VALUE istext, so everything reading the cell still sees plain text —https:/http:/mailto:/tel:only),TEXT(value, format_text)(Excel format codes:"$#,##0","0.0%","$#,##0.0,,\"M\"","mmm d, yyyy"; quote literal letters with\"; no scientific/fraction/[h]/[>100]codes),FIND(find, within, [start])(case-sensitive, literal) andSEARCH(find, within, [start])(case-insensitive,*/?wildcards) — both return a 1-based position and an ERROR when not found, so "contains" isISNUMBER(SEARCH("east", @Region));TEXTJOIN(delimiter, ignore_empty, values…)joins values or a whole column (TEXTJOIN(", ", TRUE, Team.Name));VALUE(text)(rarely needed — arithmetic already reads number-like text). - Not available (don't emit these):
VLOOKUP,INDEX/MATCH,WORKDAY/NETWORKDAYS/YEARFRAC/WEEKNUM, and any array/spill formula (includingSUMPRODUCTwith inline array math like(A="x")*B). UseLOOKUPinstead ofVLOOKUP. For anything else here, put the logic in a query table (a table withsql— full DuckDB SQL) rather than a formula. - Criteria are Excel-style strings:COUNTIF(Region, "East"),SUMIF(Units, ">10", Amount). Text criteria take Excel wildcards:COUNTIF(Notes, "*refund*")(contains),"north*"(starts with),"<>*test*"(doesn't contain);~*/~?match the character itself. - Or write the condition as a comparison over a BARE column in place of any column/criterion pair:SUMIF(Amount, Units > 10),COUNTIF(Status <> "Closed"),SUMIFS(Amount, Region = "East", Units > 10). Never@Units > 10there — that is one row's TRUE/FALSE, not a per-row test. - Custom functions: define once in
customFunctions(alias:functions;bodyreferences the params by name), call anywhere:"Markup(@Amount)".
External data (MCP only)
list_connections→describe_connection(connectionId, filter?)for table/column names →create_external_query_table(connectionId, sql, name?, tab?). Derive everything else withsqltables over it.tab(a tab name) places the table;set_card_rectcannot move a card across tabs.- No connection yet?
add_connection(kind, name, config)saves one for credential-free kinds:socrata(config is the portal or dataset URL) andcsv/duckdb/sqlite(config is a file path; the user approves). Kinds that need credentials are refused — ask the user to add them under File → Database Connections. - Never paste fetched data as static
rows: no record of its source, no refresh. - The table is a snapshot, never live: the user refreshes it, which re-runs your SQL.
- Result column names come from the source's SQL, and refresh re-matches them by name — rename in a derived table, not the source table. Socrata (SoQL) allows identifier aliases only (
AS filer_name). - A result too big for memory lands as a read-only dataset; the tool result says which.
Scripts (MCP only)
For data that is not one query (a union of two sources, cleanup code), write a Python/R script:
add_script(name, source, language?, outputs?)(anapply_actionsaction) creates the card, or replaces the source of the script with that name. It does not run.run_script(script)runs it — the user approves every run. Scripts read tables assheet.Name.to_df()/sheet.sql(...)and write withemit_table(df, name=…)/emit_tile(v, name=…).- A run updates only outputs the script itself is bound to; an unbound emit whose name is taken creates
Name (2). To have a script refresh an existing table, bind it:outputs: { "emitName": "Table name" }. The emit name must match exactly: each run OVERWRITES a bound table it emits, and unbinds (never deletes) one it doesn't. - Column types follow the data frame: ints → integer, floats → number,
.dt.date→ date,datetime64→ timestamp.
Editing an existing document
A saved .dsheet file is a zip of JSON plus parquet: never read or write it directly. Work from the document's dsdoc export instead — in a chat, ask the user for AI → Copy Prompt for AI (this guide plus the open document as dsdoc JSON; Copy Document for AI is the JSON alone); over MCP, call get_document.
To change the document, reply with a dsdoc object containing only what you are adding or changing. It is merged into the open document — import_document (default onNameConflict: "update") over MCP, AI → Import AI Output… for a pasted reply — as one undoable step the user reviews item by item:
- Anything you omit is left untouched: tabs, tables, tiles, charts, columns, rows.
- A table, tile or chart whose name already exists is updated in place. Repeat its exact name and include only the new or changed columns — a new column is added, an existing calc column's
formulais replaced — androwsonly when the data itself changes: givenrowsREPLACE the table's data. - Keep existing names stable. A new name creates a second table; to rename, use the rename actions below, which rewrite every reference.
Over MCP, apply_actions covers the granular edits dsdoc can't express, as one undoable batch: set_cell / set_cell_range (by rowIndex in the rows you read, or rowId), replace_values (find-and-replace), rename_table / rename_column / rename_tile / rename_tab (references rewritten), delete_table / delete_column / delete_tile / delete_chart / delete_tab, set_column_format / set_column_width / set_card_rect, sort_table, add_section / delete_section, move_card (to another tab), add_script. The tool's own description lists every action with its params; get_document shows every card's rect, each section's id and each tab's scripts (pass tab, tables or summary: true to narrow a large document). When the work is done, save_document(path?) saves like File → Save — to the document's own path, or to path (a .dsheet) for a document that has never been saved.
Multi-tab documents
Give each tab a name (and optionally a description). Charts may reference tables on any tab. A document that models a business usually reads best as: an Overview tab with KPI tiles + charts, and data tables on their own tabs.
Common mistakes to avoid
- Including calc-column cells in
rows(calc values are computed — omit them). - Reusing a name: two tables (or a table and a tile) with the same name is an error.
- Referencing columns by letter (
A,B1) — DreamSheets has no cell addresses; use names. - An unquoted spaced name:
SUM(Unit Price)fails — writeSUM('Unit Price')(SQL:"Unit Price"). - Formatted strings as data: write
1200.5, not"$1,200.50"(set"format": "currency"instead). - A leading
=on formulas is tolerated but unnecessary. - Misspelling a
stylekey — unknown keys are reported as warnings, not applied. BothbackgroundColorandbackground_colorparse;colourdoes not.
Good structure = an intuitive document
- Split distinct entities into separate tables (e.g.
Orders,Products) instead of one wide table; join withLOOKUP. - Add a short
descriptionto every table, calculated column, and chart — DreamSheets shows them, and the document explains itself. - Describe every column whose name alone doesn't say what it holds: a count (what is counted), a code (its values), a derived measure (its rule in words), a value that depends on another column.
{"name": "Paid Count", "description": "Transfers where this entity paid."} - Surface the numbers that matter as formula tiles; chart anything with a trend or comparison.