From 6ea32c7ab9ced3f69f993762493cc50fc7d7fb3a Mon Sep 17 00:00:00 2001 From: debabsah Date: Mon, 5 Oct 2026 12:37:35 -0700 Subject: [PATCH 1/2] feat: size.markdown-fit warns when markdown text runs past its block A showcase About tab cut its definitions off mid-section at 10 units and nothing said so: Superset scrolls a markdown block's overflow inside the block, and macOS draws no scrollbar there. advise now estimates each block's text height from its markdown (headings by level, paragraphs and hard breaks, lists tight, loose and nested, GFM tables, quotes, code, rules,
) and its width, and compares it with the block. - chartwright/design/markdown_fit.py: the estimate. Type sizes, margins, Inter's character widths and the grid widths (1536 px at a 1600 px viewport, 1276 or 1308 px beside the filter bar) measured on 4.1.4, 5.0.0 and 6.1.0; text wrapped word by word, margins collapsed as CSS does. A low bound (never above any release) decides; a high bound (never below, allowing for Windows scrollbars) sizes the fix. - size.markdown-fit (warn/info, fixable): warns when the low bound cuts letters off on every release and says by how much ("it needs ~29.6 units at 12/12, has 10"); an info when only the last line or the padding reaches the edge. Covers layout rows, tabs, sub-tabs, header and footer rows. No fix for a standard's row, a block with an image, or past 100 units. Short strips under 2 units are the same estimate: 32 px of padding plus the line's margins. - layout.markdown-height fixes a one-line block to the height its line takes (1.6 to 2.4 units) instead of 2, which cut a level-1 heading's padding off. - Design brain 11. - tools/record_markdown_fit.py records the calibration into tests/fixtures/markdown_fit/measurements.json (17 texts, five widths, three releases, plus renders with Windows-style scrollbars at three heights); tests/test_markdown_fit.py checks both bounds against every measurement; tools/ci_live_markdown_fit.py runs in the live CI job on every release. --- .github/workflows/ci.yml | 5 + CHANGELOG.md | 11 + chartwright/design/guidelines/composition.md | 8 +- chartwright/design/markdown_fit.py | 427 +++ chartwright/design/model.py | 5 +- chartwright/design/rules.py | 126 +- chartwright/spec.py | 3 +- docs/DESIGN-BRAIN.md | 68 + docs/FEATURES.md | 4 +- docs/LAYOUT-GUIDE.md | 5 +- docs/VERIFICATION.md | 15 +- schema/dashboard_spec.schema.json | 2 +- tests/fixtures/markdown_fit/measurements.json | 2344 +++++++++++++++++ tests/test_design_defaults.py | 2 +- tests/test_design_rules_v2.py | 27 +- tests/test_layout_header.py | 4 +- tests/test_layout_headers.py | 2 +- tests/test_markdown_fit.py | 282 ++ tests/test_standards.py | 4 +- tests/test_standards_content.py | 25 +- tools/ci_live_markdown_fit.py | 72 + tools/record_markdown_fit.py | 372 +++ 22 files changed, 3769 insertions(+), 44 deletions(-) create mode 100644 chartwright/design/markdown_fit.py create mode 100644 tests/fixtures/markdown_fit/measurements.json create mode 100644 tests/test_markdown_fit.py create mode 100644 tools/ci_live_markdown_fit.py create mode 100644 tools/record_markdown_fit.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efc7653..280343e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,11 @@ jobs: - name: Locked text visible on the rendered dashboard if: ${{ !cancelled() && steps.visual.outcome == 'success' }} run: python tools/ci_live_visible.py --base-url http://localhost:8098 + # Markdown blocks at the height advise --fix gives them show every line with + # Windows-style scrollbars on, and a block size.markdown-fit calls cut off is. + - name: Markdown blocks fit at the height advise gives them + if: ${{ !cancelled() && steps.visual.outcome == 'success' }} + run: python tools/ci_live_markdown_fit.py --base-url http://localhost:8098 # Saved queries captured from Superset's own frontend, so CSV reports work. - name: Chart queries saved for reports if: ${{ !cancelled() && steps.visual.outcome == 'success' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 193e8a9..62c351c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ while the major version is 0, minor bumps may include breaking changes and say s ## Unreleased +### Upgrading + +- The design brain is version 11. `advise` now warns when a markdown block's text runs past the block's bottom edge (`size.markdown-fit`), so a `--design strict` or `advise --strict` gate can newly fail on such a block; `advise --fix` raises it. `layout.markdown-height` now fixes a one-line block to the height its line takes, 1.6 units for a line of text up to 2.4 for a `#` heading, instead of 2. + +### Added + +- `advise` checks that each markdown block is tall enough for its text (`size.markdown-fit`). Superset cuts the rest off inside the block: macOS shows no scrollbar there, so a screenshot shows the text stopping mid-line. The check estimates the text's height from its headings, paragraphs, lists, tables, quotes, code and line breaks and the block's width, measured against what 4.1.4, 5.0.0 and 6.1.0 draw. + - It warns when letters are cut off on every release and says by how much ("it needs ~29.6 units at 12/12, has 10"); `--fix` raises the block to a height that shows every line on every release, Windows scrollbars included. + - A block whose last line or padding only reaches the edge gets an info: it scrolls a few px, and Windows draws a scrollbar in it. That covers short strips: a line of text needs 1.6 units, a `##` heading 2.2. + - It checks blocks in layout rows, tabs and sub-tabs, and header and footer rows, and allows for the filter bar, which narrows the page on a dashboard with native filters. A header or footer row a standard owns is reported, never resized, and a block holding an image gets no fix, since its height is unknown. + ### Fixed - `decompile` (and so `adopt`) names more chart settings it can't carry when they differ from Superset's defaults: big-number font sizes and a hidden trendline, a smooth or step line, horizontal bars on a time axis, a pie's radius and hidden labels, a funnel's labels, tooltip and percentage calculation, treemap labels, a heatmap's axis sort, legend, margins, label intervals and value bounds, a histogram's normalize setting, and any chart's currency format. Before, they were dropped without a note. diff --git a/chartwright/design/guidelines/composition.md b/chartwright/design/guidelines/composition.md index 8bbeb29..d1d8a49 100644 --- a/chartwright/design/guidelines/composition.md +++ b/chartwright/design/guidelines/composition.md @@ -15,10 +15,10 @@ - Tabs when sections answer different questions; scrolling when one question deepens. Never a tab with a single lonely chart. - Whitespace is markdown's job, sparingly: a one-line section header beats an - empty band (2 units is plenty for a header). Consistent number formats per - measure across KPI cards and axes (`number_format` on KPIs, timeseries and - bars; `number_formats` per column on tables; pivots use Superset's smart - default). + empty band, at the 1.6 to 2.4 units `advise` fits to its line. Consistent + number formats per measure across KPI cards and axes (`number_format` on + KPIs, timeseries and bars; `number_formats` per column on tables; pivots use + Superset's smart default). - Color restraint: Superset's default palette, RAG only where a threshold has a real business meaning; never encode the same dimension with two palettes. - RAG polarity is a convention, not a choice: red = adverse, green = good diff --git a/chartwright/design/markdown_fit.py b/chartwright/design/markdown_fit.py new file mode 100644 index 0000000..72c9b48 --- /dev/null +++ b/chartwright/design/markdown_fit.py @@ -0,0 +1,427 @@ +"""How tall Superset draws a dashboard markdown block, estimated offline from its +text: `size.markdown-fit` and `layout.markdown-height` (rules.py) both read it. + +What Superset does with a markdown block, at 4.1.4, 5.0.0 and 6.1.0 alike: +- renders the text with react-markdown and GitHub-flavoured markdown (tables, + strikethrough), raw HTML sanitized (packages/superset-ui-core/src/components/ + SafeMarkdown.tsx at 4.1.4 and 5.0.0, components/SafeMarkdown/SafeMarkdown.tsx at + 6.1.0); +- puts it in a holder with 16 px of padding on every side (DashboardBuilder.tsx + :326-332 at 4.1.4, :325-331 at 5.0.0, :311-317 at 6.1.0) that scrolls what doesn't + fit (overflow-y: auto; gridComponents/Markdown.jsx:106-109 at 4.1.4 and 5.0.0, + gridComponents/Markdown/Markdown.tsx:126-130 at 6.1.0). Text past the bottom edge + is cut off. macOS shows no scrollbar until the reader scrolls inside the block, so + the text just stops mid-line, as in a screenshot; Windows draws a scrollbar; +- never draws it shorter than 5 grid rows, 40 px: the markdown component passes + GRID_MIN_ROW_UNITS = 5 (util/constants.ts:42) to ResizableContainer as its + minHeight (resizable/ResizableContainer.jsx:285-286 at 4.1.4, + ResizableContainer.tsx:287 at 6.1.0); +- lays the grid out in 12 columns with 16 px gutters (util/constants.ts:38-40). At a + 1600 px viewport the grid is 1536 px wide. Beside the vertical filter bar, which + opens by default on a dashboard with native filters (DashboardBuilder/state.ts:39-41 + at 4.1.4; OPEN_FILTER_BAR_WIDTH = 260 in dashboard/constants.ts), it is 1276 px at + 4.1.4 and 5.0.0 and 1308 px at 6.1.0. + +The type sizes, margins and character widths below were measured on 2026-10-05 in +headless Chromium at that 1600 px viewport, each release with its default theme +(tools/record_markdown_fit.py; docs/DESIGN-BRAIN.md, "Markdown blocks"). The text +wraps word by word as the browser wraps it, with Inter's own advance widths, and +block margins collapse as CSS collapses them. tests/test_markdown_fit.py holds what +each release drew for 17 texts at five widths, beside the filter bar and not, and +checks both bounds below against every one. + +Two answers, because a rule must be sure before it speaks and its fix must work on +every release: +- low: never more than any release drew. Its characters are 3% narrower than the + table's and it takes the more compact release in the widest grid, so a block it + calls cut off is cut off on every release; +- high: never less than any release drew, as Windows draws it. It takes the taller + release, its characters are 1% wider than the table's, and it allows for two + classic scrollbars of 17 px: the page's own, which narrows the grid, and one in the + block. A block that overflows while it loads (before its web font arrives, say) + draws a scrollbar, and the narrower text can need the extra line that keeps it + there: 5.0.0 kept one on a two-line text whose first line came within 1% of the + edge. Every block raised to the high bound showed every line with no inner + scrollbar, on every release, with Windows-style scrollbars on. +""" + +from __future__ import annotations + +import html +import math +import re +import unicodedata +from dataclasses import dataclass, field + +# Inter's advance widths in em for ASCII 32..126 at weight 400, the widest each +# character measured at any release (canvas measureText): 4.1.4 ships Inter 3, 5.0.0 +# and 6.1.0 Inter 4, and their letters differ by under 2%. +_EM = tuple(float(f"0{v}") for v in ( + ".281 .288 .466 .633 .642 .982 .644 .300 .365 .365 .501 .662 .288 .460 .288 .360 " + ".631 .464 .610 .636 .646 .608 .624 .571 .619 .624 .288 .302 .662 .662 .662 .511 " + ".966 .690 .654 .730 .722 .601 .590 .746 .743 .269 .571 .672 .565 .903 .753 .765 " + ".639 .765 .644 .642 .646 .744 .690 .985 .682 .679 .629 .365 .360 .365 .471 .456 " + ".497 .564 .621 .571 .621 .583 .370 .613 .591 .242 .242 .549 .242 .876 .591 .600 " + ".612 .612 .376 .528 .364 .591 .562 .818 .546 .562 .552 .426 .333 .426 .662").split()) +_EM_OTHER = 0.6 # a character outside ASCII (an accented letter, Greek): about an 'o' +_EM_WIDE = 1.0 # East Asian wide and fullwidth characters, drawn by a fallback font +LOW_SCALE = 0.97 # the low bound's character widths, against the table's +HIGH_SCALE = 1.01 # the high bound's character widths, against the table's +_HEADING_WEIGHT = 1.02 # h1-h3 are drawn at weight 600 (4.1.4, 5.0.0) or 500 (6.1.0) + +PAD = 16.0 # the holder's padding, each side +SCROLLBAR = 17.0 # a classic scrollbar's width on Windows +GUTTER = 16.0 # GRID_GUTTER_SIZE +MIN_BOX = 40.0 # GRID_MIN_ROW_UNITS grid rows of GRID_BASE_UNIT (8) px +UNIT = 40.0 # px per spec height unit +BODY = 14.0 # body font size, every release +LIST_INDENT = 40.0 # ul and ol padding-left, every release + + +@dataclass(frozen=True) +class Typography: + """One release's default markdown styles, in px. Each pair is (margin above, + margin below); a heading is (font size, line height, margin above, below).""" + + release: str + grid: float # the grid's width at a 1600 px viewport + grid_filter_bar: float # beside the open vertical filter bar + h: dict[int, tuple[float, float, float, float]] + line: float = 22.0 # body line height (paragraphs, list items, cells) + p_m: tuple[float, float] = (0.0, 8.0) + list_m: tuple[float, float] = (0.0, 9.5) + cell_pad: float = 0.0 # a table cell's padding, top plus bottom + table_spacing: float = 0.0 # border-spacing above, between and below rows + quote_font: float = BODY + quote_line: float = 22.0 + quote_m: tuple[float, float] = (0.0, 0.0) + quote_pad: float = 0.0 # top plus bottom + quote_inset: float = 0.0 # left plus right: padding, border and margin + code_line: float = 22.0 + code_m: tuple[float, float] = (0.0, 0.0) + code_pad: float = 0.0 # top plus bottom: padding and border + hr: tuple[float, float, float] = (1.0, 15.0, 15.0) # height, margin above, below + + +_H = {1: (28.0, 39.2, 12.0, 12.0), 3: (16.0, 22.4, 8.0, 4.0), 4: (16.0, 22.4, 8.0, 4.0), + 5: (16.0, 22.4, 8.0, 4.0), 6: (12.0, 16.8, 8.0, 4.0)} +TYPOGRAPHY = ( + # 4.1.4 and 5.0.0 drew every measured block alike. + Typography( + "4.1.4 and 5.0.0", grid=1536.0, grid_filter_bar=1276.0, + h={**_H, 2: (21.0, 29.4, 12.0, 8.0)}, + quote_font=17.5, quote_line=27.5, quote_m=(0.0, 19.0), quote_pad=19.0, + quote_inset=43.0, code_line=18.2, code_m=(0.0, 9.5), code_pad=20.0, + ), + Typography( + "6.1.0", grid=1536.0, grid_filter_bar=1308.0, + h={**_H, 2: (20.0, 28.0, 12.0, 8.0)}, + list_m=(14.0, 14.0), cell_pad=2.0, table_spacing=2.0, quote_m=(14.0, 14.0), + quote_inset=80.0, code_m=(14.0, 14.0), hr=(2.0, 7.0, 7.0), + ), +) + + +# -- parsing: markdown to blocks ---------------------------------------------------- + + +@dataclass +class Block: + kind: str # p | h | list | table | quote | code | hr + level: int = 0 # a heading's level + texts: list[str] = field(default_factory=list) # p: its lines between hard breaks + items: list[tuple[int, str]] = field(default_factory=list) # list: (depth, text) + loose: bool = False # list items separated by blank lines + rows: list[list[str]] = field(default_factory=list) # table rows, header first + lines: int = 0 # code: its lines + + +_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})") +_ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$") +_SETEXT = re.compile(r"^ {0,3}(?:=+|-+)[ \t]*$") +_HR = re.compile(r"^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$") +_ITEM = re.compile(r"^([ \t]*)(?:[-*+]|\d{1,9}[.)])(?:[ \t]+(.*)|[ \t]*$)") +_QUOTE = re.compile(r"^ {0,3}> ?(.*)$") +_DELIM = re.compile(r"^[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$") +_HTML_H = re.compile(r"^\s*]*>(.*?)\s*$", re.I) +_HARD = re.compile(r"(?: {2,}|\\)$") +_BR = re.compile(r"", re.I) +_MEDIA = re.compile(r"!\[[^\]]*\]\([^)]*\)|<(?:img|iframe|video|svg|object|embed)\b", re.I) + + +def _starts_block(line: str) -> bool: + return bool(_FENCE.match(line) or _ATX.match(line) or _HR.match(line) + or _ITEM.match(line) or _QUOTE.match(line)) + + +def _cells(line: str) -> list[str]: + s = line.strip() + s = s[1:] if s.startswith("|") else s + s = s[:-1] if s.endswith("|") and not s.endswith("\\|") else s + return [c.strip() for c in re.split(r"(? tuple[list[Block], bool]: + """The blocks a markdown text draws, and whether it holds an image or embedded + media, whose height the text can't tell. CommonMark's common shapes: ATX and + setext headings, paragraphs with soft and hard breaks, lists (nested, tight or + loose), GFM tables, block quotes, fenced code and rules. HTML reads as its text, + with
a line break.""" + lines = markdown.replace("\r\n", "\n").replace("\t", " ").split("\n") + blocks: list[Block] = [] + i, n = 0, len(lines) + while i < n: + line = lines[i] + if not line.strip(): + i += 1 + elif m := _FENCE.match(line): + j = i + 1 + while j < n and not lines[j].strip().startswith(m.group(1)): + j += 1 + blocks.append(Block("code", lines=max(1, j - i - 1))) + i = j + 1 + elif m := _ATX.match(line) or _HTML_H.match(line): + level = len(m.group(1)) if m.re is _ATX else int(m.group(1)) + blocks.append(Block("h", level=level, texts=[m.group(2) or ""])) + i += 1 + elif _HR.match(line): + blocks.append(Block("hr")) + i += 1 + elif _QUOTE.match(line): + body = [] + while i < n and (m := _QUOTE.match(lines[i])): + body.append(m.group(1)) + i += 1 + inner, _ = parse("\n".join(body)) + texts = [t for b in inner for t in (b.texts or [it for _, it in b.items])] + blocks.append(Block("quote", texts=texts or [""])) + elif "|" in line and i + 1 < n and "-" in lines[i + 1] and _DELIM.match(lines[i + 1]): + rows = [_cells(line)] + i += 2 + while i < n and lines[i].strip() and "|" in lines[i]: + rows.append(_cells(lines[i])) + i += 1 + blocks.append(Block("table", rows=rows)) + elif _ITEM.match(line): + i = _parse_list(lines, i, blocks) + else: + # A paragraph runs to a blank line or another block; an underline of = or - + # makes it a heading (setext). + para = [line] + i += 1 + while (i < n and lines[i].strip() and not _starts_block(lines[i]) + and not _SETEXT.match(lines[i])): + para.append(lines[i]) + i += 1 + if i < n and _SETEXT.match(lines[i]): + level = 1 if lines[i].strip()[0] == "=" else 2 + blocks.append(Block("h", level=level, texts=[" ".join(l.strip() for l in para)])) + i += 1 + else: + blocks.append(Block("p", texts=_paragraph_lines(para))) + return blocks, bool(_MEDIA.search(markdown)) + + +def _paragraph_lines(raw: list[str]) -> list[str]: + """A paragraph's lines between hard breaks (two trailing spaces, a backslash or +
); a soft break joins its lines with a space.""" + out, cur = [], [] + for k, line in enumerate(raw): + cur.append(line.strip().rstrip("\\").strip()) + if _HARD.search(line) and k < len(raw) - 1: + out.append(" ".join(cur)) + cur = [] + out.append(" ".join(cur)) + return [seg for text in out for seg in _BR.split(text)] + + +def _parse_list(lines: list[str], i: int, blocks: list[Block]) -> int: + """One list from line i, nested items and all; returns the line after it.""" + block = Block("list") + indents: list[int] = [] # each open level's marker indent + gap = False # a blank line since the last line of an item + while i < len(lines): + line = lines[i] + if not line.strip(): + gap = True + elif m := _ITEM.match(line): + indent = len(m.group(1)) + while indents and indent < indents[-1]: + indents.pop() + if not indents or indent > indents[-1] + 1: + indents.append(indent) + if gap and block.items and len(indents) == 1: + block.loose = True + block.items.append((len(indents) - 1, m.group(2) or "")) + gap = False + elif (gap and not line.startswith(" ")) or (not gap and _starts_block(line)): + break # the list has ended + else: + depth, text = block.items[-1] + block.items[-1] = (depth, f"{text} {line.strip()}".strip()) + block.loose = block.loose or gap # a second paragraph in an item + gap = False + i += 1 + blocks.append(block) + return i + + +# -- text width --------------------------------------------------------------------- + +_LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)|!?\[([^\]]*)\]\[[^\]]*\]") +_AUTOLINK = re.compile(r"<((?:https?|mailto):[^>\s]+)>") +_TAG = re.compile(r"]*>") +_EMPHASIS = re.compile(r"(? str: + """Inline markdown and HTML reduced to the text a reader sees.""" + text = _MEDIA.sub("", text) + text = _LINK.sub(lambda m: m.group(1) or m.group(2) or "", text) + text = _AUTOLINK.sub(r"\1", text) + text = _TAG.sub("", text).replace("`", "") + text = _EMPHASIS.sub("", text) + text = re.sub(r"\\([!-/:-@\[-`{-~])", r"\1", text) + return html.unescape(text) + + +def em_width(text: str) -> float: + """A text's width on one line, in em.""" + out = 0.0 + for ch in text: + o = ord(ch) + if 32 <= o < 127: + out += _EM[o - 32] + elif ch == "\u00a0": + out += _EM[0] + else: + out += _EM_WIDE if unicodedata.east_asian_width(ch) in ("W", "F") else _EM_OTHER + return out + + +def wrapped_lines(text: str, width: float, font_px: float = BODY, scale: float = 1.0) -> int: + """Lines a text takes in `width` px, wrapped greedily at spaces and hyphens as the + browser wraps it. A word wider than the line takes a line of its own (the holder + clips it sideways); an empty text still takes its line.""" + px = font_px * scale + space = _EM[0] * px + lines, x = 1, 0.0 + for word in re.split(r"[ \t\n]+", text.strip()): + for k, piece in enumerate(_PIECE.findall(word)): + w = em_width(piece) * px + add = w + (space if k == 0 and x > 0 else 0.0) + if x > 0 and x + add > width: + lines += 1 + x = w + else: + x += add + return lines + + +# -- layout: blocks to px ----------------------------------------------------------- + + +def _table(rows: list[list[str]], width: float, t: Typography, scale: float) -> float: + """Rows of one line while the columns fit side by side. Past that, each column + gets a share of the width in proportion to its widest cell, roughly as the + browser's automatic table layout does, and a row is as tall as its tallest cell.""" + texts = [[plain(c) for c in row] for row in rows] + cols = max(len(r) for r in texts) + widest = [max(em_width(r[k]) if k < len(r) else 0.0 for r in texts) * BODY * scale + for k in range(cols)] + total = sum(widest) or 1.0 + height = t.table_spacing + for row in texts: + lines = 1 if total <= width else max( + wrapped_lines(c, width * widest[k] / total, BODY, scale) for k, c in enumerate(row)) + height += lines * t.line + t.cell_pad + t.table_spacing + return height + + +def _box(block: Block, width: float, t: Typography, scale: float) -> tuple[float, float, float]: + """(margin above, height, margin below) of one block, its text `width` px wide.""" + def lines(text: str, w: float = width, font: float = BODY, factor: float = 1.0) -> int: + return wrapped_lines(plain(text), w, font, scale * factor) + + if block.kind == "h": + font, lh, above, below = t.h[block.level] + weight = _HEADING_WEIGHT if block.level <= 3 else 1.0 + return above, lines(block.texts[0], font=font, factor=weight) * lh, below + if block.kind == "p": + return t.p_m[0], sum(lines(s) for s in block.texts) * t.line, t.p_m[1] + if block.kind == "list": + # A loose list wraps each item in a paragraph, whose bottom margin parts the + # items and joins the list's own below the last. + height = sum(lines(text, width - LIST_INDENT * (depth + 1)) * t.line + for depth, text in block.items) + gap = t.p_m[1] if block.loose else 0.0 + return t.list_m[0], height + gap * (len(block.items) - 1), max(t.list_m[1], gap) + if block.kind == "table": + return 0.0, _table(block.rows, width, t, scale), 0.0 + if block.kind == "quote": + body = sum(lines(s, width - t.quote_inset, t.quote_font) for s in block.texts) + height = body * t.quote_line + t.p_m[1] * (len(block.texts) - 1) + t.quote_pad + return t.quote_m[0], height, t.quote_m[1] + if block.kind == "code": + return t.code_m[0], block.lines * t.code_line + t.code_pad, t.code_m[1] + return t.hr[1], t.hr[0], t.hr[2] + + +def layout(blocks: list[Block], width: float, t: Typography, + scale: float = 1.0) -> tuple[float, float]: + """(how far down the last line reaches, the height that shows everything with no + inner scrollbar), in px from the holder's top edge, for text `width` px wide. + Margins between blocks collapse to the larger; the first block's margin above and + the last one's below stay inside the padding.""" + y, below = PAD, None + for block in blocks: + above, height, after = _box(block, width, t, scale) + y += (above if below is None else max(below, above)) + height + below = after + return y, y + (below or 0.0) + PAD + + +# -- the estimate ------------------------------------------------------------------- + + +def text_width(cols: int, grid: float) -> float: + """The width a block `cols` twelfths wide wraps its text in, on a grid `grid` px.""" + column = (grid - 11 * GUTTER) / 12 + return cols * column + (cols - 1) * GUTTER - 2 * PAD + + +def box_px(height: float) -> float: + """The px Superset gives a block of this spec height: never under 40.""" + return max(MIN_BOX, height * UNIT) + + +@dataclass(frozen=True) +class Fit: + text_low: float # px: the last line reaches at least this far down + need_low: float # px: the block needs at least this much + need_high: float # px: this much shows it whole on every release + media: bool # an image or embed adds height the text can't tell + + @property + def units(self) -> float: + """need_high in spec units, up to a whole 8 px grid row (a fifth of a unit).""" + return math.ceil(round(self.need_high / 8, 6)) / 5 + + +def estimate(markdown: str, cols: int, filter_bar: bool | None = False) -> Fit: + """How a markdown text fits a block `cols` twelfths wide at a 1600 px viewport, + beside the open filter bar or not. None says the bar may be there or not: the low + bound then wraps without it and the high bound beside it.""" + blocks, media = parse(markdown) + wide = text_width(cols, max(t.grid_filter_bar if filter_bar else t.grid + for t in TYPOGRAPHY)) + lows = [layout(blocks, wide, t, LOW_SCALE) for t in TYPOGRAPHY] + # Windows narrows the grid by the page's own scrollbar too. + highs = [layout(blocks, text_width(cols, (t.grid if filter_bar is False + else t.grid_filter_bar) - SCROLLBAR) - SCROLLBAR, + t, HIGH_SCALE)[1] + for t in TYPOGRAPHY] + return Fit(min(a for a, _ in lows), min(b for _, b in lows), max(highs), media) diff --git a/chartwright/design/model.py b/chartwright/design/model.py index ad50749..a1b177c 100644 --- a/chartwright/design/model.py +++ b/chartwright/design/model.py @@ -26,7 +26,10 @@ # "7" = fills keep a null record when the author edits one (an edit later deleted stays # deleted), and default.stale-record: a fixable finding for a renamed or removed chart's # design.filled entry, which now validates. Both change what `--fix` writes. -DESIGN_BRAIN_VERSION = "7" +# "11" = size.markdown-fit, a new warn-severity rule for markdown text cut off by its +# block, and layout.markdown-height fixes a one-line block to the height its line takes +# (1.6 to 2.4 units) instead of 2, from the same estimate. +DESIGN_BRAIN_VERSION = "11" KPI_TYPES = {"big_number_total", "big_number_trend"} TIMESERIES_TYPES = {"timeseries_line", "timeseries_bar", "timeseries_area", "timeseries_scatter"} diff --git a/chartwright/design/rules.py b/chartwright/design/rules.py index a9618a4..c35b939 100644 --- a/chartwright/design/rules.py +++ b/chartwright/design/rules.py @@ -18,6 +18,7 @@ from datetime import date from ..spec import ( + DEFAULT_HEIGHT, DEFAULT_ROW_LIMIT, DEFAULT_TIME_GRAIN, SUPERSET_COLOR_SCHEMES, @@ -29,6 +30,7 @@ row_items, without_superset_defaults, ) +from .markdown_fit import Fit, box_px, estimate from .model import AXIS_TYPES, KPI_TYPES, TIMESERIES_TYPES, Finding, RuleContext, rule # -- size: minimum readable geometry ------------------------------------------ @@ -1191,11 +1193,12 @@ def color_scheme(ctx: RuleContext): ) -@rule("layout.markdown-height", "info", "a one-line markdown header doesn't need a chart-sized block", fixable=True, since="2") -def markdown_height(ctx: RuleContext): - # Operates on the raw layout indices so the fix can address the block - # (markdown has no name to key on). A container address is None (layout - # rows), a tab index, [tab, sub-tab], "header" or "footer"; fix.py reads the same. +def _markdown_blocks(ctx: RuleContext): + """Every markdown block in the layout's rows, as (container, label, row, item, + block). Raw layout indices, so a fix can address the block (markdown has no name + to key on): the container is None (layout rows), a tab index, [tab, sub-tab], + "header" or "footer", and the row index counts headers and dividers. fix.py reads + the same.""" lay = ctx.spec.layout sources = [] if lay.header: @@ -1211,20 +1214,105 @@ def markdown_height(ctx: RuleContext): sources.append(([ti, si], f"tab {title!r}", sub.rows)) if lay.footer: sources.append(("footer", "footer", lay.footer)) - owned = ctx.standard_rows() for addr, label, rows in sources: for ri, row in enumerate(rows): - if addr in ("header", "footer") and (addr, ri) in owned: - continue # a standard's row: the standard is its one owner, not a repair - # ri indexes the raw rows (headers and dividers included): the fix reads the same list. for ii, item in enumerate(row_items(row) or []): - if isinstance(item, str): - continue - lines = [l for l in item.markdown.splitlines() if l.strip()] - h = item.height or 4 - if len(lines) <= 1 and h >= 3: - yield Finding( - "layout.markdown-height", "info", None, f"{label} row {ri}", - f"one-line markdown block at {h} units; 2 is plenty for a header", - fix={"md": [addr, ri, ii], "set": {"height": 2}}, - ) + if not isinstance(item, str): + yield addr, label, ri, ii, item + + +def _markdown_estimate(ctx: RuleContext, item) -> Fit: + """How the block's text fits at its width. The filter bar narrows the grid when it + opens by default: on the left, beside a dashboard with native filters. A horizontal + bar sits above the grid, but 4.1.4 and 5.0.0 draw it on the left without the + HORIZONTAL_FILTER_BAR flag, so that case is bounded both ways.""" + d = ctx.spec.dashboard + bar = (bool(ctx.spec.filters) if d.filter_bar_orientation != "horizontal" + else None if ctx.spec.filters else False) + return estimate(item.markdown, ctx.spec.resolved_item_width(item), bar) + + +_TYPE_CSS = re.compile(r"(? 1 or h < 3: + continue + # The height the line takes, padding and margins included (size.markdown-fit's + # estimate): a level-1 heading needs 2.4 units, a plain line 1.6. + fit = _markdown_estimate(ctx, item) + if fit.media or h <= fit.units: + continue + yield Finding( + "layout.markdown-height", "info", None, f"{label} row {ri}", + f"one-line markdown block at {h:g} units; {fit.units:g} fits it", + fix={"md": [addr, ri, ii], "set": {"height": fit.units}}, + ) + + +@rule("size.markdown-fit", "warn", + "a markdown block must be tall enough for its text: Superset cuts off the rest, " + "with no scrollbar on macOS", fixable=True, since="11", severities=("warn", "info")) +def markdown_fit(ctx: RuleContext): + """The text's height, estimated from its markdown and its width (markdown_fit.py), + against the block's. Warns only when the lower bound of the estimate already cuts + letters off (8 px, one grid row, past the bottom edge): text Superset hides with no + sign in a screenshot. A block whose last line merely touches the edge, or whose + padding doesn't fit, gets an info: it scrolls a few px, and Windows draws a + scrollbar in it. That is how a one-line strip under 1.6 units, or a heading strip + under 2, falls short. + + The fix raises the height to the upper bound, which fits on every release. None + when the block holds an image (its height is unknown), when the text needs more + than the 100-unit maximum, or in a header or footer row a standard owns (its + height is the standard's; only a cut-off text is reported there).""" + d = ctx.spec.dashboard + if d.css and _TYPE_CSS.search(d.css): + caveat = "; the dashboard's CSS sets its own type, so this assumes Superset's default" + elif d.theme: + caveat = f"; this assumes Superset's default type, which the theme {d.theme!r} may change" + else: + caveat = "" + owned = ctx.standard_rows() + for addr, label, ri, ii, item in _markdown_blocks(ctx): + h = item.height or DEFAULT_HEIGHT["markdown"] + box = box_px(h) + fit = _markdown_estimate(ctx, item) + if fit.need_low <= box: + continue + cut = fit.text_low - box >= 8 + standard = addr in ("header", "footer") and (addr, ri) in owned + if standard and not cut: + continue + w = ctx.spec.resolved_item_width(item) + need = (f"at least ~{fit.units:g} units with its image" if fit.media + else f"~{fit.units:g} units") + if cut: + hidden = max(1, round((fit.text_low - box) / 22)) + lead = (f"markdown text runs past its block: about {hidden} line" + f"{'s' if hidden > 1 else ''} cut off at the bottom edge, hidden behind " + f"an inner scrollbar macOS doesn't draw") + else: + lead = (f"markdown block {fit.need_low - box:.0f} px short: its last line or its " + f"padding reaches the bottom edge, so the block scrolls, and Windows draws " + f"a scrollbar in it") + if standard: + tail = "; a standard owns this row, so its height is the standard's to change" + elif fit.units > 100: + tail = "; past the 100-unit maximum, so split the text or give it a tab of its own" + else: + tail = f"; raise the height{' or widen it' if w < 12 else ''}" + yield Finding( + "size.markdown-fit", "warn" if cut else "info", None, f"{label} row {ri}", + f"{lead}; it needs {need} at {w}/12, has {h:g}{tail}{caveat}", + fix=({"md": [addr, ri, ii], "set": {"height": fit.units}} + if not (standard or fit.media or fit.units > 100) else None), + ) diff --git a/chartwright/spec.py b/chartwright/spec.py index 9912ad3..af79d64 100644 --- a/chartwright/spec.py +++ b/chartwright/spec.py @@ -1256,7 +1256,8 @@ class MarkdownBlock(BaseModel): width: int | None = Field(default=None, ge=1, le=GRID_WIDTH) height: int | float | None = Field( default=None, ge=0.2, le=100, - description="Height units (1 = 40 px) in fifths: 0.2 = 8 px, one Superset grid row (1.6 = 64 px)") + description="Height units (1 = 40 px) in fifths: 0.2 = 8 px, one Superset grid row (1.6 = 64 px). " + "Superset draws a block at least 1 unit tall") @field_validator("height") @classmethod diff --git a/docs/DESIGN-BRAIN.md b/docs/DESIGN-BRAIN.md index 73d434c..f1b1ac7 100644 --- a/docs/DESIGN-BRAIN.md +++ b/docs/DESIGN-BRAIN.md @@ -358,6 +358,7 @@ fails when it drifts. | `size.hbar-window` | warn | ✔ | - | 1 | horizontal bars need ~0.5 units of height per bar | | `size.heatmap-geometry` | warn | ✔ | - | 1 | heatmaps need >= 5/12 width (7/12 with many columns) and 6 height | | `size.kpi-height` | warn | ✔ | - | 1 | big numbers read best at 2-6 units | +| `size.markdown-fit` | warn/info | ✔ | - | 11 | a markdown block must be tall enough for its text: Superset cuts off the rest, with no scrollbar on macOS | | `size.min-width` | warn/error | - | - | 2 | below 3/12 width a chart is unreadable; KPIs need 2/12 | | `size.pie-geometry` | warn | ✔ | - | 1 | pies need >= 5/12 width and 8 height or the ring shrinks and the legend crowds | | `size.pivot-window` | warn | ✔ | - | 2 | a pivot's height should show a meaningful share of its row_limit | @@ -1341,6 +1342,68 @@ release, with no overlapping labels. ECharts thins the ticks to fit. a 29 February starts and ends on the same day and month, so `%d %b` could name two dates. Superset's `Last year` spans 365 days and still takes the fill. +### Markdown blocks + +Measured on 2026-10-05 the same way, by `tools/record_markdown_fit.py`: 17 texts +at 12, 8, 6, 4 and 3 twelfths on each release, the longest beside the filter bar +too, and each at 6/12 at three heights with Windows-style scrollbars on. The +fixture is `tests/fixtures/markdown_fit/measurements.json`. + +Superset draws a markdown block's text in a holder with 16 px of padding on every +side. What doesn't fit scrolls inside the holder (`overflow-y: auto`). macOS draws +no scrollbar there until the reader scrolls, so the text stops mid-line with no +sign that more follows; Windows draws a scrollbar. A block is never drawn shorter +than 40 px, so heights under 1 unit draw as 1. A header row, a sub-tab and a footer +row wrap text exactly as wide as layout rows do. + +| | 4.1.4 and 5.0.0 | 6.1.0 | +|---|---|---| +| paragraph | 14 px Inter, 22 px lines, 8 px below | the same | +| `#` | 28 px, 39.2 px line, 12 px above and below | the same | +| `##` | 21 px, 29.4 px line, 12 px above, 8 below | 20 px, 28 px line | +| `###` to `#####` | 16 px, 22.4 px line, 8 px above, 4 below | the same | +| list | indented 40 px, 9.5 px below | 14 px above and below | +| table row | 22 px | 24 px, 2 px apart | +| block quote | 17.5 px text on 27.5 px lines, padded 19 px, 19 px below | 14 px text, inset 40 px each side, 14 px above and below | +| code block | 18.2 px lines, padded 20 px, 9.5 px below | 22 px lines, 14 px above and below | +| grid at 1600 px | 1536 px, 1276 px beside the filter bar | 1536 px, 1308 px beside it | + +So a strip pays 32 px of padding plus its line's margins before its text: a line of +text needs 62 px (1.6 units), `###` 66 px (1.8), `##` 81.4 px (2.2; 80 px on 6.1.0) +and `#` 95.2 px (2.4). That is the "about 60 px of chrome" that cut strips under 2 +units off, and `size.markdown-fit` counts it with everything else. + +`chartwright/design/markdown_fit.py` parses the markdown into blocks, wraps each +line word by word with Inter's measured character widths, and stacks the blocks +with CSS's collapsing margins. At each release's own styles it matched 265 of the +270 measured heights within 1.5 px. The other five were the long text at narrow +widths, where the shared character table (the widest of each character at any +release) wraps a line or three more than 5.0.0 or 6.1.0 does. Two bounds come out: + +- The low bound takes the more compact release, the widest grid and characters 3% + narrower. It never exceeded a measured height: 80 of 90 it matched, the rest it + undershot by a line or two, or 3.5% (132 px) on the long text at 3/12. +- The high bound takes the taller release, characters 1% wider, and two 17 px + scrollbars: the page's own, which narrows the grid on Windows, and one in the + block. It never fell short: 75 of 90 it matched, and at 6/12 and wider it allowed + at most 44 px, or 110 px beside the filter bar. Narrow blocks of long text cost + the most, up to 10% at 3/12 beside the filter bar. + +The second scrollbar is there because of one render. A block that overflows while +it loads (before its web font arrives, say) draws a scrollbar, and the narrower +text can need the extra line that keeps it there: on 5.0.0 a two-line text whose +first line came within 1% of the edge kept a scrollbar and a cut-off third line at +the height it needed without one. At the height the high bound gives, no block +scrolled on any release. + +`size.markdown-fit` warns when the low bound's text runs 8 px (a grid row) or more +past the bottom edge, so letters are cut on every release. A block whose last line +only touches the edge, or whose padding doesn't fit, gets an info: it scrolls a few +px, and Windows draws a scrollbar in it. `--fix` raises the block to the high bound, +and `layout.markdown-height` now shrinks a one-line block to the height its line +takes on every release instead of a flat 2 units, which cut a `#` heading's +padding off. + ### Not measured - Other viewports and themes. The constants are pixel sizes, so a @@ -1356,6 +1419,11 @@ two dates. Superset's `Last year` spans 365 days and still takes the fill. - The other thresholds this page names: axis heights, pie and heatmap geometry, the horizontal-bar height per bar in `size.hbar-window`, and `vbar_max_categories`. +- Markdown under another font, size or spacing: a theme, dashboard CSS that sets + type (both named in the finding), or a browser that blocks web fonts and falls + back to Helvetica or Arial. Characters outside ASCII take rough widths (0.6 em, + 1 em for East Asian wide ones). Images and embeds add a height the text can't + tell, so a block holding one gets no fix. `search_min_rows` (20) and `page_min_rows` (3) are usability judgement, not pixel facts, and stay so. On page sizes, Nielsen recommends that "it's usually diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 57bbf30..8092647 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -58,7 +58,7 @@ into one. Everything below works from that one file. - Stack letters vertically for columns; `.` marks deliberate empty space. - An unclear sketch gets a message saying exactly what to fix, never a guess. - Every rule drawn and compiled: [the layout guide](LAYOUT-GUIDE.md). -- **Precise Sizing**: Set exact widths and heights per chart (markdown blocks down to one 8 px grid row: `"height": 1.6` is 64 px), or drag a chart taller in the UI and `chartwright absorb` writes the new height back into the spec; widths are a one-line edit in the layout. +- **Precise Sizing**: Set exact widths and heights per chart (markdown blocks in 8 px steps from 40 px: `"height": 1.6` is 64 px), or drag a chart taller in the UI and `chartwright absorb` writes the new height back into the spec; widths are a one-line edit in the layout. - **Rows, Tabs, and Notes**: Even or custom row splits, titled tabs (with one level of sub-tabs, e.g. a sub-tab per row of a scorecard), section headers and dividers between rows (`{"header": "Revenue", "size": "large"}`, `{"divider": true}`), a white card behind a row, and markdown blocks for notes. - **Header and Footer**: `layout.header` rows sit above everything and `layout.footer` rows below it, outside any tab, so a tabbed dashboard shows them above and under every tab: a banner, a data note, a branding strip, a contact line. - Adding a header to a dashboard already in use moves nothing else in it. @@ -66,7 +66,7 @@ into one. Everything below works from that one file. ## The Design Brain - **Codified BI/UX Practice**: An optional layer holding what Few, Tufte and IBCS teach about reading a dashboard, plus the Superset rendering quirks that break it, on by default and off with one flag ([full reference](DESIGN-BRAIN.md)). - **The Brief**: `chartwright brief` prints design guidance for the AI (or you) to read before writing a spec: budgets, chart choice, and composition, tuned to an audience preset (`executive`, `analytical`, or `operational`). -- **The Critic**: `chartwright advise` reviews a finished spec: readable minimum sizes, layout composition (KPIs first, fold budgets, row density), chart-choice limits, narrative polish. `--fix` applies the safe geometry subset; `--profile` adds data-aware checks (a time axis on a non-temporal column, a pie hiding 40 slices); `--chart` looks at one chart. +- **The Critic**: `chartwright advise` reviews a finished spec: readable minimum sizes, markdown blocks tall enough for their text, layout composition (KPIs first, fold budgets, row density), chart-choice limits, narrative polish. `--fix` applies the safe geometry subset; `--profile` adds data-aware checks (a time axis on a non-temporal column, a pie hiding 40 slices); `--chart` looks at one chart. - **Design Defaults in the Spec**: Leave the small display choices unset and `advise --fix` writes sensible values into the spec, where the diff shows them; `chartwright explain` says where each value came from and how to change it. - Monthly time axes labelled `Sep 2026`, counts shown as `12,345`, "vs previous month" after a trendline KPI's change. - Tables that page by what fits their panel, a search box on long raw tables, no cell bars behind id, code, year or zip columns. diff --git a/docs/LAYOUT-GUIDE.md b/docs/LAYOUT-GUIDE.md index 9ffb91c..99f0cc1 100644 --- a/docs/LAYOUT-GUIDE.md +++ b/docs/LAYOUT-GUIDE.md @@ -208,7 +208,10 @@ draws them under whichever tab is open, so one footer serves every tab: Markdown heights take fifths of a unit, one Superset grid row (8 px) each, so a slim strip fits exactly: `1.6` is 64 px. Superset pads a text block 16 px on -every side, so the block needs its content's height plus 32 px, or it scrolls. +every side, so the block needs its content's height plus 32 px, or it scrolls, +and it draws no block under 1 unit (40 px). `chartwright advise` estimates the +height each block's text takes at its width and names a block too short for it +(`size.markdown-fit`); `advise --fix` raises the block. A footer row may hold charts as well as markdown; each chart is still placed exactly once. Decompile reads any rows below a dashboard's tabs as its footer, diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index e22865b..b75a05c 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -35,11 +35,12 @@ injection. | Layer | Proves | Where it runs | |---|---|---| -| Offline suite (1564 tests, 67 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | +| Offline suite (1689 tests, 68 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | | Chart-option contract | Every emitted chart option is declared by each version's plugin source | every PR and push to main | | Live guarantee check | Three specs applied (every chart type, every display control, every dashboard and filter control), per-chart data check, ids stable across re-apply | every PR and push to main, all 3 versions | | Standards content round trip | `standards apply` writes an org footer, a team header and two CSS blocks into a spec; it applies, `plan` stays clean, decompile reads the CSS markers and every managed row back, `--claim` rebuilds the record, re-apply stays clean | every PR and push to main, all 3 versions | | Locked text visible | `standards verify-visible` in headless Chromium: the standards fixture's locked footer shows, and the same dashboard with near-white footer CSS is caught. Installing the browser may fail without failing the job (the check is then skipped); once installed, a failing check fails it | every PR and push to main, all 3 versions | +| Markdown fit | 17 markdown texts at 6/12 in headless Chromium with Windows-style scrollbars: at the height `advise --fix` gives each, none scrolls; a unit less, each block `size.markdown-fit` calls cut off is, and each it reports scrolls. Skipped, like the row above, when the browser fails to install | every PR and push to main, all 3 versions | | Lifecycle soak | 500 randomized edit cycles with invariants held | 500 cycles on 6.1.0 and 4.1.4 before release; 25 cycles per version on every PR and push to main | | Second-writer scenarios | Stale-tab overwrites detected by `plan`, repaired by `apply` | every PR and push to main, all 3 versions | | Fault injection | A typed failure at every stage boundary; complete restore | every PR and push to main, all 3 versions | @@ -175,6 +176,16 @@ collects, the same "generated, not hand-maintained" rule the rule table in visual extra says how to install it; Playwright is no core dependency. A live test runs the real browser when `CHARTWRIGHT_VISIBLE_LIVE` names a Superset. +- **Markdown fit** (`test_markdown_fit.py`): what 4.1.4, 5.0.0 and 6.1.0 + drew in headless Chromium for 17 markdown texts at five widths, and beside + the filter bar (`fixtures/markdown_fit/measurements.json`): the estimate's low + bound never exceeds any of those heights and its high bound never falls short, + each within a set margin; at the fix's height no block scrolled with + Windows-style scrollbars on, a block the rule calls cut off was, and one it + reports at all scrolled; the grid widths it assumes are the ones drawn, in + rows, header, footer and sub-tabs; the parser reads each markdown shape; and + `size.markdown-fit` warns, informs, fixes in every container and stays + silent as documented, never resizing a standard's row. - **Dashboard owners** (`test_dashboard_owners.py`): owners never reach the bundle; usernames resolve where the security API answers and emails everywhere, an unknown or ambiguous owner is a resolve-stage error with @@ -350,8 +361,10 @@ python tools/ci_live_ui_chart.py --base-url http://localhost:8098 # a chart a python tools/ci_live_adopt.py --base-url http://localhost:8098 # adopt in place pip install -e ".[visual]" && playwright install chromium python tools/ci_live_visible.py --base-url http://localhost:8098 # locked text visible +python tools/ci_live_markdown_fit.py --base-url http://localhost:8098 # markdown blocks fit python tools/ci_live_saved_queries.py --base-url http://localhost:8098 # saved queries, CSV export python tools/record_visible_measurements.py --base-url http://localhost:8098 # refresh the fixture +python tools/record_markdown_fit.py --base-url http://localhost:8098 # refresh the markdown fixture python tools/soak.py --base-url http://localhost:8098 --cycles 500 --seed 1 python tools/adversary.py --base-url http://localhost:8098 python tools/faultline.py --base-url http://localhost:8098 diff --git a/schema/dashboard_spec.schema.json b/schema/dashboard_spec.schema.json index 98f9876..2effd6c 100644 --- a/schema/dashboard_spec.schema.json +++ b/schema/dashboard_spec.schema.json @@ -2736,7 +2736,7 @@ } ], "default": null, - "description": "Height units (1 = 40 px) in fifths: 0.2 = 8 px, one Superset grid row (1.6 = 64 px)", + "description": "Height units (1 = 40 px) in fifths: 0.2 = 8 px, one Superset grid row (1.6 = 64 px). Superset draws a block at least 1 unit tall", "title": "Height" } }, diff --git a/tests/fixtures/markdown_fit/measurements.json b/tests/fixtures/markdown_fit/measurements.json new file mode 100644 index 0000000..705b3cd --- /dev/null +++ b/tests/fixtures/markdown_fit/measurements.json @@ -0,0 +1,2344 @@ +{ + "about": "Markdown blocks Superset drew in headless Chromium at a 1600 x 1200 viewport, in px; written by tools/record_markdown_fit.py, which says what each number is.", + "corpus": { + "about": "## How to read this dashboard\n\nEvery figure on these tabs comes from the operations warehouse, refreshed nightly at 02:00 UTC. A day closes when the last flight of the schedule lands, so the most recent day can move until the following morning. Figures are shown in the network's reporting currency and converted at the month's average rate, never the daily rate, so a month never changes once it has closed. Where a chart says \"network\" it means every route the schedule operated in the period, including seasonal routes, and excludes charters, ferry flights and positioning flights, which carry no revenue passengers.\n\nHover over any point to see its exact value, and use the filter bar on the left to narrow every chart at once. A filter applies to every tab, not only the one in view. The period selector defaults to the last full quarter; widen it to compare a season with the same season a year earlier.\n\n## Punctuality\n\n- **On-time departure**: the share of departures that leave the gate within 15 minutes of the scheduled time. Cancelled flights are excluded from both sides of the ratio; diverted flights count by their original schedule.\n- **On-time arrival**: the share of arrivals that reach the gate within 15 minutes of the scheduled time. This is the measure the regulator publishes and the one the monthly report leads with.\n- **Average delay**: the mean minutes late across flights that were late, measured gate to gate. Early flights count as zero, never as negative minutes, so an early arrival cannot hide a late one.\n- **Delay cause**: the first code the station controller recorded. Reactionary delays, caused by a late inbound aircraft, are grouped separately so that a single morning disruption does not read as a day of independent failures.\n- **Completion factor**: flights operated as a share of flights scheduled two days before departure. Schedule changes made earlier than that are planning, not disruption, and do not count against it.\n\n## Capacity and demand\n\nCapacity is measured in available seat kilometres: every seat offered, multiplied by the great-circle distance of the sector it was offered on. Demand is measured in revenue passenger kilometres: every fare-paying passenger, multiplied by the same distance. Staff travel, infants without a seat and award tickets redeemed at zero fare are not revenue passengers.\n\nLoad factor is demand divided by capacity. It is shown both for the network and per route, and the route figures are weighted by distance, so a long sector moves the network average more than a short one. A route with a load factor above 90 percent for four consecutive weeks is flagged for a capacity review; one below 60 percent for the same period is flagged for a schedule review. Both flags appear in the route table on the Network tab and clear themselves once the route returns to the normal band.\n\nSeat counts come from the aircraft actually operated, not the aircraft planned, so an equipment swap on the day changes the capacity of that flight. Blocked seats, such as crew rest seats on long sectors, are removed from the count.\n\n## Revenue and yield\n\nPassenger revenue is the fare the passenger paid for each sector, net of taxes, airport charges and fuel surcharges, which pass through to third parties. A ticket covering several sectors is prorated across them by distance. Ancillary revenue, such as checked bags, seat selection and onboard sales, is reported separately and never mixed into yield.\n\nYield is passenger revenue per revenue passenger kilometre, shown in cents. Unit revenue is passenger revenue per available seat kilometre, which falls when seats fly empty even if every passenger paid more. Read the two together: rising yield with falling unit revenue usually means fares went up faster than the planes filled.\n\nRefunds are booked against the month of the original sale, not the month of the refund, so a large refund wave can revise a closed month's revenue. The revision is noted in the monthly summary and the chart marks the revised point with a hollow marker.\n\n## Cost and efficiency\n\nUnit cost is operating cost per available seat kilometre, shown in cents, with fuel shown both inside and outside the total because fuel moves with markets the airline does not control. Operating cost includes crew, maintenance, airport and navigation charges, aircraft ownership and overheads, and excludes one-off items such as restructuring charges, which are listed in the notes of the month in which they occur.\n\nFuel burn per block hour is measured from the aircraft's own fuel records, not from the flight plan, and compared with the same aircraft type's fleet average. A tail that burns more than three percent above its type's average for a month is listed on the Fleet tab for an engineering review.\n\nAircraft utilisation is block hours per aircraft per day, counting only aircraft in the operating fleet. Aircraft in heavy maintenance, in storage or on lease to another operator are left out, so a fleet with many aircraft parked does not look less productive than it is.\n\n## Data quality and changes\n\nEach tab shows the time of its last successful refresh in the footer. If a source system is late, the affected charts show the previous day's figures and a note says which source is behind. A missing day is never filled in or estimated; it stays visibly empty until the source delivers it.\n\nDefinitions change rarely and only at the start of a quarter. When one does, the change is listed here with its date, the previous definition is kept for one year, and charts that span the change mark it with a vertical line so that a step in the series is not mistaken for a change in performance.\n\nQuestions about a figure go to the analytics team through the request form linked in the header. Please include the tab, the chart title and the filters you had set, so the figure can be reproduced exactly.\n", + "br-html": "Figures refresh nightly.
The most recent day can move.

Questions go to the analytics team.", + "caps-digits": "ASK 1,234,567,890 | RPK 987,654,321 | LF 80.0% | CASK 10.25 | RASK 11.40 | OTP-15 88.2% | CF 99.1% | BH/AC/DAY 12.4 | FUEL KG/BH 2,345 | Q3 FY2026 VS Q3 FY2025: +4.2% ASK, +6.1% RPK, +1.5 PTS LF", + "long-word": "See https://example.org/dashboards/operations/definitions/punctuality-and-capacity-measures-for-the-network-report for the full list.", + "loose-list": "- First, filter to the period you need.\n\n- Then pick a route from the table on the Network tab.\n\n- Finally, open the delay causes for that route.", + "nested-list": "1. Punctuality\n - On-time departure\n - On-time arrival\n2. Capacity\n - Seat kilometres\n - Load factor\n3. Revenue", + "quote-code": "> Figures shown are provisional until the month closes.\n\n```\nload_factor = rpk / ask\nyield = revenue / rpk\n```\n\n---\n\nLast updated by the analytics team.", + "section": "## Capacity and demand\n\nCapacity is measured in available seat kilometres: every seat offered, multiplied by the great-circle distance of the sector it was offered on. Demand is measured in revenue passenger kilometres.\n\n- **Load factor**: demand divided by capacity\n- **Yield**: passenger revenue per revenue passenger kilometre\n- **Unit cost**: operating cost per available seat kilometre", + "setext": "Network report\n==============\n\nQuarterly figures\n-----------------\n\nBody text.", + "strip-bold": "**Source:** operations warehouse, refreshed nightly", + "strip-h1": "# Network performance", + "strip-h2": "## Punctuality", + "strip-h3": "### Delay causes", + "strip-h4": "#### Notes", + "strip-text": "Figures refresh nightly at 02:00 UTC.", + "table": "| Measure | Unit | Source |\n|---|---|---|\n| On-time arrival | % of arrivals | Station records |\n| Load factor | % of seats | Reservations |\n| Yield | cents per RPK | Revenue accounting |\n| Unit cost | cents per ASK | Finance |\n| Utilisation | block hours per day | Maintenance |", + "two-lines": "Figures refresh nightly at 02:00 UTC. \nThe most recent day can still move." + }, + "releases": { + "4.1.4": { + "blocks": { + "about@12": { + "content_w": 1504, + "need": 1156, + "text": 1132.3 + }, + "about@3": { + "content_w": 340, + "need": 3246, + "text": 3222.3 + }, + "about@4": { + "content_w": 469, + "need": 2432, + "text": 2408.3 + }, + "about@6": { + "content_w": 728, + "need": 1772, + "text": 1748.3 + }, + "about@8": { + "content_w": 987, + "need": 1442, + "text": 1418.3 + }, + "br-html@12": { + "content_w": 1504, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 728, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 987, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "caps-digits@3": { + "content_w": 340, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "caps-digits@8": { + "content_w": 987, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 469, + "need": 106, + "text": 82 + }, + "long-word@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "loose-list@12": { + "content_w": 1504, + "need": 124, + "text": 98 + }, + "loose-list@3": { + "content_w": 340, + "need": 146, + "text": 120 + }, + "loose-list@4": { + "content_w": 469, + "need": 124, + "text": 98 + }, + "loose-list@6": { + "content_w": 728, + "need": 124, + "text": 98 + }, + "loose-list@8": { + "content_w": 987, + "need": 124, + "text": 98 + }, + "nested-list@12": { + "content_w": 1504, + "need": 196, + "text": 170 + }, + "nested-list@3": { + "content_w": 340, + "need": 196, + "text": 170 + }, + "nested-list@4": { + "content_w": 469, + "need": 196, + "text": 170 + }, + "nested-list@6": { + "content_w": 728, + "need": 196, + "text": 170 + }, + "nested-list@8": { + "content_w": 987, + "need": 196, + "text": 170 + }, + "quote-code@12": { + "content_w": 1504, + "need": 215, + "text": 190.9 + }, + "quote-code@3": { + "content_w": 340, + "need": 242, + "text": 218.4 + }, + "quote-code@4": { + "content_w": 469, + "need": 242, + "text": 218.4 + }, + "quote-code@6": { + "content_w": 728, + "need": 215, + "text": 190.9 + }, + "quote-code@8": { + "content_w": 987, + "need": 215, + "text": 190.9 + }, + "section@12": { + "content_w": 1504, + "need": 187, + "text": 161.4 + }, + "section@3": { + "content_w": 340, + "need": 297, + "text": 271.4 + }, + "section@4": { + "content_w": 469, + "need": 231, + "text": 205.4 + }, + "section@6": { + "content_w": 728, + "need": 209, + "text": 183.4 + }, + "section@8": { + "content_w": 987, + "need": 209, + "text": 183.4 + }, + "setext@12": { + "content_w": 1504, + "need": 163, + "text": 138.6 + }, + "setext@3": { + "content_w": 340, + "need": 163, + "text": 138.6 + }, + "setext@4": { + "content_w": 469, + "need": 163, + "text": 138.6 + }, + "setext@6": { + "content_w": 728, + "need": 163, + "text": 138.6 + }, + "setext@8": { + "content_w": 987, + "need": 163, + "text": 138.6 + }, + "strip-bold@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-bold@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1504, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 340, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 469, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 728, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 987, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1504, + "need": 81, + "text": 57.4 + }, + "strip-h2@3": { + "content_w": 340, + "need": 81, + "text": 57.4 + }, + "strip-h2@4": { + "content_w": 469, + "need": 81, + "text": 57.4 + }, + "strip-h2@6": { + "content_w": 728, + "need": 81, + "text": 57.4 + }, + "strip-h2@8": { + "content_w": 987, + "need": 81, + "text": 57.4 + }, + "strip-h3@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1504, + "need": 164, + "text": 148 + }, + "table@3": { + "content_w": 340, + "need": 230, + "text": 214 + }, + "table@4": { + "content_w": 469, + "need": 164, + "text": 148 + }, + "table@6": { + "content_w": 728, + "need": 164, + "text": 148 + }, + "table@8": { + "content_w": 987, + "need": 164, + "text": 148 + }, + "two-lines@12": { + "content_w": 1504, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 340, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 469, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 987, + "need": 84, + "text": 60 + } + }, + "containers": { + "footer@4": 469, + "header@12": 1504, + "sub-tab@6": 728 + }, + "filter_bar": { + "about@12": { + "content_w": 1244, + "need": 1332, + "text": 1308.3 + }, + "about@3": { + "content_w": 275, + "need": 3980, + "text": 3955.7 + }, + "about@4": { + "content_w": 383, + "need": 2916, + "text": 2892.3 + }, + "about@6": { + "content_w": 598, + "need": 2036, + "text": 2012.3 + }, + "about@8": { + "content_w": 813, + "need": 1618, + "text": 1594.3 + } + }, + "grid_w": 1536, + "grid_w_filter_bar": 1276, + "verify": { + "about@fits": { + "box": 1776, + "height": 44.4, + "scrolls": false, + "text": 1748.3 + }, + "about@fix": { + "box": 1800, + "height": 45.0, + "scrolls": false, + "text": 1748.3 + }, + "about@short": { + "box": 1736, + "height": 43.4, + "scrolls": true, + "text": 1748.3 + }, + "br-html@fits": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 104 + }, + "br-html@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 104 + }, + "br-html@short": { + "box": 88, + "height": 2.2, + "scrolls": true, + "text": 104 + }, + "caps-digits@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "caps-digits@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 60 + }, + "caps-digits@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 82 + }, + "long-word@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "long-word@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "long-word@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 60 + }, + "loose-list@fits": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 98 + }, + "loose-list@fix": { + "box": 144, + "height": 3.6, + "scrolls": false, + "text": 98 + }, + "loose-list@short": { + "box": 88, + "height": 2.2, + "scrolls": true, + "text": 98 + }, + "nested-list@fits": { + "box": 200, + "height": 5.0, + "scrolls": false, + "text": 170 + }, + "nested-list@fix": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 170 + }, + "nested-list@short": { + "box": 160, + "height": 4.0, + "scrolls": true, + "text": 170 + }, + "quote-code@fits": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 190.9 + }, + "quote-code@fix": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 190.9 + }, + "quote-code@short": { + "box": 176, + "height": 4.4, + "scrolls": true, + "text": 190.9 + }, + "section@fits": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 183.4 + }, + "section@fix": { + "box": 224, + "height": 5.6, + "scrolls": false, + "text": 183.4 + }, + "section@short": { + "box": 176, + "height": 4.4, + "scrolls": true, + "text": 183.4 + }, + "setext@fits": { + "box": 168, + "height": 4.2, + "scrolls": false, + "text": 138.6 + }, + "setext@fix": { + "box": 168, + "height": 4.2, + "scrolls": false, + "text": 138.6 + }, + "setext@short": { + "box": 128, + "height": 3.2, + "scrolls": true, + "text": 138.6 + }, + "strip-bold@fits": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-bold@fix": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-bold@short": { + "box": 40, + "height": 0.6, + "scrolls": true, + "text": 38 + }, + "strip-h1@fits": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 67.2 + }, + "strip-h1@fix": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 67.2 + }, + "strip-h1@short": { + "box": 56, + "height": 1.4, + "scrolls": true, + "text": 67.2 + }, + "strip-h2@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 57.4 + }, + "strip-h2@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 57.4 + }, + "strip-h2@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 57.4 + }, + "strip-h3@fits": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h3@fix": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h3@short": { + "box": 40, + "height": 0.8, + "scrolls": true, + "text": 46.4 + }, + "strip-h4@fits": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h4@fix": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h4@short": { + "box": 40, + "height": 0.8, + "scrolls": true, + "text": 46.4 + }, + "strip-text@fits": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-text@fix": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-text@short": { + "box": 40, + "height": 0.6, + "scrolls": true, + "text": 38 + }, + "table@fits": { + "box": 168, + "height": 4.2, + "scrolls": false, + "text": 148 + }, + "table@fix": { + "box": 192, + "height": 4.8, + "scrolls": false, + "text": 148 + }, + "table@short": { + "box": 128, + "height": 3.2, + "scrolls": true, + "text": 148 + }, + "two-lines@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "two-lines@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "two-lines@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 60 + } + } + }, + "5.0.0": { + "blocks": { + "about@12": { + "content_w": 1504, + "need": 1156, + "text": 1132.3 + }, + "about@3": { + "content_w": 340, + "need": 3246, + "text": 3222.3 + }, + "about@4": { + "content_w": 469, + "need": 2388, + "text": 2364.3 + }, + "about@6": { + "content_w": 728, + "need": 1772, + "text": 1748.3 + }, + "about@8": { + "content_w": 987, + "need": 1442, + "text": 1418.3 + }, + "br-html@12": { + "content_w": 1504, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 728, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 987, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "caps-digits@3": { + "content_w": 340, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "caps-digits@8": { + "content_w": 987, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 469, + "need": 106, + "text": 82 + }, + "long-word@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "loose-list@12": { + "content_w": 1504, + "need": 124, + "text": 98 + }, + "loose-list@3": { + "content_w": 340, + "need": 146, + "text": 120 + }, + "loose-list@4": { + "content_w": 469, + "need": 124, + "text": 98 + }, + "loose-list@6": { + "content_w": 728, + "need": 124, + "text": 98 + }, + "loose-list@8": { + "content_w": 987, + "need": 124, + "text": 98 + }, + "nested-list@12": { + "content_w": 1504, + "need": 196, + "text": 170 + }, + "nested-list@3": { + "content_w": 340, + "need": 196, + "text": 170 + }, + "nested-list@4": { + "content_w": 469, + "need": 196, + "text": 170 + }, + "nested-list@6": { + "content_w": 728, + "need": 196, + "text": 170 + }, + "nested-list@8": { + "content_w": 987, + "need": 196, + "text": 170 + }, + "quote-code@12": { + "content_w": 1504, + "need": 215, + "text": 190.9 + }, + "quote-code@3": { + "content_w": 340, + "need": 242, + "text": 218.4 + }, + "quote-code@4": { + "content_w": 469, + "need": 242, + "text": 218.4 + }, + "quote-code@6": { + "content_w": 728, + "need": 215, + "text": 190.9 + }, + "quote-code@8": { + "content_w": 987, + "need": 215, + "text": 190.9 + }, + "section@12": { + "content_w": 1504, + "need": 187, + "text": 161.4 + }, + "section@3": { + "content_w": 340, + "need": 297, + "text": 271.4 + }, + "section@4": { + "content_w": 469, + "need": 231, + "text": 205.4 + }, + "section@6": { + "content_w": 728, + "need": 209, + "text": 183.4 + }, + "section@8": { + "content_w": 987, + "need": 209, + "text": 183.4 + }, + "setext@12": { + "content_w": 1504, + "need": 163, + "text": 138.6 + }, + "setext@3": { + "content_w": 340, + "need": 163, + "text": 138.6 + }, + "setext@4": { + "content_w": 469, + "need": 163, + "text": 138.6 + }, + "setext@6": { + "content_w": 728, + "need": 163, + "text": 138.6 + }, + "setext@8": { + "content_w": 987, + "need": 163, + "text": 138.6 + }, + "strip-bold@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-bold@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1504, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 340, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 469, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 728, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 987, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1504, + "need": 81, + "text": 57.4 + }, + "strip-h2@3": { + "content_w": 340, + "need": 81, + "text": 57.4 + }, + "strip-h2@4": { + "content_w": 469, + "need": 81, + "text": 57.4 + }, + "strip-h2@6": { + "content_w": 728, + "need": 81, + "text": 57.4 + }, + "strip-h2@8": { + "content_w": 987, + "need": 81, + "text": 57.4 + }, + "strip-h3@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1504, + "need": 164, + "text": 148 + }, + "table@3": { + "content_w": 340, + "need": 230, + "text": 214 + }, + "table@4": { + "content_w": 469, + "need": 164, + "text": 148 + }, + "table@6": { + "content_w": 728, + "need": 164, + "text": 148 + }, + "table@8": { + "content_w": 987, + "need": 164, + "text": 148 + }, + "two-lines@12": { + "content_w": 1504, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 340, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 469, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 987, + "need": 84, + "text": 60 + } + }, + "containers": { + "footer@4": 469, + "header@12": 1504, + "sub-tab@6": 728 + }, + "filter_bar": { + "about@12": { + "content_w": 1244, + "need": 1332, + "text": 1308.3 + }, + "about@3": { + "content_w": 275, + "need": 3980, + "text": 3955.7 + }, + "about@4": { + "content_w": 383, + "need": 2894, + "text": 2870.3 + }, + "about@6": { + "content_w": 598, + "need": 2036, + "text": 2012.3 + }, + "about@8": { + "content_w": 813, + "need": 1618, + "text": 1594.3 + } + }, + "grid_w": 1536, + "grid_w_filter_bar": 1276, + "verify": { + "about@fits": { + "box": 1776, + "height": 44.4, + "scrolls": false, + "text": 1748.3 + }, + "about@fix": { + "box": 1800, + "height": 45.0, + "scrolls": false, + "text": 1748.3 + }, + "about@short": { + "box": 1736, + "height": 43.4, + "scrolls": true, + "text": 1748.3 + }, + "br-html@fits": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 104 + }, + "br-html@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 104 + }, + "br-html@short": { + "box": 88, + "height": 2.2, + "scrolls": true, + "text": 104 + }, + "caps-digits@fits": { + "box": 88, + "height": 2.2, + "scrolls": true, + "text": 82 + }, + "caps-digits@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 82 + }, + "caps-digits@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 82 + }, + "long-word@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "long-word@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "long-word@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 60 + }, + "loose-list@fits": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 98 + }, + "loose-list@fix": { + "box": 144, + "height": 3.6, + "scrolls": false, + "text": 98 + }, + "loose-list@short": { + "box": 88, + "height": 2.2, + "scrolls": true, + "text": 98 + }, + "nested-list@fits": { + "box": 200, + "height": 5.0, + "scrolls": false, + "text": 170 + }, + "nested-list@fix": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 170 + }, + "nested-list@short": { + "box": 160, + "height": 4.0, + "scrolls": true, + "text": 170 + }, + "quote-code@fits": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 190.9 + }, + "quote-code@fix": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 190.9 + }, + "quote-code@short": { + "box": 176, + "height": 4.4, + "scrolls": true, + "text": 190.9 + }, + "section@fits": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 183.4 + }, + "section@fix": { + "box": 224, + "height": 5.6, + "scrolls": false, + "text": 183.4 + }, + "section@short": { + "box": 176, + "height": 4.4, + "scrolls": true, + "text": 183.4 + }, + "setext@fits": { + "box": 168, + "height": 4.2, + "scrolls": false, + "text": 138.6 + }, + "setext@fix": { + "box": 168, + "height": 4.2, + "scrolls": false, + "text": 138.6 + }, + "setext@short": { + "box": 128, + "height": 3.2, + "scrolls": true, + "text": 138.6 + }, + "strip-bold@fits": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-bold@fix": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-bold@short": { + "box": 40, + "height": 0.6, + "scrolls": true, + "text": 38 + }, + "strip-h1@fits": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 67.2 + }, + "strip-h1@fix": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 67.2 + }, + "strip-h1@short": { + "box": 56, + "height": 1.4, + "scrolls": true, + "text": 67.2 + }, + "strip-h2@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 57.4 + }, + "strip-h2@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 57.4 + }, + "strip-h2@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 57.4 + }, + "strip-h3@fits": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h3@fix": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h3@short": { + "box": 40, + "height": 0.8, + "scrolls": true, + "text": 46.4 + }, + "strip-h4@fits": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h4@fix": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h4@short": { + "box": 40, + "height": 0.8, + "scrolls": true, + "text": 46.4 + }, + "strip-text@fits": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-text@fix": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-text@short": { + "box": 40, + "height": 0.6, + "scrolls": true, + "text": 38 + }, + "table@fits": { + "box": 168, + "height": 4.2, + "scrolls": false, + "text": 148 + }, + "table@fix": { + "box": 192, + "height": 4.8, + "scrolls": false, + "text": 148 + }, + "table@short": { + "box": 128, + "height": 3.2, + "scrolls": true, + "text": 148 + }, + "two-lines@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "two-lines@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "two-lines@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 60 + } + } + }, + "6.1.0": { + "blocks": { + "about@12": { + "content_w": 1504, + "need": 1156, + "text": 1132 + }, + "about@3": { + "content_w": 340, + "need": 3246, + "text": 3222 + }, + "about@4": { + "content_w": 469, + "need": 2432, + "text": 2408 + }, + "about@6": { + "content_w": 728, + "need": 1772, + "text": 1748 + }, + "about@8": { + "content_w": 987, + "need": 1442, + "text": 1418 + }, + "br-html@12": { + "content_w": 1504, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 728, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 987, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "caps-digits@3": { + "content_w": 340, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "caps-digits@8": { + "content_w": 987, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 469, + "need": 106, + "text": 82 + }, + "long-word@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "loose-list@12": { + "content_w": 1504, + "need": 142, + "text": 112 + }, + "loose-list@3": { + "content_w": 340, + "need": 164, + "text": 134 + }, + "loose-list@4": { + "content_w": 469, + "need": 142, + "text": 112 + }, + "loose-list@6": { + "content_w": 728, + "need": 142, + "text": 112 + }, + "loose-list@8": { + "content_w": 987, + "need": 142, + "text": 112 + }, + "nested-list@12": { + "content_w": 1504, + "need": 214, + "text": 184 + }, + "nested-list@3": { + "content_w": 340, + "need": 214, + "text": 184 + }, + "nested-list@4": { + "content_w": 469, + "need": 214, + "text": 184 + }, + "nested-list@6": { + "content_w": 728, + "need": 214, + "text": 184 + }, + "nested-list@8": { + "content_w": 987, + "need": 214, + "text": 184 + }, + "quote-code@12": { + "content_w": 1504, + "need": 179, + "text": 155 + }, + "quote-code@3": { + "content_w": 340, + "need": 201, + "text": 177 + }, + "quote-code@4": { + "content_w": 469, + "need": 179, + "text": 155 + }, + "quote-code@6": { + "content_w": 728, + "need": 179, + "text": 155 + }, + "quote-code@8": { + "content_w": 987, + "need": 179, + "text": 155 + }, + "section@12": { + "content_w": 1504, + "need": 196, + "text": 166 + }, + "section@3": { + "content_w": 340, + "need": 306, + "text": 276 + }, + "section@4": { + "content_w": 469, + "need": 240, + "text": 210 + }, + "section@6": { + "content_w": 728, + "need": 218, + "text": 188 + }, + "section@8": { + "content_w": 987, + "need": 218, + "text": 188 + }, + "setext@12": { + "content_w": 1504, + "need": 161, + "text": 137.2 + }, + "setext@3": { + "content_w": 340, + "need": 161, + "text": 137.2 + }, + "setext@4": { + "content_w": 469, + "need": 161, + "text": 137.2 + }, + "setext@6": { + "content_w": 728, + "need": 161, + "text": 137.2 + }, + "setext@8": { + "content_w": 987, + "need": 161, + "text": 137.2 + }, + "strip-bold@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-bold@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1504, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 340, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 469, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 728, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 987, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1504, + "need": 80, + "text": 56 + }, + "strip-h2@3": { + "content_w": 340, + "need": 80, + "text": 56 + }, + "strip-h2@4": { + "content_w": 469, + "need": 80, + "text": 56 + }, + "strip-h2@6": { + "content_w": 728, + "need": 80, + "text": 56 + }, + "strip-h2@8": { + "content_w": 987, + "need": 80, + "text": 56 + }, + "strip-h3@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1504, + "need": 190, + "text": 174 + }, + "table@3": { + "content_w": 340, + "need": 256, + "text": 240 + }, + "table@4": { + "content_w": 469, + "need": 190, + "text": 174 + }, + "table@6": { + "content_w": 728, + "need": 190, + "text": 174 + }, + "table@8": { + "content_w": 987, + "need": 190, + "text": 174 + }, + "two-lines@12": { + "content_w": 1504, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 340, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 469, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 987, + "need": 84, + "text": 60 + } + }, + "containers": { + "footer@4": 469, + "header@12": 1504, + "sub-tab@6": 728 + }, + "filter_bar": { + "about@12": { + "content_w": 1276, + "need": 1288, + "text": 1264 + }, + "about@3": { + "content_w": 283, + "need": 3818, + "text": 3794 + }, + "about@4": { + "content_w": 393, + "need": 2806, + "text": 2782 + }, + "about@6": { + "content_w": 614, + "need": 2014, + "text": 1990 + }, + "about@8": { + "content_w": 835, + "need": 1618, + "text": 1594 + } + }, + "grid_w": 1536, + "grid_w_filter_bar": 1308, + "verify": { + "about@fits": { + "box": 1776, + "height": 44.4, + "scrolls": false, + "text": 1748 + }, + "about@fix": { + "box": 1800, + "height": 45.0, + "scrolls": false, + "text": 1748 + }, + "about@short": { + "box": 1736, + "height": 43.4, + "scrolls": true, + "text": 1748 + }, + "br-html@fits": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 104 + }, + "br-html@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 104 + }, + "br-html@short": { + "box": 88, + "height": 2.2, + "scrolls": true, + "text": 104 + }, + "caps-digits@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "caps-digits@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 60 + }, + "caps-digits@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 60 + }, + "long-word@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "long-word@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "long-word@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 60 + }, + "loose-list@fits": { + "box": 144, + "height": 3.6, + "scrolls": false, + "text": 112 + }, + "loose-list@fix": { + "box": 144, + "height": 3.6, + "scrolls": false, + "text": 112 + }, + "loose-list@short": { + "box": 104, + "height": 2.6, + "scrolls": true, + "text": 112 + }, + "nested-list@fits": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 184 + }, + "nested-list@fix": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 184 + }, + "nested-list@short": { + "box": 176, + "height": 4.4, + "scrolls": true, + "text": 184 + }, + "quote-code@fits": { + "box": 184, + "height": 4.6, + "scrolls": false, + "text": 155 + }, + "quote-code@fix": { + "box": 216, + "height": 5.4, + "scrolls": false, + "text": 155 + }, + "quote-code@short": { + "box": 144, + "height": 3.6, + "scrolls": true, + "text": 155 + }, + "section@fits": { + "box": 224, + "height": 5.6, + "scrolls": false, + "text": 188 + }, + "section@fix": { + "box": 224, + "height": 5.6, + "scrolls": false, + "text": 188 + }, + "section@short": { + "box": 184, + "height": 4.6, + "scrolls": true, + "text": 188 + }, + "setext@fits": { + "box": 168, + "height": 4.2, + "scrolls": false, + "text": 137.2 + }, + "setext@fix": { + "box": 168, + "height": 4.2, + "scrolls": false, + "text": 137.2 + }, + "setext@short": { + "box": 128, + "height": 3.2, + "scrolls": true, + "text": 137.2 + }, + "strip-bold@fits": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-bold@fix": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-bold@short": { + "box": 40, + "height": 0.6, + "scrolls": true, + "text": 38 + }, + "strip-h1@fits": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 67.2 + }, + "strip-h1@fix": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 67.2 + }, + "strip-h1@short": { + "box": 56, + "height": 1.4, + "scrolls": true, + "text": 67.2 + }, + "strip-h2@fits": { + "box": 80, + "height": 2.0, + "scrolls": false, + "text": 56 + }, + "strip-h2@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 56 + }, + "strip-h2@short": { + "box": 40, + "height": 1.0, + "scrolls": true, + "text": 56 + }, + "strip-h3@fits": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h3@fix": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h3@short": { + "box": 40, + "height": 0.8, + "scrolls": true, + "text": 46.4 + }, + "strip-h4@fits": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h4@fix": { + "box": 72, + "height": 1.8, + "scrolls": false, + "text": 46.4 + }, + "strip-h4@short": { + "box": 40, + "height": 0.8, + "scrolls": true, + "text": 46.4 + }, + "strip-text@fits": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-text@fix": { + "box": 64, + "height": 1.6, + "scrolls": false, + "text": 38 + }, + "strip-text@short": { + "box": 40, + "height": 0.6, + "scrolls": true, + "text": 38 + }, + "table@fits": { + "box": 192, + "height": 4.8, + "scrolls": false, + "text": 174 + }, + "table@fix": { + "box": 192, + "height": 4.8, + "scrolls": false, + "text": 174 + }, + "table@short": { + "box": 152, + "height": 3.8, + "scrolls": true, + "text": 174 + }, + "two-lines@fits": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "two-lines@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 60 + }, + "two-lines@short": { + "box": 48, + "height": 1.2, + "scrolls": true, + "text": 60 + } + } + } + }, + "widths": [ + 12, + 8, + 6, + 4, + 3 + ] +} diff --git a/tests/test_design_defaults.py b/tests/test_design_defaults.py index 105e4e6..1bd126f 100644 --- a/tests/test_design_defaults.py +++ b/tests/test_design_defaults.py @@ -755,7 +755,7 @@ def test_explain_shows_every_governed_field_and_its_source(monkeypatch, tmp_path code, out = _cli(["explain", str(spec), "--json", "--chart", "A"], monkeypatch, tmp_path, capsys) assert code == 0 and json.loads(out)["charts"][0]["chart"] == "A" code, out = _cli(["explain", str(spec)], monkeypatch, tmp_path, capsys) - assert code == 0 and out.startswith("Design defaults (design brain 7") + assert code == 0 and out.startswith("Design defaults (design brain 11") code, out = _cli(["explain", str(spec), "--chart", "Z"], monkeypatch, tmp_path, capsys) assert code == 1 and "unknown_chart" in out diff --git a/tests/test_design_rules_v2.py b/tests/test_design_rules_v2.py index b0229bc..4d98d36 100644 --- a/tests/test_design_rules_v2.py +++ b/tests/test_design_rules_v2.py @@ -2,6 +2,8 @@ ranking sort, ordinal order, format consistency, color bands, and the markdown-height fix.""" +import pytest + from chartwright.design import advise, advise_and_fix from chartwright.design.presets import Overlay from chartwright.spec import load_spec @@ -181,15 +183,32 @@ def test_treemap_vs_bar(): def test_markdown_height_fix(): + # A level-2 heading takes 81.4 px on 4.1.4 and 5.0.0 (80 on 6.1.0): 2.2 units. data = mk([line("L")], layout={"rows": [[{"markdown": "## Section", "height": 6}], ["L"]]}) fixed, rep = advise_and_fix(data, overlay=EMPTY) entry = next(e for e in rep.fixed if e["rule"] == "layout.markdown-height") - assert entry["set"] == {"height": 2} and entry["was"] == {"height": 6} - assert fixed["layout"]["rows"][0][0]["height"] == 2 + assert entry["set"] == {"height": 2.2} and entry["was"] == {"height": 6} + assert fixed["layout"]["rows"][0][0]["height"] == 2.2 load_spec(fixed) +@pytest.mark.parametrize("markdown,fits", [ + ("# Network performance", 2.4), ("## Section", 2.2), ("### Delay causes", 1.8), + ("Figures refresh nightly at 02:00 UTC.", 1.6), +]) +def test_markdown_height_fits_the_line_it_holds(markdown, fits): + """The fix is the height the line takes on every release (tests/fixtures/markdown_fit), + and a block already that short, or shorter than 3 units, is left alone.""" + for height, fired in ((6, True), (3, True), (fits, False), (2.8, False)): + data = mk([line("L")], layout={"rows": [[{"markdown": markdown, "height": height}], ["L"]]}) + found = [f for f in run(data).findings if f.rule == "layout.markdown-height"] + assert bool(found) is fired, (markdown, height) + if fired: + assert found[0].fix["set"] == {"height": fits} + assert found[0].detail == f"one-line markdown block at {height} units; {fits:g} fits it" + + def test_markdown_height_fix_reaches_sub_tabs_and_footer(): md = {"markdown": "## Section", "height": 6} data = mk([line("L"), line("M")], layout={ @@ -200,8 +219,8 @@ def test_markdown_height_fix_reaches_sub_tabs_and_footer(): where = sorted(e["md"][0] if e["md"][0] == "footer" else "sub" for e in rep.fixed if e["rule"] == "layout.markdown-height") assert where == ["footer", "sub"] - assert fixed["layout"]["tabs"][0]["tabs"][0]["rows"][0][0]["height"] == 2 - assert fixed["layout"]["footer"][0][0]["height"] == 2 + assert fixed["layout"]["tabs"][0]["tabs"][0]["rows"][0][0]["height"] == 2.2 + assert fixed["layout"]["footer"][0][0]["height"] == 2.2 load_spec(fixed) diff --git a/tests/test_layout_header.py b/tests/test_layout_header.py index fc8ac38..079f4f0 100644 --- a/tests/test_layout_header.py +++ b/tests/test_layout_header.py @@ -233,8 +233,8 @@ def test_a_header_markdown_fix_is_addressed_to_the_header(): fixed, rep = advise_and_fix(data, overlay=EMPTY) addresses = sorted(str(e["md"][0]) for e in rep.fixed if e["rule"] == "layout.markdown-height") assert addresses == ["None", "header"] - assert fixed["layout"]["header"][0][0]["height"] == 2 - assert fixed["layout"]["rows"][0][0]["height"] == 2 + assert fixed["layout"]["header"][0][0]["height"] == 2.2 + assert fixed["layout"]["rows"][0][0]["height"] == 2.2 load_spec(fixed) diff --git a/tests/test_layout_headers.py b/tests/test_layout_headers.py index 6fe0238..dcc9bc9 100644 --- a/tests/test_layout_headers.py +++ b/tests/test_layout_headers.py @@ -167,5 +167,5 @@ def test_the_markdown_height_fix_addresses_the_right_block_past_headers(): data = with_rows([{"header": "Top"}, {"row": [{"markdown": "One line", "height": 4}, "Revenue"], "background": "white"}, ["Orders", "By Region"]]) fixed, report = advise_and_fix(data, overlay=load_overlay(None)) - assert fixed["layout"]["rows"][1]["row"][0]["height"] == 2 + assert fixed["layout"]["rows"][1]["row"][0]["height"] == 1.6 assert fixed["layout"]["rows"][0] == {"header": "Top"} diff --git a/tests/test_markdown_fit.py b/tests/test_markdown_fit.py new file mode 100644 index 0000000..06b408e --- /dev/null +++ b/tests/test_markdown_fit.py @@ -0,0 +1,282 @@ +"""size.markdown-fit, and the estimate of a markdown block's height behind it +(chartwright/design/markdown_fit.py), against what Superset drew. + +tests/fixtures/markdown_fit/measurements.json holds what 4.1.4, 5.0.0 and 6.1.0 drew in +headless Chromium at a 1600 px viewport on 2026-10-05 (tools/record_markdown_fit.py): +- 17 texts (definitions in five sections, one-line strips of each heading level, lists + tight, loose and nested, a table, a quote with code and a rule, hard breaks,
, + setext headings, a long URL, capitals and digits) at 12, 8, 6, 4 and 3 twelfths, and + the long text beside the filter bar. Each with the height it needs to show with no + inner scrollbar and how far down its text reaches, in px; +- each text at 6/12 at three heights, with Windows-style scrollbars on: the height the + fix writes, the height it needs (to an 8 px grid row) and a unit less. Whether it + scrolled, and where its text ended. The screenshots behind them showed text cut off + at the bottom edge exactly where `text` passes `box`. + +The low bound must never exceed what any release drew (or the rule warns of a block +that fits), and the high bound never fall short of it (or the fix leaves it cut off). +""" + +import json +from pathlib import Path + +import pytest + +from chartwright.design import advise, advise_and_fix +from chartwright.design.markdown_fit import (TYPOGRAPHY, box_px, estimate, parse, plain, + text_width, wrapped_lines) +from chartwright.design.model import RULES +from chartwright.design.presets import Overlay +from chartwright.spec import load_spec + +MEASURED = json.loads((Path(__file__).parent / "fixtures" / "markdown_fit" / + "measurements.json").read_text(encoding="utf-8")) +CORPUS = MEASURED["corpus"] +RELEASES = MEASURED["releases"] +ABOUT = CORPUS["about"] +EMPTY = Overlay() +DS = {"database": "db", "table": "orders"} + + +def drawn(key: str, filter_bar: bool) -> list[dict]: + return [r["filter_bar" if filter_bar else "blocks"][key] for r in RELEASES.values() + if key in r["filter_bar" if filter_bar else "blocks"]] + + +CASES = [(f"{n}@{w}", False) for n in CORPUS for w in MEASURED["widths"]] +CASES += [(f"about@{w}", True) for w in MEASURED["widths"]] + + +def test_every_release_was_measured(): + assert set(RELEASES) == {"4.1.4", "5.0.0", "6.1.0"} + for r in RELEASES.values(): + assert len(r["blocks"]) == len(CORPUS) * len(MEASURED["widths"]) + assert len(r["verify"]) == len(CORPUS) * 3 + + +@pytest.mark.parametrize("key,filter_bar", CASES) +def test_the_bounds_hold_what_every_release_drew(key, filter_bar): + """Measured px are whole (scrollHeight rounds), so each bound may sit half a px past.""" + name, w = key.split("@") + seen = drawn(key, filter_bar) + fit = estimate(CORPUS[name], int(w), filter_bar) + low_text, low_need = min(d["text"] for d in seen), min(d["need"] for d in seen) + high_need = max(d["need"] for d in seen) + assert fit.text_low <= low_text + 0.5 and fit.need_low <= low_need + 0.5 + assert fit.need_high >= high_need - 0.5 + # Tight enough to be worth having: the low bound within a line or 4%, the high one + # within a line or 11% (narrow blocks of long text beside the filter bar are where + # the scrollbar allowance costs the most). + assert low_need - fit.need_low <= max(23, 0.04 * low_need) + assert fit.need_high - high_need <= max(23, 0.11 * high_need) + + +@pytest.mark.parametrize("release", sorted(RELEASES)) +def test_the_rule_agrees_with_what_each_release_showed(release): + """At 6/12 with Windows-style scrollbars: a block at the fix's height or taller never + scrolled, a block the rule calls cut off was, and one it reports at all scrolled.""" + checked = {"fits": 0, "warn": 0, "finding": 0} + for key, seen in RELEASES[release]["verify"].items(): + fit = estimate(CORPUS[key.split("@")[0]], 6) + box = box_px(seen["height"]) + assert box == seen["box"], key + if fit.units <= seen["height"]: + checked["fits"] += 1 + assert not seen["scrolls"], key + if fit.text_low - box >= 8: + checked["warn"] += 1 + assert seen["text"] - seen["box"] >= 8, key + if fit.need_low > box: + checked["finding"] += 1 + assert seen["scrolls"], key + assert min(checked.values()) >= 5, checked + + +def test_the_grid_widths_the_estimate_assumes_are_the_ones_drawn(): + for release, r in RELEASES.items(): + t = TYPOGRAPHY[1] if release == "6.1.0" else TYPOGRAPHY[0] + assert (r["grid_w"], r["grid_w_filter_bar"]) == (t.grid, t.grid_filter_bar) + for key, seen in r["blocks"].items(): + assert abs(text_width(int(key.split("@")[1]), t.grid) - seen["content_w"]) < 1 + # A header row, a sub-tab and a footer row wrap text as wide as layout rows do. + for where, width in r["containers"].items(): + assert abs(text_width(int(where.split("@")[1]), t.grid) - width) < 1 + + +def test_heights_under_a_unit_still_draw_40_px(): + """The markdown component's minimum (GRID_MIN_ROW_UNITS rows of 8 px).""" + assert [box_px(h) for h in (0.2, 0.6, 1, 1.6)] == [40, 40, 40, 64] + shown = {k: v["box"] for r in RELEASES.values() for k, v in r["verify"].items() + if v["height"] < 1} + assert shown and set(shown.values()) == {40} + + +# -- parsing ---------------------------------------------------------------------- + + +def kinds(markdown): + return [(b.kind, b.level) if b.kind == "h" else b.kind for b in parse(markdown)[0]] + + +def test_parse_reads_the_common_shapes(): + assert kinds("# A\n## B\nText\nmore text\n\n- a\n- b\n\n| x | y |\n|---|---|\n| 1 | 2 |" + "\n\n> q\n\n```\ncode\n```\n\n---\n\nEnd") == [ + ("h", 1), ("h", 2), "p", "list", "table", "quote", "code", "hr", "p"] + assert kinds("Title\n=====\n\nSub\n---\n\nBody") == [("h", 1), ("h", 2), "p"] + assert kinds("

Html heading

\n\nText") == [("h", 3), "p"] + p = parse("one \ntwo\\\nthree
four\nstill four")[0][0] + assert p.texts == ["one", "two", "three", "four still four"] + lst = parse("1. a\n - b\n - c\n2. d\n\n more of d\n3. e")[0][0] + assert [d for d, _ in lst.items] == [0, 1, 1, 0, 0] and lst.loose + assert lst.items[3][1] == "d more of d" + assert parse("![chart](x.png)")[1] and not parse("text")[1] + + +def test_plain_text_is_what_the_reader_sees(): + assert plain("**Bold** and _em_, [a link](http://x), `code`, ~~gone~~ & tag") == \ + "Bold and em, a link, code, gone & tag" + assert plain("snake_case stays") == "snake_case stays" + + +def test_text_wraps_at_spaces_and_after_hyphens_between_letters(): + assert wrapped_lines("", 100) == 1 + assert wrapped_lines("word " * 10, 10_000) == 1 + # One unbreakable word wider than the line keeps a line of its own. + assert wrapped_lines("a " + "x" * 200 + " b", 300) == 3 + assert wrapped_lines("great-circle", 60) == 2 and wrapped_lines("-5", 1) == 1 + + +# -- the rule ----------------------------------------------------------------------- + + +def mk(layout, filters=None, **dashboard): + data = {"spec_version": "1", "dashboard": {"title": "T", "slug": "t", **dashboard}, + "charts": [{"type": "big_number_total", "name": "K", "dataset": DS, + "metric": "COUNT(*)"}], + "layout": layout} + if filters: + data["filters"] = filters + return data + + +def rows(*blocks): + return {"rows": [*([b] for b in blocks), ["K"]]} + + +def fits(data): + return [f for f in advise(load_spec(data), overlay=EMPTY).findings + if f.rule == "size.markdown-fit"] + + +def test_the_rule_is_registered_with_its_levels(): + r = RULES["size.markdown-fit"] + assert (r.severity, r.severities, r.fixable, r.data_aware, r.since) == ( + "warn", ("warn", "info"), True, False, "11") + + +def test_definitions_cut_off_mid_section_warn_and_the_fix_shows_them(): + """The About tab: a full-width block of about 1,000 words in five sections, 10 units + high. Every release drew it 1,156 px tall; the block showed 400.""" + data = mk(rows({"markdown": ABOUT, "width": 12, "height": 10})) + [f] = fits(data) + need = estimate(ABOUT, 12).units + assert f.severity == "warn" and f.where == "layout row 0" and 28.9 < need < 30 + assert f.detail.startswith("markdown text runs past its block: about 33 lines cut off") + assert f"it needs ~{need:g} units at 12/12, has 10; raise the height" in f.detail + fixed, report = advise_and_fix(data, overlay=EMPTY) + assert fixed["layout"]["rows"][0][0]["height"] == need + entry = next(e for e in report.fixed if e["rule"] == "size.markdown-fit") + assert entry["md"] == [None, 0, 0] and entry["was"] == {"height": 10} + assert not fits(fixed) + + +@pytest.mark.parametrize("markdown,height,level", [ + # Short strips: the 16 px padding and the heading's margins come before the text. + ("## Punctuality", 1.2, "warn"), ("## Punctuality", 1.6, "info"), ("## Punctuality", 2, None), + ("# Network performance", 1.2, "warn"), ("# Network performance", 2, "info"), + ("# Network performance", 2.4, None), + ("Figures refresh nightly at 02:00 UTC.", 0.6, "info"), + ("Figures refresh nightly at 02:00 UTC.", 1, "info"), + ("Figures refresh nightly at 02:00 UTC.", 1.6, None), + ("Figures refresh nightly at 02:00 UTC.", None, None), +]) +def test_strips_shorter_than_their_line_and_padding(markdown, height, level): + block = {"markdown": markdown, **({"height": height} if height else {})} + found = fits(mk(rows(block))) + assert [f.severity for f in found] == ([level] if level else []) + if level == "info": + assert "so the block scrolls, and Windows draws a scrollbar in it" in found[0].detail + + +def test_every_place_a_block_can_sit_is_checked_and_fixed_in_place(): + strip = {"markdown": "## Section", "height": 1.2} + data = mk({ + "header": [[dict(strip)]], + "tabs": [{"title": "Main", "rows": [[dict(strip)], ["K"]]}, + {"title": "More", "tabs": [{"title": "Sub", "rows": [{"header": "H"}, [dict(strip)]]}]}], + "footer": [[dict(strip)]], + }) + assert sorted(f.where for f in fits(data)) == [ + "footer row 0", "header row 0", "tab 'Main' row 0", "tab 'More > Sub' row 1"] + fixed, _ = advise_and_fix(data, overlay=EMPTY) + lay = fixed["layout"] + assert {lay["header"][0][0]["height"], lay["tabs"][0]["rows"][0][0]["height"], + lay["tabs"][1]["tabs"][0]["rows"][1][0]["height"], lay["footer"][0][0]["height"]} == {2.2} + assert not fits(fixed) + + +def test_the_filter_bar_narrows_the_text(): + block = {"markdown": ABOUT, "width": 12, "height": 30} + select = [{"name": "Region", "type": "select", "dataset": DS, "column": "region"}] + assert not fits(mk(rows(block))) + [f] = fits(mk(rows(block), filters=select)) + assert f.severity == "warn" + # A horizontal bar sits above the grid; without its feature flag 4.1.4 and 5.0.0 draw + # it on the left, so nothing is sure enough to warn about. + assert not fits(mk(rows(block), filters=select, filter_bar_orientation="horizontal")) + + +def test_a_narrow_block_is_told_it_can_widen(): + data = mk({"rows": [[{"markdown": CORPUS["section"], "width": 4, "height": 4}, "K"]]}) + [f] = fits(data) + assert "at 4/12, has 4; raise the height or widen it" in f.detail + + +def test_no_fix_when_the_height_cannot_be_known_or_written(): + image = fits(mk(rows({"markdown": "![Trend](https://example.org/t.png)\n\n" + ABOUT, + "height": 10})))[0] + assert image.fix is None and "needs at least ~" in image.detail + huge = fits(mk(rows({"markdown": ABOUT * 3, "width": 3, "height": 50})))[0] + assert huge.fix is None and "past the 100-unit maximum" in huge.detail + + +@pytest.mark.parametrize("dashboard,caveat", [ + ({"css": ".dashboard-markdown p { font-size: 16px; }"}, "the dashboard's CSS sets its own type"), + ({"theme": "Brand"}, "which the theme 'Brand' may change"), + ({"css": ".dashboard-markdown h2 { color: #003366; }"}, None), +]) +def test_custom_type_is_named_and_the_fix_still_offered(dashboard, caveat): + [f] = fits(mk(rows({"markdown": ABOUT, "height": 10}), **dashboard)) + assert f.fix is not None + assert (caveat in f.detail) if caveat else f.detail.endswith("raise the height") + + +def test_a_finding_can_be_ignored_by_its_position(): + data = mk(rows({"markdown": ABOUT, "height": 10})) + data["design"] = {"ignore": ["size.markdown-fit@layout-row-0"]} + rep = advise(load_spec(data), overlay=EMPTY) + assert not [f for f in rep.findings if f.rule == "size.markdown-fit"] + assert "size.markdown-fit@layout-row-0" in rep.ignored + + +def test_the_two_markdown_rules_settle_on_one_height(): + """layout.markdown-height shrinks a one-line block to the height the fit estimate + gives it, and size.markdown-fit never raises it again.""" + data = mk(rows({"markdown": "# Network performance", "height": 6}, + {"markdown": "## Section", "height": 1.2})) + fixed, report = advise_and_fix(data, overlay=EMPTY) + assert [fixed["layout"]["rows"][i][0]["height"] for i in (0, 1)] == [2.4, 2.2] + assert sorted(e["rule"] for e in report.fixed if "markdown" in e["rule"]) == [ + "layout.markdown-height", "size.markdown-fit"] + again, report = advise_and_fix(fixed, overlay=EMPTY) + assert again == fixed and not report.fixed diff --git a/tests/test_standards.py b/tests/test_standards.py index dfa7750..a1e4b2a 100644 --- a/tests/test_standards.py +++ b/tests/test_standards.py @@ -497,7 +497,7 @@ def test_the_standards_disable_list_is_reported_as_ignored(repo, capsys): def test_explain_names_the_standard_chain(repo, capsys): spec = write_spec(repo / "specs" / "s.json", standard="finance") text = run_ok(capsys, "explain", str(spec)) - assert text.splitlines()[0] == ("Design defaults (design brain 7, audience analytical, " + assert text.splitlines()[0] == ("Design defaults (design brain 11, audience analytical, " "standard org -> finance)") payload = run_ok(capsys, "explain", str(spec), "--json") assert payload["standard"] == {"name": "finance", "chain": ["org", "finance"], @@ -889,7 +889,7 @@ def test_no_standards_directory_changes_nothing(tmp_path, capsys): assert all("layer" not in f and "locked" not in f for f in payload["findings"]) assert payload == advise(load_spec(DATA)).payload() text = run_ok(capsys, "explain", str(spec)) - assert text.splitlines()[0] == "Design defaults (design brain 7, audience analytical)" + assert text.splitlines()[0] == "Design defaults (design brain 11, audience analytical)" def _bundle_hash(data: dict) -> str: diff --git a/tests/test_standards_content.py b/tests/test_standards_content.py index a1ccbb3..1c4ec80 100644 --- a/tests/test_standards_content.py +++ b/tests/test_standards_content.py @@ -969,7 +969,7 @@ def test_a_released_row_is_the_authors_for_repairs_too(tmp_path, capsys): apply(capsys, str(path)) assert read(path)["design"]["standard_written"]["layout.footer[org][0]"]["released"] fixed, _ = advise_and_fix(read(path)) - assert fixed["layout"]["footer"][0][0]["height"] == 2 + assert fixed["layout"]["footer"][0][0]["height"] == 1.6 def test_a_released_row_put_back_word_for_word_is_still_the_authors(tmp_path, capsys): @@ -984,7 +984,7 @@ def test_a_released_row_put_back_word_for_word_is_still_the_authors(tmp_path, ca edit(path, lambda d: d["layout"]["footer"][0][0].update(markdown="Legal notice")) assert C.owned_rows(load_spec(read(path))) == set() fixed, _ = advise_and_fix(read(path)) - assert fixed["layout"]["footer"][0][0]["height"] == 2 + assert fixed["layout"]["footer"][0][0]["height"] == 1.6 def test_blocks_go_after_a_leading_namespace(tmp_path, capsys): @@ -1285,10 +1285,27 @@ def test_a_repair_never_edits_a_standards_row(tmp_path, capsys): apply(capsys, str(path)) data = read(path) fixed, _ = advise_and_fix(data) - assert fixed["layout"]["footer"][0][0]["height"] == 2, "the author's row is repaired" + assert fixed["layout"]["footer"][0][0]["height"] == 1.6, "the author's row is repaired" assert fixed["layout"]["footer"][-1] == [{"markdown": "Legal"}], "the standard's is not" +def test_a_standards_row_cut_off_is_reported_but_never_resized(tmp_path, capsys): + """size.markdown-fit names a standard's row whose text is cut off, with no fix (the + height is the standard's), and says nothing of one whose padding merely scrolls.""" + org = ("name: org\ndefault: true\ncontent:\n footer:\n" + " - [{markdown: \"## Legal\\n\\nFor named recipients only.\", height: 1.2}]\n" + " - [{markdown: Legal, height: 1}]\n") + repo = make_repo(tmp_path / "r", {"org.yaml": org}).parent + path = spec_file(repo) + apply(capsys, str(path)) + data = read(path) + found = [f for f in advise(load_spec(data)).findings if f.rule == "size.markdown-fit"] + assert [(f.where, f.severity, f.fix) for f in found] == [("footer row 1", "warn", None)] + assert "a standard owns this row, so its height is the standard's to change" in found[0].detail + fixed, _ = advise_and_fix(data) + assert fixed["layout"]["footer"][1:] == data["layout"]["footer"][1:] + + def test_number_format_skips_charts_whose_metrics_disagree_or_plot_shares(repo): fin = std(repo) spec = load_spec({**DATA, "charts": [ @@ -1447,7 +1464,7 @@ def test_explain_has_a_dashboard_section(repo, capsys): "released", "bnbColors", "supersetColors") assert scheme["override"].startswith("it is yours") text = run_ok(capsys, "explain", str(path)) - assert text.splitlines()[0].startswith("Design defaults (design brain 7") + assert text.splitlines()[0].startswith("Design defaults (design brain 11") assert "Dashboard content (standard org -> finance, via design.standard)" in text # A chart field the standard wrote says so in the chart section too. k = next(c for c in payload["charts"] if c["chart"] == "K") diff --git a/tools/ci_live_markdown_fit.py b/tools/ci_live_markdown_fit.py new file mode 100644 index 0000000..b4e9cc8 --- /dev/null +++ b/tools/ci_live_markdown_fit.py @@ -0,0 +1,72 @@ +"""Live check of size.markdown-fit: a markdown block at the height `advise --fix` gives +it shows every line, and a block the rule calls cut off is. + +Flow: each text of tools/record_markdown_fit.py's corpus at 6/12, at the height the +estimate's fix writes and a unit less, on one dashboard (slug cw-live-mdfit), opened in +headless Chromium at a 1600 px viewport with classic scrollbars on, as Windows draws +them. At the fix's height no block may scroll; a unit less, every block the rule warns +about must have its text cut off by a grid row or more, and every block it reports at +all must scroll. The dashboard is deleted at the end. + +Needs the visual extra: pip install -e ".[visual]" && playwright install chromium. + + SDC_CI_PASSWORD=admin python tools/ci_live_markdown_fit.py --base-url http://host:8098 + +Exit codes: 0 pass, 1 failure. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "tools")) + +from chartwright.client import SupersetClient # noqa: E402 +from chartwright.design.markdown_fit import box_px, estimate # noqa: E402 +from record_markdown_fit import CORPUS, verify # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--base-url", default=os.environ.get("SDC_CI_BASE_URL", "http://localhost:8098")) + ap.add_argument("--username", default="admin") + ap.add_argument("--prefix", default="cw-live", help="slug prefix of the dashboard") + args = ap.parse_args(argv) + password = os.environ.get("SDC_CI_PASSWORD", "admin") # sandbox default; never in argv + base = args.base_url.rstrip("/") + client = SupersetClient(base, args.username, password) + client.login() + + fits = {name: estimate(text, 6) for name, text in CORPUS.items()} + heights = [] + for name, fit in fits.items(): + heights += [(name, "fix", fit.units), (name, "short", round(max(0.2, fit.units - 1), 1))] + seen = verify(client, base, args.username, password, f"{args.prefix}-mdfit", heights) + failures: list[str] = [] + for key, got in seen.items(): + name, kind = key.split("@") + fit, box = fits[name], box_px(got["height"]) + expect = [] + if kind == "fix": + expect.append(("shows every line", not got["scrolls"])) + else: + if fit.text_low - box >= 8: + expect.append(("has its text cut off", got["text"] - got["box"] >= 8)) + if fit.need_low > box: + expect.append(("scrolls", got["scrolls"])) + for what, ok in expect: + line = f"{name} at {got['height']:g} units {what} (box {got['box']} px, text to {got['text']} px)" + print(("ok " if ok else "FAIL ") + line) + if not ok: + failures.append(line) + print(f"{len(failures)} failure(s)" if failures else "markdown fit holds") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/record_markdown_fit.py b/tools/record_markdown_fit.py new file mode 100644 index 0000000..3b8651e --- /dev/null +++ b/tools/record_markdown_fit.py @@ -0,0 +1,372 @@ +"""Record how tall Superset draws markdown blocks of known content, so the +size.markdown-fit estimate (chartwright/design/markdown_fit.py) is tested offline +against real renders (tests/test_markdown_fit.py reads +tests/fixtures/markdown_fit/measurements.json). + +For each instance, in headless Chromium at a 1600 x 1200 viewport: +- `blocks`: a dashboard (slug cw-test-mdfit-) holding every CORPUS text at + every width in WIDTHS. For each block it reads `content_w`, the width the text wraps + in (the holder less its padding); `need`, the height that shows everything with no + inner scrollbar, padding and outer margins included (scrollHeight with the holder + squeezed to 1 px, so the content overflows and its full height is reported); and + `text`, how far down the last line of text (or rule, or table) reaches; +- `filter_bar`: the about text at every width on a dashboard with a native filter, + whose bar opens on the left and narrows the grid; +- `containers`: the width a block wraps its text in from a header row, a sub-tab and + a footer row, against layout rows; +- `verify`: with Chromium's classic scrollbars on, as Windows draws them, each text at + 6/12 at three heights: the one the estimate's fix writes (`fix`), the one `need` + asks for rounded up to an 8 px grid row, and a unit less. For each it records the + box, whether it scrolls, and where the text ends (read before anything is + squeezed), and with --shots saves a screenshot of the block. + +Every dashboard is deleted at the end. + + pip install -e ".[visual]" && playwright install chromium + SDC_CI_PASSWORD=admin python tools/record_markdown_fit.py \\ + --base-url http://localhost:8094 --base-url http://localhost:8098 [--shots DIR] +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) + +from chartwright.apply import apply as run_apply # noqa: E402 +from chartwright.client import SupersetClient # noqa: E402 +from chartwright.compiler import _position # noqa: E402 +from chartwright.design.markdown_fit import estimate # noqa: E402 +from chartwright.spec import load_spec # noqa: E402 + +OUT = REPO / "tests" / "fixtures" / "markdown_fit" / "measurements.json" +WIDTHS = [12, 8, 6, 4, 3] +DS = {"database": "examples", "table": "cleaned_sales_data"} + +# Definitions in five sections, with headings and a bullet list, like the About tab +# that rendered cut off mid-section at 10 units (about 1,000 words). +ABOUT = """## How to read this dashboard + +Every figure on these tabs comes from the operations warehouse, refreshed nightly at 02:00 UTC. A day closes when the last flight of the schedule lands, so the most recent day can move until the following morning. Figures are shown in the network's reporting currency and converted at the month's average rate, never the daily rate, so a month never changes once it has closed. Where a chart says "network" it means every route the schedule operated in the period, including seasonal routes, and excludes charters, ferry flights and positioning flights, which carry no revenue passengers. + +Hover over any point to see its exact value, and use the filter bar on the left to narrow every chart at once. A filter applies to every tab, not only the one in view. The period selector defaults to the last full quarter; widen it to compare a season with the same season a year earlier. + +## Punctuality + +- **On-time departure**: the share of departures that leave the gate within 15 minutes of the scheduled time. Cancelled flights are excluded from both sides of the ratio; diverted flights count by their original schedule. +- **On-time arrival**: the share of arrivals that reach the gate within 15 minutes of the scheduled time. This is the measure the regulator publishes and the one the monthly report leads with. +- **Average delay**: the mean minutes late across flights that were late, measured gate to gate. Early flights count as zero, never as negative minutes, so an early arrival cannot hide a late one. +- **Delay cause**: the first code the station controller recorded. Reactionary delays, caused by a late inbound aircraft, are grouped separately so that a single morning disruption does not read as a day of independent failures. +- **Completion factor**: flights operated as a share of flights scheduled two days before departure. Schedule changes made earlier than that are planning, not disruption, and do not count against it. + +## Capacity and demand + +Capacity is measured in available seat kilometres: every seat offered, multiplied by the great-circle distance of the sector it was offered on. Demand is measured in revenue passenger kilometres: every fare-paying passenger, multiplied by the same distance. Staff travel, infants without a seat and award tickets redeemed at zero fare are not revenue passengers. + +Load factor is demand divided by capacity. It is shown both for the network and per route, and the route figures are weighted by distance, so a long sector moves the network average more than a short one. A route with a load factor above 90 percent for four consecutive weeks is flagged for a capacity review; one below 60 percent for the same period is flagged for a schedule review. Both flags appear in the route table on the Network tab and clear themselves once the route returns to the normal band. + +Seat counts come from the aircraft actually operated, not the aircraft planned, so an equipment swap on the day changes the capacity of that flight. Blocked seats, such as crew rest seats on long sectors, are removed from the count. + +## Revenue and yield + +Passenger revenue is the fare the passenger paid for each sector, net of taxes, airport charges and fuel surcharges, which pass through to third parties. A ticket covering several sectors is prorated across them by distance. Ancillary revenue, such as checked bags, seat selection and onboard sales, is reported separately and never mixed into yield. + +Yield is passenger revenue per revenue passenger kilometre, shown in cents. Unit revenue is passenger revenue per available seat kilometre, which falls when seats fly empty even if every passenger paid more. Read the two together: rising yield with falling unit revenue usually means fares went up faster than the planes filled. + +Refunds are booked against the month of the original sale, not the month of the refund, so a large refund wave can revise a closed month's revenue. The revision is noted in the monthly summary and the chart marks the revised point with a hollow marker. + +## Cost and efficiency + +Unit cost is operating cost per available seat kilometre, shown in cents, with fuel shown both inside and outside the total because fuel moves with markets the airline does not control. Operating cost includes crew, maintenance, airport and navigation charges, aircraft ownership and overheads, and excludes one-off items such as restructuring charges, which are listed in the notes of the month in which they occur. + +Fuel burn per block hour is measured from the aircraft's own fuel records, not from the flight plan, and compared with the same aircraft type's fleet average. A tail that burns more than three percent above its type's average for a month is listed on the Fleet tab for an engineering review. + +Aircraft utilisation is block hours per aircraft per day, counting only aircraft in the operating fleet. Aircraft in heavy maintenance, in storage or on lease to another operator are left out, so a fleet with many aircraft parked does not look less productive than it is. + +## Data quality and changes + +Each tab shows the time of its last successful refresh in the footer. If a source system is late, the affected charts show the previous day's figures and a note says which source is behind. A missing day is never filled in or estimated; it stays visibly empty until the source delivers it. + +Definitions change rarely and only at the start of a quarter. When one does, the change is listed here with its date, the previous definition is kept for one year, and charts that span the change mark it with a vertical line so that a step in the series is not mistaken for a change in performance. + +Questions about a figure go to the analytics team through the request form linked in the header. Please include the tab, the chart title and the filters you had set, so the figure can be reproduced exactly. +""" + +CORPUS = { + "about": ABOUT, + "strip-text": "Figures refresh nightly at 02:00 UTC.", + "strip-h1": "# Network performance", + "strip-h2": "## Punctuality", + "strip-h3": "### Delay causes", + "strip-h4": "#### Notes", + "strip-bold": "**Source:** operations warehouse, refreshed nightly", + "two-lines": "Figures refresh nightly at 02:00 UTC. \nThe most recent day can still move.", + "br-html": "Figures refresh nightly.
The most recent day can move.

Questions go to the analytics team.", + "section": ("## Capacity and demand\n\nCapacity is measured in available seat kilometres: " + "every seat offered, multiplied by the great-circle distance of the sector it " + "was offered on. Demand is measured in revenue passenger kilometres.\n\n" + "- **Load factor**: demand divided by capacity\n" + "- **Yield**: passenger revenue per revenue passenger kilometre\n" + "- **Unit cost**: operating cost per available seat kilometre"), + "loose-list": ("- First, filter to the period you need.\n\n" + "- Then pick a route from the table on the Network tab.\n\n" + "- Finally, open the delay causes for that route."), + "nested-list": ("1. Punctuality\n - On-time departure\n - On-time arrival\n" + "2. Capacity\n - Seat kilometres\n - Load factor\n3. Revenue"), + "table": ("| Measure | Unit | Source |\n|---|---|---|\n" + "| On-time arrival | % of arrivals | Station records |\n" + "| Load factor | % of seats | Reservations |\n" + "| Yield | cents per RPK | Revenue accounting |\n" + "| Unit cost | cents per ASK | Finance |\n" + "| Utilisation | block hours per day | Maintenance |"), + "caps-digits": ("ASK 1,234,567,890 | RPK 987,654,321 | LF 80.0% | CASK 10.25 | RASK 11.40 | " + "OTP-15 88.2% | CF 99.1% | BH/AC/DAY 12.4 | FUEL KG/BH 2,345 | " + "Q3 FY2026 VS Q3 FY2025: +4.2% ASK, +6.1% RPK, +1.5 PTS LF"), + "quote-code": ("> Figures shown are provisional until the month closes.\n\n" + "```\nload_factor = rpk / ask\nyield = revenue / rpk\n```\n\n" + "---\n\nLast updated by the analytics team."), + "long-word": ("See https://example.org/dashboards/operations/definitions/punctuality-and-" + "capacity-measures-for-the-network-report for the full list."), + "setext": "Network report\n==============\n\nQuarterly figures\n-----------------\n\nBody text.", +} + + + + +def spec_for(slug: str, title: str, layout: dict, filters: bool = False) -> dict: + """A dashboard of markdown blocks; the one chart a spec needs sits in its own row.""" + count = f"{title} count" + data = { + "spec_version": "1", + "dashboard": {"title": title, "slug": slug}, + "charts": [{"name": count, "type": "big_number_total", "dataset": DS, + "metric": "COUNT(*)"}], + "layout": layout, + } + target = data["layout"]["tabs"][0]["tabs"][0] if "tabs" in layout else data["layout"] + target["rows"] = [*target.get("rows", []), [count]] + if filters: + data["filters"] = [{"name": "Deal size", "type": "select", "dataset": DS, + "column": "deal_size"}] + return data + + +def rows_of(blocks: list[dict]) -> dict: + return {"rows": [[b] for b in blocks]} + + +# Reads every markdown block drawn so far (a dashboard may draw rows only as they +# scroll into view). `need` squeezes the holder to 1 px so its content overflows: +# Chromium's scrollHeight then counts the padding and the children's outer margins, +# the height that shows the block with no inner scrollbar. +MEASURE_JS = r""" +() => { + const grid = document.querySelector('.grid-container').getBoundingClientRect(); + const out = []; + for (const md of document.querySelectorAll('.dashboard-markdown')) { + const holder = md.querySelector('.dashboard-component-chart-holder'); + if (!holder || !holder.querySelector('p, h1, h2, h3, h4, h5, h6, ul, ol, table, pre')) continue; + const cs = getComputedStyle(holder); + const pad = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight); + const box = holder.clientHeight, scrolls = holder.scrollHeight > holder.clientHeight; + const top = holder.getBoundingClientRect().top; + const range = document.createRange(); + range.selectNodeContents(holder); + const text = range.getBoundingClientRect().bottom - top; + const keep = holder.style.height; + holder.style.height = '1px'; + const need = holder.scrollHeight; + holder.style.height = keep; + out.push({id: md.id, content_w: holder.clientWidth - pad, box, scrolls, need, + text: Math.round(text * 10) / 10}); + } + return {grid_w: grid.width, blocks: out}; +} +""" + + +def _login(page, base: str, username: str, password: str) -> None: + page.goto(f"{base}/login/") + page.wait_for_selector("input[type=password]") + page.locator("input:not([type=password]):not([type=hidden])").first.fill(username) + page.fill("input[type=password]", password) + page.keyboard.press("Enter") + page.wait_for_load_state("networkidle") + + +def _ids(data: dict) -> list[str]: + return [k for k in _position(load_spec(data)) if k.startswith("MARKDOWN-")] + + +def _measure(page, base: str, slug: str, ids: list[str]) -> tuple[float, dict[str, dict]]: + """Open the dashboard and measure its markdown blocks, scrolling until each has + drawn. A block's first reading is kept: squeezing it for `need` can leave a + classic scrollbar behind, which narrows the text on the next reading.""" + page.goto(f"{base}/superset/dashboard/{slug}/") + page.wait_for_load_state("networkidle") + page.wait_for_selector(".dashboard-markdown") + page.wait_for_timeout(1500) + seen: dict[str, dict] = {} + grid_w = 0.0 + for _ in range(400): + got = page.evaluate(MEASURE_JS) + grid_w = got["grid_w"] + for b in got["blocks"]: + seen.setdefault(b["id"], b) + if all(i in seen for i in ids): + break + page.mouse.wheel(0, 600) + page.wait_for_timeout(250) + missing = [i for i in ids if i not in seen] + if missing: + raise SystemExit(f"{slug}: never drew {missing}") + return grid_w, seen + + +def _apply(client, data: dict) -> None: + report = run_apply(load_spec(data), client, "record") + if not report.ok: + raise SystemExit(report.to_json()) + + +def _delete(client, slug: str) -> None: + dash = client.find_dashboard_by_slug(slug) + if dash is None: + return + charts = client.dashboard_charts(dash["id"]) + url = f"{client.base_url}/api/v1/dashboard/{dash['id']}" + client._send(lambda: client.session.delete(url, timeout=60)) + for c in charts: + client.delete_chart(c["id"]) + + +def _keep(b: dict) -> dict: + return {k: b[k] for k in ("content_w", "need", "text")} + + +def verify(client, base: str, username: str, password: str, slug: str, + heights: list[tuple[str, str, float]], shots: Path | None = None) -> dict: + """Apply each CORPUS text at 6/12 at the heights given, as (text, kind, height), open + the dashboard with Chromium's classic scrollbars on, as Windows draws them, and read + each block's box, whether it scrolls and where its text ends. Deletes the dashboard.""" + from playwright.sync_api import sync_playwright + + data = spec_for(slug, slug.replace("-", " "), rows_of( + [{"markdown": CORPUS[n], "width": 6, "height": h} for n, _, h in heights])) + out: dict = {} + try: + _apply(client, data) + with sync_playwright() as p: + # Playwright hides scrollbars in headless Chromium; a reader on Windows sees them. + browser = p.chromium.launch(ignore_default_args=["--hide-scrollbars"]) + page = browser.new_context(viewport={"width": 1600, "height": 1200}).new_page() + page.set_default_timeout(90000) + _login(page, base, username, password) + ids = _ids(data) + _, seen = _measure(page, base, slug, ids) + for (name, kind, h), i in zip(heights, ids): + b = seen[i] + out[f"{name}@{kind}"] = {"height": h, "box": b["box"], "scrolls": b["scrolls"], + "text": b["text"]} + if shots is not None: + shots.mkdir(parents=True, exist_ok=True) + # The resizable container inside carries the same id. + page.locator(f".dashboard-markdown[id='{i}']").screenshot( + path=str(shots / f"{slug}-{name}-{kind}-{h:g}.png")) + browser.close() + finally: + _delete(client, slug) + return out + + +def record(base: str, username: str, password: str, shots: Path | None) -> tuple[str, dict]: + from playwright.sync_api import sync_playwright + + client = SupersetClient(base, username, password) + client.login() + release = client.superset_version() + tag = release.replace(".", "") + slug = f"cw-test-mdfit-{tag}" + names = [(name, w) for name in CORPUS for w in WIDTHS] + md = lambda name, w: {"markdown": CORPUS[name], "width": w, "height": 4} # noqa: E731 + specs = { + "blocks": spec_for(slug, f"cw test mdfit {tag}", rows_of([md(n, w) for n, w in names])), + "filter_bar": spec_for(f"{slug}-f", f"cw test mdfit {tag} f", + rows_of([md("about", w) for w in WIDTHS]), filters=True), + "containers": spec_for(f"{slug}-c", f"cw test mdfit {tag} c", { + "header": [[md("about", 12)]], + "tabs": [{"title": "Tab", "tabs": [{"title": "Sub-tab", "rows": [[md("about", 6)]]}]}], + "footer": [[md("about", 4)]], + }), + } + out: dict = {"blocks": {}, "filter_bar": {}, "containers": {}} + try: + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_context(viewport={"width": 1600, "height": 1200}).new_page() + page.set_default_timeout(90000) + _login(page, base, username, password) + for key, data in specs.items(): + _apply(client, data) + ids = _ids(data) + grid_w, seen = _measure(page, base, data["dashboard"]["slug"], ids) + if key == "blocks": + out["grid_w"] = grid_w + out["blocks"] = {f"{n}@{w}": _keep(seen[i]) for (n, w), i in zip(names, ids)} + elif key == "filter_bar": + out["grid_w_filter_bar"] = grid_w + out["filter_bar"] = {f"about@{w}": _keep(seen[i]) for w, i in zip(WIDTHS, ids)} + else: + where = {"header": "header@12", "footer": "footer@4"} + out["containers"] = {where.get(i.split("-")[2], "sub-tab@6"): + seen[i]["content_w"] for i in ids} + browser.close() + finally: + for data in specs.values(): + _delete(client, data["dashboard"]["slug"]) + # Each text at the fix's height, at the height it measured (whole 8 px grid rows), + # and a unit less. + heights = [] + for name in CORPUS: + fits = min(100, math.ceil(out["blocks"][f"{name}@6"]["need"] / 8) / 5) + heights += [(name, "fix", estimate(CORPUS[name], 6).units), (name, "fits", fits), + (name, "short", round(max(0.2, fits - 1), 1))] + out["verify"] = verify(client, base, username, password, f"{slug}-v", heights, shots) + return release, out + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--base-url", action="append", required=True) + ap.add_argument("--username", default="admin") + ap.add_argument("--shots", type=Path, help="save a screenshot of each verify block here") + args = ap.parse_args(argv) + password = os.environ.get("SDC_CI_PASSWORD", "admin") # sandbox default; never in argv + + rec = json.loads(OUT.read_text(encoding="utf-8")) if OUT.exists() else {"releases": {}} + rec["about"] = ("Markdown blocks Superset drew in headless Chromium at a 1600 x 1200 " + "viewport, in px; written by tools/record_markdown_fit.py, which says " + "what each number is.") + rec["corpus"] = CORPUS + rec["widths"] = WIDTHS + for base in args.base_url: + release, measured = record(base.rstrip("/"), args.username, password, args.shots) + rec["releases"][release] = measured + scrolled = sorted(k for k, v in measured["verify"].items() if v["scrolls"]) + print(release, f"grid {measured['grid_w']} px ({measured['grid_w_filter_bar']} px " + f"beside the filter bar); containers {measured['containers']}; scrolls at", + scrolled, flush=True) + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(rec, indent=1, sort_keys=True) + "\n", encoding="utf-8") + print(f"wrote {OUT.relative_to(REPO).as_posix()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f3c713f3329ac457a1a12ff85afc5224a9405e0f Mon Sep 17 00:00:00 2001 From: debabsah Date: Mon, 5 Oct 2026 13:31:58 -0700 Subject: [PATCH 2/2] feat: size.markdown-fit reads the dashboard's CSS, in a 1440 px window Dashboards that set their markdown's text sizes in CSS were estimated with Superset's own sizes, so the rule missed both ways: a long block a few lines over went unreported, a small footer that fits got a warning, and captions set smaller than Superset's text were under-estimated. - markdown_fit.py now reads the spec's dashboard.css and applies it in cascade order (!important, specificity, source order) over Superset's own styles, written as CSS per release: rules on .dashboard-markdown and its elements, with descendant, child, + and ~ combinators, :first-child and :last-child, for font-size, line-height, font-weight, letter- and word-spacing, margins, padding, borders and border-spacing (px, em, rem, %; unitless line heights). Measured on every release: every .dashboard-markdown rule beats Superset's, except an h6's font size and h4-h6 weight, which the markdown component sets in its own class; heading and body line heights are factors. - The layout is now an element tree with CSS margin collapsing (li margins, p:last-child), line boxes from their inline text (a smaller in a line), inline font sizes and padding (em, code) in the widths, measured Inter widths for Latin-1 and common symbols, and wide/narrow guesses for the rest per bound. - CSS it can't read that could size markdown (another font family, attribute selectors and pseudo-elements, @media, var(), a percentage margin, the block's padding, class-less rules) is named in the finding instead of the old blanket "assumes Superset's default" caveat; a theme is still named. - The rule assumes a 1440 px window (wider windows wrap less, so the fix holds there too), estimates only the releases the spec can go to (a theme leaves out 4.1.4 and 5.0.0, so a horizontal filter bar sits on top), and lets a block run under half a px over, since scrollHeight is whole px. - The fixture now holds both windows, a styled sales-review dashboard's 14 blocks under its own CSS, 570 elements' computed styles (no CSS, every property on every element, the styled dashboard's CSS) and verify passes for corpus and styled blocks at 1440. Tests check the cascade against every computed style and both bounds against every height; the live CI check covers the styled blocks too. --- CHANGELOG.md | 5 +- chartwright/design/markdown_fit.py | 1150 ++- chartwright/design/model.py | 5 +- chartwright/design/rules.py | 69 +- docs/DESIGN-BRAIN.md | 120 +- docs/FEATURES.md | 2 +- docs/LAYOUT-GUIDE.md | 5 +- docs/VERIFICATION.md | 27 +- tests/fixtures/markdown_fit/measurements.json | 6601 +++++++++++++---- tests/test_markdown_fit.py | 399 +- tools/ci_live_markdown_fit.py | 69 +- tools/record_markdown_fit.py | 385 +- 12 files changed, 6846 insertions(+), 1991 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c351c..dfc5712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,10 @@ while the major version is 0, minor bumps may include breaking changes and say s ### Added -- `advise` checks that each markdown block is tall enough for its text (`size.markdown-fit`). Superset cuts the rest off inside the block: macOS shows no scrollbar there, so a screenshot shows the text stopping mid-line. The check estimates the text's height from its headings, paragraphs, lists, tables, quotes, code and line breaks and the block's width, measured against what 4.1.4, 5.0.0 and 6.1.0 draw. - - It warns when letters are cut off on every release and says by how much ("it needs ~29.6 units at 12/12, has 10"); `--fix` raises the block to a height that shows every line on every release, Windows scrollbars included. +- `advise` checks that each markdown block is tall enough for its text (`size.markdown-fit`). Superset cuts the rest off inside the block: macOS shows no scrollbar there, so a screenshot shows the text stopping mid-line. The check estimates the text's height from its headings, paragraphs, lists, tables, quotes, code and line breaks, the block's width and the dashboard's own CSS, in a window 1440 px wide, measured against what 4.1.4, 5.0.0 and 6.1.0 draw. A wider window wraps fewer lines, so a height that fits there fits wider too. + - It warns when letters are cut off on every release and says by how much ("it needs ~16 units at 12/12 in a 1440 px wide window, has 15"); `--fix` raises the block to a height that shows every line on every release, Windows scrollbars included. - A block whose last line or padding only reaches the edge gets an info: it scrolls a few px, and Windows draws a scrollbar in it. That covers short strips: a line of text needs 1.6 units, a `##` heading 2.2. + - It reads the text sizes and spacing a dashboard's CSS gives its markdown: font sizes, line heights, weights, letter and word spacing, margins, padding and borders set on `.dashboard-markdown` elements, `h4 + p` and `p:last-child` rules included, in cascade order over Superset's own. CSS that could change a block's height in a way it doesn't read (another font family, attribute selectors and pseudo-elements, `@media` rules) is named in the finding, as is a theme. - It checks blocks in layout rows, tabs and sub-tabs, and header and footer rows, and allows for the filter bar, which narrows the page on a dashboard with native filters. A header or footer row a standard owns is reported, never resized, and a block holding an image gets no fix, since its height is unknown. ### Fixed diff --git a/chartwright/design/markdown_fit.py b/chartwright/design/markdown_fit.py index 72c9b48..7f16e46 100644 --- a/chartwright/design/markdown_fit.py +++ b/chartwright/design/markdown_fit.py @@ -1,11 +1,12 @@ """How tall Superset draws a dashboard markdown block, estimated offline from its -text: `size.markdown-fit` and `layout.markdown-height` (rules.py) both read it. +text and the dashboard's CSS: `size.markdown-fit` and `layout.markdown-height` +(rules.py) both read it. What Superset does with a markdown block, at 4.1.4, 5.0.0 and 6.1.0 alike: - renders the text with react-markdown and GitHub-flavoured markdown (tables, strikethrough), raw HTML sanitized (packages/superset-ui-core/src/components/ SafeMarkdown.tsx at 4.1.4 and 5.0.0, components/SafeMarkdown/SafeMarkdown.tsx at - 6.1.0); + 6.1.0). The elements sit straight inside the holder, with no wrapper of their own; - puts it in a holder with 16 px of padding on every side (DashboardBuilder.tsx :326-332 at 4.1.4, :325-331 at 5.0.0, :311-317 at 6.1.0) that scrolls what doesn't fit (overflow-y: auto; gridComponents/Markdown.jsx:106-109 at 4.1.4 and 5.0.0, @@ -16,19 +17,38 @@ GRID_MIN_ROW_UNITS = 5 (util/constants.ts:42) to ResizableContainer as its minHeight (resizable/ResizableContainer.jsx:285-286 at 4.1.4, ResizableContainer.tsx:287 at 6.1.0); -- lays the grid out in 12 columns with 16 px gutters (util/constants.ts:38-40). At a - 1600 px viewport the grid is 1536 px wide. Beside the vertical filter bar, which - opens by default on a dashboard with native filters (DashboardBuilder/state.ts:39-41 - at 4.1.4; OPEN_FILTER_BAR_WIDTH = 260 in dashboard/constants.ts), it is 1276 px at - 4.1.4 and 5.0.0 and 1308 px at 6.1.0. - -The type sizes, margins and character widths below were measured on 2026-10-05 in -headless Chromium at that 1600 px viewport, each release with its default theme -(tools/record_markdown_fit.py; docs/DESIGN-BRAIN.md, "Markdown blocks"). The text -wraps word by word as the browser wraps it, with Inter's own advance widths, and -block margins collapse as CSS collapses them. tests/test_markdown_fit.py holds what -each release drew for 17 texts at five widths, beside the filter bar and not, and -checks both bounds below against every one. +- lays the grid out in 12 columns with 16 px gutters (util/constants.ts:38-40), as + wide as the window less 64 px. Beside the vertical filter bar, which opens by + default on a dashboard with native filters (DashboardBuilder/state.ts:39-41 at + 4.1.4; OPEN_FILTER_BAR_WIDTH = 260 in dashboard/constants.ts), it is 260 px + narrower at 4.1.4 and 5.0.0 and 228 px at 6.1.0. + +The dashboard's CSS reaches the markdown: every `.dashboard-markdown ` rule +beats Superset's own type, on every release, except an h6's font size and the weight +of h4 to h6, which the markdown component sets inside its own class (Markdown.jsx +:92-104 at 4.1.4 and 5.0.0, Markdown/Markdown.tsx:112-124 at 6.1.0). So the estimate +reads the CSS the spec carries and applies it in cascade order, with Superset's own +styles (RELEASES) underneath: rules on `.dashboard-markdown` and its elements, with +descendant, child and sibling combinators (`h4 + p`), :first-child and :last-child, +and the properties that size text: font-size, line-height, font-weight, letter- and +word-spacing, margins, padding, borders and a table's border-spacing. Anything else +that could size markdown (a font family, an attribute selector, a pseudo-element, an +@media block, the block's own padding) is listed in `Stylesheet.unread`, and the +finding names it. + +Superset's own styles and the character widths below were measured on 2026-10-05 in +headless Chromium, each release with its default theme (tools/record_markdown_fit.py; +docs/DESIGN-BRAIN.md, "Markdown blocks"). Headings' line heights are a factor, 1.4 +(dashboard/styles.ts:21-50), the body's antd's 1.5715, so both follow a font size the +CSS sets. The text wraps word by word as the browser wraps it, with Inter's own +advance widths, a line is as tall as the inline text on it needs, and block margins +collapse as CSS collapses them. tests/test_markdown_fit.py holds what each release +drew for 17 texts at five widths in two windows, beside the filter bar and not, and +for a dashboard's own markdown under its own CSS, and checks both bounds below +against every one. + +The rule assumes a window 1440 px wide (VIEWPORT), a common 13-inch laptop's +screen. A wider window wraps fewer lines, so a height that fits there fits wider too. Two answers, because a rule must be sure before it speaks and its fix must work on every release: @@ -52,6 +72,7 @@ import re import unicodedata from dataclasses import dataclass, field +from functools import lru_cache # Inter's advance widths in em for ASCII 32..126 at weight 400, the widest each # character measured at any release (canvas measureText): 4.1.4 ships Inter 3, 5.0.0 @@ -63,65 +84,389 @@ ".639 .765 .644 .642 .646 .744 .690 .985 .682 .679 .629 .365 .360 .365 .471 .456 " ".497 .564 .621 .571 .621 .583 .370 .613 .591 .242 .242 .549 .242 .876 .591 .600 " ".612 .612 .376 .528 .364 .591 .562 .818 .546 .562 .552 .426 .333 .426 .662").split()) -_EM_OTHER = 0.6 # a character outside ASCII (an accented letter, Greek): about an 'o' -_EM_WIDE = 1.0 # East Asian wide and fullwidth characters, drawn by a fallback font +# The same for Latin-1 and common punctuation and symbols, measured the same way. +_EM_MORE = {ch: em for em, chars in ( + (0.223, "′"), (0.241, "‚"), (0.242, "ìíîï"), (0.261, "‘’"), (0.268, "¸"), + (0.269, "ÌÍÎÏ"), (0.27, "¦"), (0.281, "\u00a0"), (0.288, "¡·"), (0.313, "¹"), + (0.384, "‹›"), (0.421, "„"), (0.44, "“”"), (0.442, "²″"), (0.446, "³"), (0.454, "ª"), + (0.456, "°"), (0.478, "¯"), (0.482, "º"), (0.497, "´"), (0.5, "–"), (0.511, "¿"), + (0.549, "≤≥≈≠√"), (0.55, "¥"), (0.556, "‡"), (0.562, "ýÿγ"), (0.563, "•"), + (0.564, "àáâãäå"), (0.568, "§"), (0.571, "¢ç✗"), (0.583, "«»èéêëð"), (0.584, "δ"), + (0.591, "¨ñùúûü"), (0.596, "β"), (0.6, "òóôõöø"), (0.601, "ÈÉÊË"), (0.603, "¶"), + (0.604, "●○■□"), (0.611, "™"), (0.612, "þ∆"), (0.615, "◇"), (0.616, "ß"), (0.622, "µ"), + (0.631, "£†π"), (0.636, "Þ"), (0.662, "¬±×÷−"), (0.664, "α"), (0.667, "€"), + (0.67, "®"), (0.677, "✘"), (0.679, "Ý"), (0.69, "ÀÁÂÃÄÅ"), (0.713, "∑∞"), (0.723, "◆"), + (0.725, "¤"), (0.73, "Ç"), (0.735, "Ð"), (0.744, "ÙÚÛÜ"), (0.753, "Ñ"), (0.764, "✓"), + (0.765, "ÒÓÔÕÖØ"), (0.77, "Ω"), (0.846, "✔"), (0.864, "…"), (0.884, "¼"), (0.915, "©"), + (0.925, "æ"), (0.926, "½"), (0.949, "¾"), (0.955, "↑↓"), (0.99, "▲▼►◄"), (0.994, "Æ"), + (1.0, "—‰→←↔★☆"), (0.0, "\u00ad\u200b\u200c\u200d\ufeff"), +) for ch in chars} +_EM_MONO = 0.6 # every character of a monospace font (Fira Code, Courier, DejaVu Mono) +# A character the tables don't hold: the low bound takes it narrow, the high one wide, +# East Asian wide and fullwidth characters (drawn by a fallback font) widest of all. +_EM_UNKNOWN = {"low": (0.3, 0.9), "high": (0.8, 1.0)} LOW_SCALE = 0.97 # the low bound's character widths, against the table's -HIGH_SCALE = 1.01 # the high bound's character widths, against the table's -_HEADING_WEIGHT = 1.02 # h1-h3 are drawn at weight 600 (4.1.4, 5.0.0) or 500 (6.1.0) +HIGH_SCALE = 1.01 # the high bound's +VIEWPORT = 1440 # the window the rule assumes, in px PAD = 16.0 # the holder's padding, each side SCROLLBAR = 17.0 # a classic scrollbar's width on Windows GUTTER = 16.0 # GRID_GUTTER_SIZE +PAGE_MARGIN = 64.0 # the window's width less the grid's MIN_BOX = 40.0 # GRID_MIN_ROW_UNITS grid rows of GRID_BASE_UNIT (8) px +SNAP = 0.4 # px a block may run over and not scroll (scrollHeight is whole px) UNIT = 40.0 # px per spec height unit -BODY = 14.0 # body font size, every release -LIST_INDENT = 40.0 # ul and ol padding-left, every release +ROOT_FONT = 16.0 # the html element's font size, what rem counts in + + +def _weight_factor(weight: float) -> float: + """How much wider Inter is at a weight than at 400, per sentence: 500 is 0.8% + wider, 600 (and 700, which Superset doesn't load, so the browser draws 600) 1.8%.""" + return 1.02 if weight >= 600 else 1.01 if weight >= 500 else 1.0 + + +# -- Superset's own markdown styles --------------------------------------------------- @dataclass(frozen=True) -class Typography: - """One release's default markdown styles, in px. Each pair is (margin above, - margin below); a heading is (font size, line height, margin above, below).""" - - release: str - grid: float # the grid's width at a 1600 px viewport - grid_filter_bar: float # beside the open vertical filter bar - h: dict[int, tuple[float, float, float, float]] - line: float = 22.0 # body line height (paragraphs, list items, cells) - p_m: tuple[float, float] = (0.0, 8.0) - list_m: tuple[float, float] = (0.0, 9.5) - cell_pad: float = 0.0 # a table cell's padding, top plus bottom - table_spacing: float = 0.0 # border-spacing above, between and below rows - quote_font: float = BODY - quote_line: float = 22.0 - quote_m: tuple[float, float] = (0.0, 0.0) - quote_pad: float = 0.0 # top plus bottom - quote_inset: float = 0.0 # left plus right: padding, border and margin - code_line: float = 22.0 - code_m: tuple[float, float] = (0.0, 0.0) - code_pad: float = 0.0 # top plus bottom: padding and border - hr: tuple[float, float, float] = (1.0, 15.0, 15.0) # height, margin above, below - - -_H = {1: (28.0, 39.2, 12.0, 12.0), 3: (16.0, 22.4, 8.0, 4.0), 4: (16.0, 22.4, 8.0, 4.0), - 5: (16.0, 22.4, 8.0, 4.0), 6: (12.0, 16.8, 8.0, 4.0)} -TYPOGRAPHY = ( - # 4.1.4 and 5.0.0 drew every measured block alike. - Typography( - "4.1.4 and 5.0.0", grid=1536.0, grid_filter_bar=1276.0, - h={**_H, 2: (21.0, 29.4, 12.0, 8.0)}, - quote_font=17.5, quote_line=27.5, quote_m=(0.0, 19.0), quote_pad=19.0, - quote_inset=43.0, code_line=18.2, code_m=(0.0, 9.5), code_pad=20.0, +class Release: + """One release's markdown styles, as CSS on the elements, with the specificity + Superset's own rules lose by: any `.dashboard-markdown ` rule beats them, + except the PROTECTED ones, given with the specificity they have in Superset.""" + + name: str + newest: tuple[int, int, int] # the newest release it stands for + bar: float # px the open vertical filter bar takes from the grid + flag_for_horizontal_bar: bool # draws the horizontal bar only with a feature flag + css: str + protected: tuple[tuple[str, str, tuple[int, int, int]], ...] + root: tuple[float, float] # the font size and line height (a factor) inherited + + def grid(self, viewport: float, filter_bar: bool) -> float: + return viewport - PAGE_MARGIN - (self.bar if filter_bar else 0.0) + + +# Measured with every property set from `.dashboard-markdown ` (the +# "probe" stylesheet in the fixture): each took, h6's font size apart. Line heights +# are factors (headings 1.4 in `body h1`..`h6`, dashboard/styles.ts:21-50; the body's +# 1.5715 from antd), so they follow a font size the CSS sets. +_HEADINGS = """ +.dashboard-markdown h1, .dashboard-markdown h2, .dashboard-markdown h3, +.dashboard-markdown h4, .dashboard-markdown h5, .dashboard-markdown h6 { line-height: 1.4; } +.dashboard-markdown h1 { font-size: 28px; margin: 12px 0 12px; } +.dashboard-markdown h3, .dashboard-markdown h4, .dashboard-markdown h5 { font-size: 16px; } +.dashboard-markdown h3, .dashboard-markdown h4, .dashboard-markdown h5, +.dashboard-markdown h6 { margin: 8px 0 4px; } +.dashboard-markdown p { margin: 0 0 8px; } +.dashboard-markdown ul, .dashboard-markdown ol { padding-left: 40px; } +.dashboard-markdown li ul, .dashboard-markdown li ol { margin: 0; } +.dashboard-markdown code, .dashboard-markdown pre { font-family: monospace; } +""" +_PROTECTED = ( + # Markdown.jsx:92-104 at 4.1.4 and 5.0.0, Markdown/Markdown.tsx:112-124 at 6.1.0: + # inside the component's styled class, so (0,2,1). + (".dashboard-markdown h6", "font-size: 12px", (0, 2, 1)), + (".dashboard-markdown h4, .dashboard-markdown h5, .dashboard-markdown h6", + "font-weight: 400", (0, 2, 1)), +) +RELEASES = ( + Release( + "4.1.4 and 5.0.0", (5, 0, 0), bar=260.0, flag_for_horizontal_bar=True, + css=_HEADINGS + """ +.dashboard-markdown h1, .dashboard-markdown h2, .dashboard-markdown h3 { font-weight: 600; } +.dashboard-markdown h2 { font-size: 21px; margin: 12px 0 8px; } +.dashboard-markdown ul, .dashboard-markdown ol { margin: 0 0 9.5px; } +.dashboard-markdown strong, .dashboard-markdown b, .dashboard-markdown th { font-weight: 600; } +.dashboard-markdown table { border-collapse: collapse; } +.dashboard-markdown blockquote { font-size: 17.5px; margin: 0 0 19px; padding: 9.5px 19px; + border-left: 5px solid; } +.dashboard-markdown pre { font-size: 13px; line-height: 1.4; margin: 0 0 9.5px; padding: 9px; + border: 1px solid; } +.dashboard-markdown code { font-size: 90%; padding: 2px 4px; } +.dashboard-markdown pre code { font-size: inherit; padding: 0; } +.dashboard-markdown hr { margin: 15px 0; border-top: 1px solid; } +""", + # Bootstrap's `blockquote p:last-child` (0,1,2): no gap under a quote's text. + protected=_PROTECTED + ( + (".dashboard-markdown blockquote p:last-child, " + ".dashboard-markdown blockquote ul:last-child, " + ".dashboard-markdown blockquote ol:last-child", "margin-bottom: 0", (0, 1, 2)),), + root=(14.0, 1.5715), ), - Typography( - "6.1.0", grid=1536.0, grid_filter_bar=1308.0, - h={**_H, 2: (20.0, 28.0, 12.0, 8.0)}, - list_m=(14.0, 14.0), cell_pad=2.0, table_spacing=2.0, quote_m=(14.0, 14.0), - quote_inset=80.0, code_m=(14.0, 14.0), hr=(2.0, 7.0, 7.0), + Release( + "6.1.0", (6, 1, 0), bar=228.0, flag_for_horizontal_bar=False, + css=_HEADINGS + """ +.dashboard-markdown h1, .dashboard-markdown h2, .dashboard-markdown h3 { font-weight: 500; } +.dashboard-markdown h2 { font-size: 20px; margin: 12px 0 8px; } +.dashboard-markdown ul, .dashboard-markdown ol { margin: 14px 0; } +.dashboard-markdown strong, .dashboard-markdown b { font-weight: 600; } +.dashboard-markdown th { font-weight: 500; } +.dashboard-markdown table { border-spacing: 2px; } +.dashboard-markdown td, .dashboard-markdown th { padding: 1px; } +.dashboard-markdown blockquote { margin: 14px 40px; } +.dashboard-markdown pre { margin: 14px 0; } +.dashboard-markdown hr { margin: 7px 0; border: 1px solid; } +""", + protected=_PROTECTED, root=(14.0, 22 / 14), ), ) +# -- reading CSS -------------------------------------------------------------------- + +_COMMENT = re.compile(r"/\*.*?\*/", re.S) +_SPLIT_TOP = re.compile(r",(?![^(\[]*[)\]])") +_COMPOUND = re.compile(r"^(\*|[a-zA-Z][\w-]*)?((?:\.[\w-]+)*)((?::(?:first-child|last-child|only-child))*)$") +_COMBINATOR = re.compile(r"\s*([>+~])\s*|\s+") +# Classes on the markdown block itself and the layout around it: a selector naming +# any other class targets something else, and never reaches the markdown's text. +_CHAIN = ("dashboard-markdown", "resizable-container", "dashboard-component", + "dashboard-component-chart-holder") +_AROUND = frozenset(_CHAIN) | { + "dashboard", "dashboard-content", "grid-container", "grid-content", "grid-row", + "dashboard-grid", "dashboard-component-tabs", "dashboard-component-tabs-content", + "dashboard-component-row", "dashboard-component-column", "dragdroppable", + "dragdroppable-row", "dragdroppable-column", "background--white", + "background--transparent", "ant-tabs", "ant-tabs-content", "ant-tabs-content-holder", + "ant-tabs-content-top", "ant-tabs-tabpane", "ant-tabs-tabpane-active", + "dashboard-markdown--editing"} +# What the estimate reads, and what else would change a markdown block's size. +_READ = re.compile(r"^(?:font-size|line-height|font-weight|letter-spacing|word-spacing|" + r"margin(?:-(?:top|right|bottom|left))?|padding(?:-(?:top|right|bottom|left))?|" + r"border(?:-(?:top|right|bottom|left))?(?:-(?:width|style))?|border-spacing|" + r"border-collapse|font-family)$") +_SIZING = re.compile(r"^(?:font|font-(?:size|family|weight|stretch|variant-numeric)|line-height|" + r"letter-spacing|word-spacing|margin.*|padding.*|border|border-spacing|" + r"border-collapse|border-(?:top|right|bottom|left)(?:-(?:width|style))?|" + r"border-(?:width|style)|text-transform|text-indent|white-space|word-break|" + r"overflow-wrap|word-wrap|hyphens|display|width|min-width|max-width|height|" + r"min-height|max-height|float|columns|column-[\w-]+|content|zoom|" + r"writing-mode|box-sizing)$") +_SIDES = ("top", "right", "bottom", "left") + + +@dataclass(frozen=True) +class _Selector: + compounds: tuple[tuple[str | None, frozenset, tuple[str, ...]], ...] + combinators: tuple[str, ...] # between compound k and k+1 + specificity: tuple[int, int, int] + + +@dataclass(frozen=True) +class _Rule: + selector: _Selector + decls: tuple[tuple[str, str, bool], ...] # longhand property, value, !important + specificity: tuple[int, int, int] + origin: int # 0 Superset's, 1 the dashboard's + order: int + + +@dataclass(frozen=True) +class Stylesheet: + """The parts of a dashboard's CSS the estimate reads, and the rest that could + size markdown text: (selector, property) for each.""" + + rules: tuple[_Rule, ...] = () + unread: tuple[tuple[str, str], ...] = () + + +def _parse_selector(text: str) -> _Selector | None: + """A selector the estimate can match against markdown elements, or None.""" + text = text.strip() + if not text or re.search(r"[\[\]#()\"']|::", text): + return None + parts = _COMBINATOR.split(text) + # re.split with a group alternates compound, combinator-or-None, compound... + compounds, combinators = [], [] + for k, part in enumerate(parts): + if k % 2: + combinators.append((part or " ").strip() or " ") + continue + m = _COMPOUND.match(part or "") + if not part or not m: + return None + tag = m.group(1).lower() if m.group(1) and m.group(1) != "*" else None + classes = frozenset(c for c in m.group(2).split(".") if c) + pseudo = tuple(p for p in m.group(3).split(":") if p) + compounds.append((tag, classes, pseudo)) + spec = (0, sum(len(c[1]) + len(c[2]) for c in compounds), sum(1 for c in compounds if c[0])) + return _Selector(tuple(compounds), tuple(combinators), spec) + + +def _declarations(body: str) -> list[tuple[str, str, bool]]: + out = [] + for decl in re.split(r";(?![^(]*\))", body): + if ":" not in decl: + continue + prop, value = decl.split(":", 1) + value = value.strip() + important = bool(re.search(r"!\s*important\s*$", value, re.I)) + value = re.sub(r"!\s*important\s*$", "", value, flags=re.I).strip() + out.append((prop.strip().lower(), value, important)) + return out + + +def _longhands(prop: str, value: str) -> list[tuple[str, str]] | None: + """A property as its longhands, or None when the value can't be read.""" + if "var(" in value or "calc(" in value or "env(" in value: + return None + parts = value.split() + if prop in ("margin", "padding", "border-width", "border-style"): + if not 1 <= len(parts) <= 4: + return None + top = parts[0] + right = parts[1] if len(parts) > 1 else top + bottom = parts[2] if len(parts) > 2 else top + left = parts[3] if len(parts) > 3 else right + name = prop if prop in ("margin", "padding") else "border" + suffix = "" if prop in ("margin", "padding") else "-" + prop.split("-")[1] + return [(f"{name}-{s}{suffix}", v) for s, v in zip(_SIDES, (top, right, bottom, left))] + if prop.startswith("border") and prop not in ("border-spacing", "border-collapse"): + bits = prop.split("-")[1:] + sides = [bits[0]] if bits and bits[0] in _SIDES else list(_SIDES) + if bits and bits[-1] in ("width", "style"): + return [(prop, parts[0])] if len(parts) == 1 else None + width, style = "medium", "none" + for p in parts: + if p in _STYLES: + style = p + elif p in ("thin", "medium", "thick") or re.fullmatch(r"-?[\d.]+(?:px|em|rem)?", p): + width = p + return [(f"border-{s}-{k}", v) for s in sides for k, v in (("width", width), + ("style", style))] + return [(prop, value)] + + +def _rules_of(css: str): + """(selector list, declarations, the @media or other at-rule it sits in) for each + style rule.""" + css = _COMMENT.sub("", css) + i, n = 0, len(css) + while i < n: + j = css.find("{", i) + if j < 0: + return + prelude = css[i:j].rsplit(";", 1)[-1].strip() + depth, k = 1, j + 1 + while k < n and depth: + depth += {"{": 1, "}": -1}.get(css[k], 0) + k += 1 + body = css[j + 1:k - 1] + if prelude.startswith("@"): + if re.match(r"@(?:media|supports|layer|container|document)\b", prelude, re.I): + for sel, decls, _ in _rules_of(body): + yield sel, decls, " ".join(prelude.split()) + else: + yield prelude, body, None + i = k + + +_STYLES = ("none", "hidden", "solid", "dashed", "dotted", "double", "groove", "ridge", + "inset", "outset") +_LENGTH = r"(?:0|-?(?:\d+\.?\d*|\.\d+)(?:px|em|rem)|thin|medium|thick|auto)" +_VALUES = { + "font-size": rf"inherit|{_LENGTH}|\d+\.?\d*%", + "line-height": rf"inherit|normal|\d+\.?\d*|\.\d+|{_LENGTH}|\d+\.?\d*%", + "font-weight": r"inherit|normal|bold|bolder|lighter|\d{3}", + "letter-spacing": rf"inherit|normal|{_LENGTH}", + "word-spacing": rf"inherit|normal|{_LENGTH}", + "border-spacing": rf"{_LENGTH}(?: {_LENGTH})?", + "border-collapse": r"inherit|collapse|separate", +} + + +def _readable(prop: str, value: str) -> bool: + """Whether the estimate can resolve this value: lengths in px, em or rem (a + percentage only for a font size or line height), and the keywords it knows.""" + v = value.strip().lower() + if prop.endswith("-style"): + return v in _STYLES + if prop.startswith(("margin", "padding", "border")) and prop not in _VALUES: + return bool(re.fullmatch(_LENGTH, v)) + return prop == "font-family" or bool(re.fullmatch(_VALUES[prop], v)) + + +def _family(value: str, origin: int) -> list[tuple[str, str]] | None: + """A font family the estimate knows: Superset's own, `inherit`, Inter (its body + font) or a monospace font, whose characters are all 0.6 em.""" + first = value.split(",")[0].strip().strip("'\"").lower() + if not origin or first in ("inherit", "inter") or re.search(r"mono|courier|code|consol", first): + return [("font-family", value)] + return None + + +def _reaches_markdown(selector: str) -> bool: + """Whether a selector the estimate can't match might still style markdown text: + it names the block, or names only elements and layout around it.""" + if "dashboard-markdown" in selector or "MARKDOWN-" in selector: + return True + # Superset's test hooks (data-test, data-test-chart-name) mark its other components. + if "[data-test" in selector: + return False + classes = set(re.findall(r"\.([\w-]+)", re.sub(r"\[[^\]]*\]", "", selector))) + return not (classes - _AROUND) and "#" not in selector + + +@lru_cache(maxsize=256) +def read_css(css: str | None, origin: int = 1, start: int = 0) -> Stylesheet: + """The rules of a stylesheet the estimate applies, in source order, and the + selectors and properties it leaves unread that could size markdown text.""" + rules, unread = [], [] + order = start + for selectors, body, nested in _rules_of(css or ""): + decls = _declarations(body) + sizing = [(p, v, imp) for p, v, imp in decls if _SIZING.match(p)] + if not sizing: + continue + for text in _SPLIT_TOP.split(selectors): + text = " ".join(text.split()) + sel = None if nested else _parse_selector(text) + if sel is None or (origin and sel.specificity[1] == 0): + # A selector with no class can't be ordered against Superset's own, and + # whether an at-rule applies depends on the window. + if _reaches_markdown(text): + shown = f"{nested} {{ {text} }}" if nested else text + unread += [(shown, p) for p, _, _ in sizing] + continue + # The block's own boxes: their padding is Superset's, set more specifically + # than a stylesheet can be sure to beat, so a change to it isn't read; their + # margins sit outside the text. + box = bool(sel.compounds[-1][1] & set(_CHAIN)) + longs = [] + for prop, value, imp in sizing: + parts = None + if box and prop.startswith("margin"): + continue + if prop == "font-family": + parts = _family(value, origin) + elif _READ.match(prop) and not (box and prop.startswith("padding")): + parts = _longhands(prop, value) + if parts is not None and not all(_readable(p, v) for p, v in parts): + parts = None + if parts is None: + if _reaches_markdown(text): + unread.append((text, prop)) + continue + longs += [(p, v, imp) for p, v in parts] + if longs: + order += 1 + rules.append(_Rule(sel, tuple(longs), sel.specificity, origin, order)) + return Stylesheet(tuple(rules), tuple(dict.fromkeys(unread))) + + +@lru_cache(maxsize=8) +def _base(release: Release) -> tuple[_Rule, ...]: + """Superset's own rules, all below any dashboard rule with a class in it (their + real selectors are elements, `body h1`), in their own order among themselves, + and the protected ones at the specificity they have in Superset.""" + rules = [_Rule(r.selector, r.decls, (0, 0, r.specificity[1] * 100 + r.specificity[2]), 0, + r.order) for r in read_css(release.css, origin=0).rules] + for k, (selector, decls, spec) in enumerate(release.protected): + for r in read_css(f"{selector} {{ {decls} }}", origin=0, start=10_000 + k * 10).rules: + rules.append(_Rule(r.selector, r.decls, spec, 0, r.order)) + return tuple(rules) + + # -- parsing: markdown to blocks ---------------------------------------------------- @@ -131,6 +476,7 @@ class Block: level: int = 0 # a heading's level texts: list[str] = field(default_factory=list) # p: its lines between hard breaks items: list[tuple[int, str]] = field(default_factory=list) # list: (depth, text) + ordered: list[bool] = field(default_factory=list) # list: each item's list is numbered loose: bool = False # list items separated by blank lines rows: list[list[str]] = field(default_factory=list) # table rows, header first lines: int = 0 # code: its lines @@ -140,13 +486,13 @@ class Block: _ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$") _SETEXT = re.compile(r"^ {0,3}(?:=+|-+)[ \t]*$") _HR = re.compile(r"^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$") -_ITEM = re.compile(r"^([ \t]*)(?:[-*+]|\d{1,9}[.)])(?:[ \t]+(.*)|[ \t]*$)") +_ITEM = re.compile(r"^([ \t]*)([-*+]|\d{1,9}[.)])(?:[ \t]+(.*)|[ \t]*$)") _QUOTE = re.compile(r"^ {0,3}> ?(.*)$") _DELIM = re.compile(r"^[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$") _HTML_H = re.compile(r"^\s*]*>(.*?)\s*$", re.I) _HARD = re.compile(r"(?: {2,}|\\)$") _BR = re.compile(r"", re.I) -_MEDIA = re.compile(r"!\[[^\]]*\]\([^)]*\)|<(?:img|iframe|video|svg|object|embed)\b", re.I) +_MEDIA = re.compile(r"!\[[^\]]*\]\([^)]*\)|<(?:img|iframe|video|svg|object|embed)\b[^>]*>", re.I) def _starts_block(line: str) -> bool: @@ -235,10 +581,17 @@ def _paragraph_lines(raw: list[str]) -> list[str]: return [seg for text in out for seg in _BR.split(text)] +def _marker(marker: str) -> str: + """What makes items one list: the same bullet, or numbers with the same delimiter.""" + return marker[-1] if marker[0].isdigit() else marker + + def _parse_list(lines: list[str], i: int, blocks: list[Block]) -> int: - """One list from line i, nested items and all; returns the line after it.""" + """One list from line i, nested items and all; returns the line after it. Another + bullet or delimiter at the list's own level starts another list.""" block = Block("list") indents: list[int] = [] # each open level's marker indent + kind = None # the top level's marker gap = False # a blank line since the last line of an item while i < len(lines): line = lines[i] @@ -246,13 +599,17 @@ def _parse_list(lines: list[str], i: int, blocks: list[Block]) -> int: gap = True elif m := _ITEM.match(line): indent = len(m.group(1)) + if kind is not None and indent <= indents[0] and _marker(m.group(2)) != kind: + break + kind = kind or _marker(m.group(2)) while indents and indent < indents[-1]: indents.pop() if not indents or indent > indents[-1] + 1: indents.append(indent) if gap and block.items and len(indents) == 1: block.loose = True - block.items.append((len(indents) - 1, m.group(2) or "")) + block.items.append((len(indents) - 1, m.group(3) or "")) + block.ordered.append(m.group(2)[0].isdigit()) gap = False elif (gap and not line.startswith(" ")) or (not gap and _starts_block(line)): break # the list has ended @@ -266,122 +623,552 @@ def _parse_list(lines: list[str], i: int, blocks: list[Block]) -> int: return i +# -- the element tree --------------------------------------------------------------- + + +@dataclass(eq=False) +class Node: + """An element as react-markdown draws it, with what selectors match on.""" + + tag: str + classes: frozenset = frozenset() + children: list[Node] = field(default_factory=list) + texts: list[str] = field(default_factory=list) # its lines of inline text + code_lines: int = 0 # pre: its lines + parent: Node | None = None + prev: Node | None = None # the element before it in its parent + first: bool | None = None + last: bool | None = None + + def add(self, *kids: Node) -> Node: + self.children.extend(kids) + return self + + +def _li(text: str, loose: bool) -> Node: + return Node("li").add(Node("p", texts=_BR.split(text)) if loose + else Node("#text", texts=_BR.split(text))) + + +def tree(markdown: str) -> Node: + """The markdown block's elements under its holder, the holder under the + resizable container under `.dashboard-markdown`, as Superset draws them.""" + holder = Node("div", frozenset({"dashboard-component", "dashboard-component-chart-holder"})) + for b in parse(markdown)[0]: + if b.kind == "p": + holder.add(Node("p", texts=b.texts)) + elif b.kind == "h": + holder.add(Node(f"h{b.level}", texts=_BR.split(b.texts[0]))) + elif b.kind == "hr": + holder.add(Node("hr")) + elif b.kind == "code": + holder.add(Node("pre", code_lines=b.lines).add(Node("code"))) + elif b.kind == "quote": + holder.add(Node("blockquote").add(*(Node("p", texts=_BR.split(t)) for t in b.texts))) + elif b.kind == "table": + head = Node("tr").add(*(Node("th", texts=[c]) for c in b.rows[0])) + body = [Node("tr").add(*(Node("td", texts=[c]) for c in r)) for r in b.rows[1:]] + table = Node("table").add(Node("thead").add(head)) + if body: + table.add(Node("tbody").add(*body)) + holder.add(table) + else: + stack: list[Node] = [] + for (depth, text), ordered in zip(b.items, b.ordered or [False] * len(b.items)): + while len(stack) > depth + 1: + stack.pop() + if len(stack) <= depth: + lst = Node("ol" if ordered else "ul") + (stack[-1].children[-1] if stack else holder).add(lst) + stack.append(lst) + stack[-1].add(_li(text, b.loose and depth == 0)) + resizable = Node("div", frozenset({"resizable-container"})).add(holder) + root = Node("div", frozenset({"dashboard-markdown"})).add(resizable) + _link(root) + return holder + + +def _link(node: Node) -> None: + elements = [c for c in node.children if c.tag != "#text"] + for k, child in enumerate(node.children): + child.parent = node + if child.tag != "#text": + at = elements.index(child) + child.prev = elements[at - 1] if at else None + child.first, child.last = at == 0, at == len(elements) - 1 + _link(child) + + +# -- the cascade -------------------------------------------------------------------- + + +def _match_compound(compound, node: Node) -> bool: + tag, classes, pseudo = compound + if (tag and tag != node.tag) or not classes <= node.classes: + return False + known = {"first-child": node.first, "last-child": node.last, + "only-child": node.first and node.last if None not in (node.first, node.last) + else None} + return all(known[p] is True for p in pseudo) + + +def _around(compound) -> bool: + """A compound that may match an element above `.dashboard-markdown`, which the + estimate doesn't see: a plain element, or layout classes.""" + tag, classes, pseudo = compound + return not pseudo and tag in (None, "div", "body", "html", "main", "section") and \ + classes <= (_AROUND - set(_CHAIN)) + + +def _matches(sel: _Selector, k: int, node: Node | None) -> bool: + if node is None: + return all(_around(c) for c in sel.compounds[:k + 1]) + if not _match_compound(sel.compounds[k], node): + return False + if k == 0: + return True + comb = sel.combinators[k - 1] + if comb == ">": + return _matches(sel, k - 1, node.parent) + if comb == " ": + up = node.parent + while up is not None: + if _matches(sel, k - 1, up): + return True + up = up.parent + return _matches(sel, k - 1, None) + sib = node.prev + while sib is not None: + if _matches(sel, k - 1, sib): + return True + sib = sib.prev if comb == "~" else None + return False + + +@dataclass(frozen=True) +class Style: + font: float + line: float # px + line_factor: float | None # how the line height inherits: a factor, or None (px) + weight: float + letter: float = 0.0 + word: float = 0.0 + mono: bool = False + spacing: float = 0.0 # a table's vertical border-spacing (inherited) + collapse: bool = False # border-collapse (inherited) + margin: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + padding: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + border: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + + @property + def inset(self) -> float: + """Horizontal room it takes from its parent's width.""" + return sum(s[1] + s[3] for s in (self.margin, self.padding, self.border)) + + @property + def edge_top(self) -> float: + return self.padding[0] + self.border[0] + + @property + def edge_bottom(self) -> float: + return self.padding[2] + self.border[2] + + +_LEN = re.compile(r"^(-?(?:\d+\.?\d*|\.\d+))(px|em|rem|%)?$") + + +def _length(value: str, font: float, parent_font: float | None = None) -> float | None: + """A length in px; em counts in `font`, or in `parent_font` for a font size.""" + v = value.strip().lower() + if v in ("auto", "0"): + return 0.0 + if v in ("thin", "medium", "thick"): + return {"thin": 1.0, "medium": 3.0, "thick": 5.0}[v] + m = _LEN.match(v) + if not m: + return None + n, unit = float(m.group(1)), m.group(2) + if unit == "px": + return n + if unit == "em": + return n * (parent_font if parent_font is not None else font) + if unit == "rem": + return n * ROOT_FONT + if unit == "%" and parent_font is not None: + return n * parent_font / 100 + return None if n else 0.0 + + +def _compute(node: Node, parent: Style, rules: tuple[_Rule, ...]) -> Style: + won: dict[str, tuple] = {} + for r in rules: + if not _matches(r.selector, len(r.selector.compounds) - 1, node): + continue + for prop, value, imp in r.decls: + key = (imp, r.specificity, r.origin, r.order) + if prop not in won or key >= won[prop][0]: + won[prop] = (key, value) + get = {p: v for p, (_, v) in won.items()} + font = parent.font + if "font-size" in get: + v = get["font-size"].lower() + font = parent.font if v == "inherit" else (_length(v, font, parent.font) or font) + factor, line = parent.line_factor, parent.line if parent.line_factor is None else None + if "line-height" in get: + v = get["line-height"].lower() + if v == "normal": + factor, line = 1.21, None # Inter's ascent and descent + elif re.fullmatch(r"\d+\.?\d*|\.\d+", v): + factor, line = float(v), None + elif v.endswith("%") and _LEN.match(v): + factor, line = None, float(v[:-1]) * font / 100 + elif v != "inherit" and (px := _length(v, font)) is not None: + factor, line = None, px + line = factor * font if factor is not None else line + weight = parent.weight + if "font-weight" in get: + v = get["font-weight"].lower() + weight = {"normal": 400, "bold": 700, "lighter": 400, + "bolder": 700 if parent.weight < 600 else 900}.get( + v, float(v) if v.replace(".", "").isdigit() else weight) + + def spacing(prop: str, current: float) -> float: + v = get.get(prop) + if v is None or v.lower() == "inherit": + return current + return 0.0 if v.lower() == "normal" else (_length(v, font) or 0.0) + + def sides(prop: str) -> tuple[float, ...]: + out = [] + for s in _SIDES: + v = _length(get.get(f"{prop}-{s}", "0"), font) or 0.0 + out.append(v if prop == "margin" else max(0.0, v)) + return tuple(out) + + border = tuple(0.0 if get.get(f"border-{s}-style", "none").lower() in ("none", "hidden") + else (_length(get.get(f"border-{s}-width", "medium"), font) or 0.0) + for s in _SIDES) + fam = get.get("font-family", "").split(",")[0].strip().strip("'\"").lower() + mono = parent.mono if not fam or fam == "inherit" else bool( + re.search(r"mono|courier|code|consol", fam)) + gap = parent.spacing + if "border-spacing" in get: + parts = get["border-spacing"].split() + gap = _length(parts[-1], font) or 0.0 + collapse = get.get("border-collapse", "collapse" if parent.collapse else "separate") + return Style(font, line, factor, weight, spacing("letter-spacing", parent.letter), + spacing("word-spacing", parent.word), mono, gap, + collapse.lower() == "collapse", sides("margin"), sides("padding"), border) + + +class _Styles: + """Every element's computed style for one release and one dashboard stylesheet.""" + + def __init__(self, release: Release, sheet: Stylesheet): + self.rules = _base(release) + sheet.rules + font, factor = release.root + self.root = Style(font, font * factor, factor, 400.0) + self.cache: dict[tuple, Style] = {} + + def of(self, node: Node) -> Style: + key = (node, ()) # the node itself, not its id: a freed node's id comes back + if key not in self.cache: + parent = self.of(node.parent) if node.parent is not None else self.root + self.cache[key] = parent if node.tag == "#text" else _compute(node, parent, self.rules) + return self.cache[key] + + def inline(self, node: Node, tags: tuple[str, ...]) -> Style: + """The style of text inside these inline elements, innermost last.""" + key = (node, tags) + if key not in self.cache: + if not tags: + self.cache[key] = self.of(node) + else: + at = node + for tag in tags: + at = Node(tag, parent=at) + parent = self.inline(node, tags[:-1]) + self.cache[key] = _compute(at, parent, self.rules) + return self.cache[key] + + # -- text width --------------------------------------------------------------------- _LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)|!?\[([^\]]*)\]\[[^\]]*\]") _AUTOLINK = re.compile(r"<((?:https?|mailto):[^>\s]+)>") -_TAG = re.compile(r"]*>") -_EMPHASIS = re.compile(r"(?]*?(/?)>") +_INLINE_TAGS = {"a", "abbr", "b", "cite", "code", "del", "em", "i", "ins", "kbd", "mark", + "q", "s", "samp", "small", "span", "strong", "sub", "sup", "u", "var"} +_ESCAPED = re.compile(r"\\([!-/:-@\[-`{-~])") + + +def _runs(text: str) -> list[tuple[str, tuple[str, ...]]]: + """Inline markdown and HTML as runs of the text a reader sees, each with the + inline elements it sits in, outermost first.""" + keep: list[str] = [] + + def hold(s: str) -> str: + keep.append(s) + return f"\ue000{len(keep) - 1}\ue001" + + text = _ESCAPED.sub(lambda m: hold(html.escape(m.group(1))), text) + text = re.sub(r"(`+)(.+?)\1", lambda m: f"{hold(html.escape(m.group(2).strip()))}", + text) + text = _MEDIA.sub("", text) + text = _LINK.sub(lambda m: f"{m.group(1) or m.group(2) or ''}", text) + text = _AUTOLINK.sub(r"\1", text) + for _ in range(2): + text = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*|(?{m.group(1) or m.group(2)}", text) + text = re.sub(r"~~(?=\S)(.+?)(?<=\S)~~", r"\1", text) + text = re.sub(r"\*(?=[^\s*])(.+?)(?<=[^\s*])\*|(?{m.group(1) or m.group(2)}", text) + out: list[tuple[str, tuple[str, ...]]] = [] + stack: list[str] = [] + at = 0 + for m in _TAG.finditer(text + ""): + chunk = text[at:m.start()] if m.end() <= len(text) else text[at:] + if chunk: + chunk = re.sub(r"\ue000(\d+)\ue001", lambda h: keep[int(h.group(1))], chunk) + out.append((html.unescape(chunk), tuple(stack))) + if m.end() > len(text): + break + at = m.end() + closing, tag, selfclose = m.group(1), m.group(2).lower(), m.group(3) + if tag not in _INLINE_TAGS or selfclose: + continue + if not closing: + stack.append(tag) + elif tag in stack: + del stack[len(stack) - 1 - stack[::-1].index(tag):] + return out def plain(text: str) -> str: """Inline markdown and HTML reduced to the text a reader sees.""" - text = _MEDIA.sub("", text) - text = _LINK.sub(lambda m: m.group(1) or m.group(2) or "", text) - text = _AUTOLINK.sub(r"\1", text) - text = _TAG.sub("", text).replace("`", "") - text = _EMPHASIS.sub("", text) - text = re.sub(r"\\([!-/:-@\[-`{-~])", r"\1", text) - return html.unescape(text) + return "".join(t for t, _ in _runs(text)) + + +def _char_em(ch: str, bound: str = "high") -> float: + o = ord(ch) + if 32 <= o < 127: + return _EM[o - 32] + if ch in _EM_MORE: + return _EM_MORE[ch] + narrow, wide = _EM_UNKNOWN[bound] + return wide if unicodedata.east_asian_width(ch) in ("W", "F") else narrow def em_width(text: str) -> float: - """A text's width on one line, in em.""" - out = 0.0 - for ch in text: - o = ord(ch) - if 32 <= o < 127: - out += _EM[o - 32] - elif ch == "\u00a0": - out += _EM[0] - else: - out += _EM_WIDE if unicodedata.east_asian_width(ch) in ("W", "F") else _EM_OTHER - return out + """A text's width on one line, in em of Inter at 400.""" + return sum(_char_em(ch) for ch in text) + +# A break opportunity follows a space, an en or em dash, or a hyphen before a letter: +# Chromium breaks "great-circle" and "Jul–Sep" there, and long hyphenated URLs too. +_BREAK_AFTER = "–—" -def wrapped_lines(text: str, width: float, font_px: float = BODY, scale: float = 1.0) -> int: - """Lines a text takes in `width` px, wrapped greedily at spaces and hyphens as the - browser wraps it. A word wider than the line takes a line of its own (the holder - clips it sideways); an empty text still takes its line.""" - px = font_px * scale - space = _EM[0] * px - lines, x = 1, 0.0 - for word in re.split(r"[ \t\n]+", text.strip()): - for k, piece in enumerate(_PIECE.findall(word)): - w = em_width(piece) * px + +def _wrap(chars: list[tuple], width: float) -> list[set]: + """The lines (character, px, style) take in `width` px, wrapped greedily as the + browser wraps them, each as the set of inline styles on it. A word wider than the + line takes a line of its own (the holder clips it sideways); an empty text still + takes its line.""" + words: list[tuple[float, list[tuple[float, set]]]] = [] # (space before, pieces) + space, pieces, piece, on = 0.0, [], 0.0, set() + for k, (ch, w, st) in enumerate(chars): + if ch in " \t\n": + if pieces or piece: + words.append((space, pieces + [(piece, on)])) + pieces, piece, space, on = [], 0.0, 0.0, set() + space = space or w + continue + piece += w + on.add(st) + nxt = chars[k + 1][0] if k + 1 < len(chars) else "" + if ch in _BREAK_AFTER or (ch == "-" and nxt.isalpha()): + pieces.append((piece, on)) + piece, on = 0.0, set() + if pieces or piece: + words.append((space, pieces + [(piece, on)])) + lines, x = [set()], 0.0 + for space, parts in words: + for k, (w, styles) in enumerate(parts): add = w + (space if k == 0 and x > 0 else 0.0) if x > 0 and x + add > width: - lines += 1 + lines.append(set()) x = w else: x += add + lines[-1] |= styles return lines -# -- layout: blocks to px ----------------------------------------------------------- - - -def _table(rows: list[list[str]], width: float, t: Typography, scale: float) -> float: - """Rows of one line while the columns fit side by side. Past that, each column - gets a share of the width in proportion to its widest cell, roughly as the - browser's automatic table layout does, and a row is as tall as its tallest cell.""" - texts = [[plain(c) for c in row] for row in rows] - cols = max(len(r) for r in texts) - widest = [max(em_width(r[k]) if k < len(r) else 0.0 for r in texts) * BODY * scale - for k in range(cols)] - total = sum(widest) or 1.0 - height = t.table_spacing - for row in texts: - lines = 1 if total <= width else max( - wrapped_lines(c, width * widest[k] / total, BODY, scale) for k, c in enumerate(row)) - height += lines * t.line + t.cell_pad + t.table_spacing - return height - - -def _box(block: Block, width: float, t: Typography, scale: float) -> tuple[float, float, float]: - """(margin above, height, margin below) of one block, its text `width` px wide.""" - def lines(text: str, w: float = width, font: float = BODY, factor: float = 1.0) -> int: - return wrapped_lines(plain(text), w, font, scale * factor) - - if block.kind == "h": - font, lh, above, below = t.h[block.level] - weight = _HEADING_WEIGHT if block.level <= 3 else 1.0 - return above, lines(block.texts[0], font=font, factor=weight) * lh, below - if block.kind == "p": - return t.p_m[0], sum(lines(s) for s in block.texts) * t.line, t.p_m[1] - if block.kind == "list": - # A loose list wraps each item in a paragraph, whose bottom margin parts the - # items and joins the list's own below the last. - height = sum(lines(text, width - LIST_INDENT * (depth + 1)) * t.line - for depth, text in block.items) - gap = t.p_m[1] if block.loose else 0.0 - return t.list_m[0], height + gap * (len(block.items) - 1), max(t.list_m[1], gap) - if block.kind == "table": - return 0.0, _table(block.rows, width, t, scale), 0.0 - if block.kind == "quote": - body = sum(lines(s, width - t.quote_inset, t.quote_font) for s in block.texts) - height = body * t.quote_line + t.p_m[1] * (len(block.texts) - 1) + t.quote_pad - return t.quote_m[0], height, t.quote_m[1] - if block.kind == "code": - return t.code_m[0], block.lines * t.code_line + t.code_pad, t.code_m[1] - return t.hr[1], t.hr[0], t.hr[2] - - -def layout(blocks: list[Block], width: float, t: Typography, - scale: float = 1.0) -> tuple[float, float]: +# Inter's ascent and descent in em: where an inline box sits on the line. +_ASCENT, _DESCENT = 0.96875, 0.2421875 + + +def _line_box(block: Style, styles: set, bound: str) -> float: + """A line's height: its block's line height, or more where inline text of + another size or line height on it reaches past it (CSS 2.1 10.8). Chromium draws + such a line up to a px taller than the arithmetic says (a smaller in a + paragraph); the high bound allows a px for it.""" + def span(s: Style) -> tuple[float, float]: + lead = (s.line - (_ASCENT + _DESCENT) * s.font) / 2 + return -(_ASCENT * s.font + lead), _DESCENT * s.font + lead + + others = [s for s in styles if (s.font, s.line) != (block.font, block.line)] + if not others: + return block.line + spans = [span(block)] + [span(s) for s in others] + height = max(b for _, b in spans) - min(t for t, _ in spans) + return height + (1.0 if bound == "high" else 0.0) + + +def _measure(runs, style_of, scale: float, bound: str) -> list[tuple]: + """Each character of the runs with its width in px and its style, an inline + element's padding, border and margin on its first and last character.""" + out: list[tuple] = [] + prev: tuple[str, ...] = () + for k, (text, tags) in enumerate(runs): + st = style_of(tags) + px = st.font * _weight_factor(st.weight) * scale + nxt = runs[k + 1][1] if k + 1 < len(runs) else () + start = len(out) + for ch in text: + em = _EM_MONO if st.mono and ch not in " \t\n" else _char_em(ch, bound) + out.append((ch, em * px + st.letter + (st.word if ch == " " else 0.0), st)) + if len(out) > start: + opened = [t for d, t in enumerate(tags) if d >= len(prev) or prev[d] != t] + closed = [t for d, t in enumerate(tags) if d >= len(nxt) or nxt[d] != t] + for depth in range(len(tags)): + edge = style_of(tags[:depth + 1]) + left = edge.margin[3] + edge.padding[3] + edge.border[3] + right = edge.margin[1] + edge.padding[1] + edge.border[1] + if tags[depth] in opened and left: + ch, w, s = out[start] + out[start] = (ch, w + left, s) + if tags[depth] in closed and right: + ch, w, s = out[-1] + out[-1] = (ch, w + right, s) + prev = tags + return out + + +def wrapped_lines(text: str, width: float, font_px: float = 14.0, scale: float = 1.0) -> int: + """Lines a plain text takes in `width` px at `font_px` in Inter at 400.""" + return len(_wrap([(ch, _char_em(ch) * font_px * scale, None) for ch in text], width)) + + +# -- layout: elements to px --------------------------------------------------------- + + +def _collapse(margins: list[float]) -> float: + """Adjoining margins collapse: the largest positive one plus the most negative.""" + return max([m for m in margins if m > 0], default=0.0) + min([m for m in margins if m < 0], + default=0.0) + + +class _Layout: + def __init__(self, styles: _Styles, scale: float, bound: str): + self.styles, self.scale, self.bound = styles, scale, bound + + def lines(self, node: Node, text: str, width: float) -> list[set]: + runs = _runs(text) or [("", ())] + chars = _measure(runs, lambda tags: self.styles.inline(node, tags), self.scale, + self.bound) + return _wrap(chars, width) + + def text_height(self, node: Node, texts: list[str], width: float) -> float: + st = self.styles.of(node) + return sum(_line_box(st, line, self.bound) + for t in texts for line in self.lines(node, t, width)) + + def table(self, node: Node, width: float) -> float: + """Rows of one line while the columns fit side by side. Past that, each column + gets a share of the width in proportion to its widest cell, roughly as the + browser's automatic table layout does, and a row is as tall as its tallest cell.""" + st = self.styles.of(node) + gap = 0.0 if st.collapse else st.spacing + rows = [tr for group in node.children for tr in group.children] + cells = [[(c, self.styles.of(c)) for c in tr.children] for tr in rows] + cols = max(len(r) for r in cells) + inset = max((cs.inset for r in cells for _, cs in r), default=0.0) + room = width - gap * (cols + 1) - inset * cols + + def one_line(c: Node, cs: Style) -> float: + runs = _runs(c.texts[0]) or [("", ())] + return sum(w for _, w, _ in _measure(runs, lambda t: self.styles.inline(c, t), + self.scale, self.bound)) + + widest = [max((one_line(*r[k]) if k < len(r) else 0.0) for r in cells) + for k in range(cols)] + total = sum(widest) or 1.0 + height = gap + for r in cells: + tallest = 0.0 + for k, (c, cs) in enumerate(r): + share = room if total <= room else room * widest[k] / total + tallest = max(tallest, self.text_height(c, c.texts, share) + cs.edge_top + + cs.edge_bottom) + height += tallest + gap + return height + + def box(self, node: Node, width: float) -> tuple[list[float], float, list[float]]: + """(the margins at its top edge, its height, the margins at its bottom edge): + the margins that collapse with whatever sits above or below it.""" + st = self.styles.of(node) + inner = width - (0.0 if node.tag == "#text" else st.inset) + if node.tag == "table": + height = self.table(node, inner) + elif node.tag == "pre": + height = node.code_lines * st.line + elif node.texts or node.tag == "#text": + height = self.text_height(node, node.texts or [""], inner) + else: + height, first, last = self.stack(node.children, inner, st.edge_top == 0, + st.edge_bottom == 0) + return ([st.margin[0], *first], height + st.edge_top + st.edge_bottom, + [st.margin[2], *last]) + if node.tag == "#text": + return [], height, [] + return [st.margin[0]], height + st.edge_top + st.edge_bottom, [st.margin[2]] + + def stack(self, children: list[Node], width: float, open_top: bool, + open_bottom: bool) -> tuple[float, list[float], list[float]]: + """Children stacked with their margins collapsed: (the height to the last one's + bottom edge, the margins passed up through an open top, through an open + bottom). A closed edge (padding or a border) keeps them inside.""" + y, through_top, pending = 0.0, [], None + for child in children: + top, height, bottom = self.box(child, width) + if pending is None: + if open_top: + through_top = top + else: + y += _collapse(top) + else: + y += _collapse(pending + top) + y += height + pending = bottom + if pending is None: + return 0.0, [], [] + if open_bottom: + return y, through_top, pending + return y + _collapse(pending), through_top, [] + + +def layout(holder: Node, width: float, styles: _Styles, scale: float = 1.0, + bound: str = "high") -> tuple[float, float]: """(how far down the last line reaches, the height that shows everything with no inner scrollbar), in px from the holder's top edge, for text `width` px wide. - Margins between blocks collapse to the larger; the first block's margin above and - the last one's below stay inside the padding.""" - y, below = PAD, None - for block in blocks: - above, height, after = _box(block, width, t, scale) - y += (above if below is None else max(below, above)) + height - below = after - return y, y + (below or 0.0) + PAD + The holder is a scroll container: its padding keeps the first block's margin + above and the last one's below inside it.""" + lay = _Layout(styles, scale, bound) + text, _, below = lay.stack(holder.children, width, False, True) + border = styles.of(holder).border + top = PAD + border[0] + text + return top, top + _collapse(below) + PAD + border[2] # -- the estimate ------------------------------------------------------------------- @@ -407,21 +1194,38 @@ class Fit: @property def units(self) -> float: - """need_high in spec units, up to a whole 8 px grid row (a fifth of a unit).""" - return math.ceil(round(self.need_high / 8, 6)) / 5 - - -def estimate(markdown: str, cols: int, filter_bar: bool | None = False) -> Fit: - """How a markdown text fits a block `cols` twelfths wide at a 1600 px viewport, - beside the open filter bar or not. None says the bar may be there or not: the low - bound then wraps without it and the high bound beside it.""" - blocks, media = parse(markdown) - wide = text_width(cols, max(t.grid_filter_bar if filter_bar else t.grid - for t in TYPOGRAPHY)) - lows = [layout(blocks, wide, t, LOW_SCALE) for t in TYPOGRAPHY] - # Windows narrows the grid by the page's own scrollbar too. - highs = [layout(blocks, text_width(cols, (t.grid if filter_bar is False - else t.grid_filter_bar) - SCROLLBAR) - SCROLLBAR, - t, HIGH_SCALE)[1] - for t in TYPOGRAPHY] + """need_high in spec units, up to a whole 8 px grid row (a fifth of a unit). + Chromium's scroll height is whole px, so a block under half a px over doesn't + scroll: an 11.5 px footer line takes 48.1 px and fits 48.""" + return math.ceil(round((self.need_high - SNAP) / 8, 6)) / 5 + + def short(self, box: float) -> bool: + """Whether the block needs more than a box this tall: it scrolls.""" + return self.need_low > box + SNAP + + +def releases_from(floor: tuple[int, int, int] | None) -> tuple[Release, ...]: + """The releases a spec can go to, given the oldest one it needs.""" + return tuple(r for r in RELEASES if floor is None or r.newest >= floor) or RELEASES[-1:] + + +def estimate(markdown: str, cols: int, filter_bar: bool | None = False, + css: str | Stylesheet | None = None, viewport: float = VIEWPORT, + releases: tuple[Release, ...] = RELEASES) -> Fit: + """How a markdown text fits a block `cols` twelfths wide in a window `viewport` + px wide, beside the open vertical filter bar or not, under the dashboard's CSS. + None says the bar may be there or not: the low bound then wraps without it and + the high bound beside it.""" + sheet = css if isinstance(css, Stylesheet) else read_css(css) + holder = tree(markdown) + media = parse(markdown)[1] + lows, highs = [], [] + wide = max(r.grid(viewport, bool(filter_bar)) for r in releases) + for r in releases: + styles = _Styles(r, sheet) + lows.append(layout(holder, text_width(cols, wide), styles, LOW_SCALE, "low")) + # Windows narrows the grid by the page's own scrollbar too. + grid = r.grid(viewport, filter_bar is not False) - SCROLLBAR + highs.append(layout(holder, text_width(cols, grid) - SCROLLBAR, styles, HIGH_SCALE, + "high")[1]) return Fit(min(a for a, _ in lows), min(b for _, b in lows), max(highs), media) diff --git a/chartwright/design/model.py b/chartwright/design/model.py index a1b177c..9338c74 100644 --- a/chartwright/design/model.py +++ b/chartwright/design/model.py @@ -27,8 +27,9 @@ # deleted), and default.stale-record: a fixable finding for a renamed or removed chart's # design.filled entry, which now validates. Both change what `--fix` writes. # "11" = size.markdown-fit, a new warn-severity rule for markdown text cut off by its -# block, and layout.markdown-height fixes a one-line block to the height its line takes -# (1.6 to 2.4 units) instead of 2, from the same estimate. +# block in a 1440 px window, under the dashboard's own CSS, and layout.markdown-height +# fixes a one-line block to the height its line takes (1.6 to 2.4 units) instead of 2, +# from the same estimate. DESIGN_BRAIN_VERSION = "11" KPI_TYPES = {"big_number_total", "big_number_trend"} diff --git a/chartwright/design/rules.py b/chartwright/design/rules.py index c35b939..0273db8 100644 --- a/chartwright/design/rules.py +++ b/chartwright/design/rules.py @@ -30,7 +30,8 @@ row_items, without_superset_defaults, ) -from .markdown_fit import Fit, box_px, estimate +from ..versions import gated_fields_used, parse_version +from .markdown_fit import VIEWPORT, Fit, box_px, estimate, read_css, releases_from from .model import AXIS_TYPES, KPI_TYPES, TIMESERIES_TYPES, Finding, RuleContext, rule # -- size: minimum readable geometry ------------------------------------------ @@ -1222,18 +1223,24 @@ def _markdown_blocks(ctx: RuleContext): def _markdown_estimate(ctx: RuleContext, item) -> Fit: - """How the block's text fits at its width. The filter bar narrows the grid when it - opens by default: on the left, beside a dashboard with native filters. A horizontal - bar sits above the grid, but 4.1.4 and 5.0.0 draw it on the left without the - HORIZONTAL_FILTER_BAR flag, so that case is bounded both ways.""" + """How the block's text fits at its width under the dashboard's CSS, on the + releases the spec can go to: a field only newer releases take (a theme, say) + leaves out the older ones. The filter bar narrows the grid when it opens by + default: on the left, beside a dashboard with native filters. A horizontal bar + sits above the grid, but 4.1.4 and 5.0.0 draw it on the left without the + HORIZONTAL_FILTER_BAR flag, so on them that case is bounded both ways.""" d = ctx.spec.dashboard - bar = (bool(ctx.spec.filters) if d.filter_bar_orientation != "horizontal" - else None if ctx.spec.filters else False) - return estimate(item.markdown, ctx.spec.resolved_item_width(item), bar) - - -_TYPE_CSS = re.compile(r"(? 1 else "" + caveat = (f"; the dashboard's CSS also styles markdown in a way this estimate " + f"doesn't read ({unread[0]}{more}), so the height may be off") + if d.theme: + caveat += (f"; this assumes Superset's own text sizes and spacing, which the theme " + f"{d.theme!r} may change") owned = ctx.standard_rows() for addr, label, ri, ii, item in _markdown_blocks(ctx): h = item.height or DEFAULT_HEIGHT["markdown"] box = box_px(h) fit = _markdown_estimate(ctx, item) - if fit.need_low <= box: + if not fit.short(box): continue cut = fit.text_low - box >= 8 standard = addr in ("header", "footer") and (addr, ri) in owned @@ -1312,7 +1324,8 @@ def markdown_fit(ctx: RuleContext): tail = f"; raise the height{' or widen it' if w < 12 else ''}" yield Finding( "size.markdown-fit", "warn" if cut else "info", None, f"{label} row {ri}", - f"{lead}; it needs {need} at {w}/12, has {h:g}{tail}{caveat}", + f"{lead}; it needs {need} at {w}/12 in a {VIEWPORT} px wide window, has {h:g}" + f"{tail}{caveat}", fix=({"md": [addr, ri, ii], "set": {"height": fit.units}} if not (standard or fit.media or fit.units > 100) else None), ) diff --git a/docs/DESIGN-BRAIN.md b/docs/DESIGN-BRAIN.md index f1b1ac7..0b69ed3 100644 --- a/docs/DESIGN-BRAIN.md +++ b/docs/DESIGN-BRAIN.md @@ -1344,10 +1344,20 @@ two dates. Superset's `Last year` spans 365 days and still takes the fill. ### Markdown blocks -Measured on 2026-10-05 the same way, by `tools/record_markdown_fit.py`: 17 texts -at 12, 8, 6, 4 and 3 twelfths on each release, the longest beside the filter bar -too, and each at 6/12 at three heights with Windows-style scrollbars on. The -fixture is `tests/fixtures/markdown_fit/measurements.json`. +Measured on 2026-10-05 the same way, by `tools/record_markdown_fit.py`, in windows +1600 and 1440 px wide, on each release: + +- 17 texts at 12, 8, 6, 4 and 3 twelfths, and the longest beside the filter bar too; +- the 14 markdown blocks of a sales-review dashboard under its own CSS (`STYLED` in + the tool: a title, section captions with colour keys, a list, notes in sections, + a footer); +- every element's computed style with no CSS, with each property the estimate reads + set on each element from `.dashboard-markdown `, and under that + dashboard's CSS; +- in the 1440 px window with Windows-style scrollbars on, each text and block at the + height the fix writes and at heights that cut it off. + +The fixture is `tests/fixtures/markdown_fit/measurements.json`. Superset draws a markdown block's text in a holder with 16 px of padding on every side. What doesn't fit scrolls inside the holder (`overflow-y: auto`). macOS draws @@ -1358,36 +1368,76 @@ row wrap text exactly as wide as layout rows do. | | 4.1.4 and 5.0.0 | 6.1.0 | |---|---|---| -| paragraph | 14 px Inter, 22 px lines, 8 px below | the same | -| `#` | 28 px, 39.2 px line, 12 px above and below | the same | +| paragraph | 14 px Inter, 22 px lines (1.5715), 8 px below | the same | +| `#` | 28 px, 39.2 px line (1.4), 12 px above and below | the same | | `##` | 21 px, 29.4 px line, 12 px above, 8 below | 20 px, 28 px line | | `###` to `#####` | 16 px, 22.4 px line, 8 px above, 4 below | the same | +| `######` | 12 px, 16.8 px line, 8 px above, 4 below | the same | | list | indented 40 px, 9.5 px below | 14 px above and below | | table row | 22 px | 24 px, 2 px apart | | block quote | 17.5 px text on 27.5 px lines, padded 19 px, 19 px below | 14 px text, inset 40 px each side, 14 px above and below | | code block | 18.2 px lines, padded 20 px, 9.5 px below | 22 px lines, 14 px above and below | -| grid at 1600 px | 1536 px, 1276 px beside the filter bar | 1536 px, 1308 px beside it | +| grid | the window less 64 px; 260 px less beside the filter bar | 228 px less beside it | So a strip pays 32 px of padding plus its line's margins before its text: a line of text needs 62 px (1.6 units), `###` 66 px (1.8), `##` 81.4 px (2.2; 80 px on 6.1.0) and `#` 95.2 px (2.4). That is the "about 60 px of chrome" that cut strips under 2 units off, and `size.markdown-fit` counts it with everything else. -`chartwright/design/markdown_fit.py` parses the markdown into blocks, wraps each -line word by word with Inter's measured character widths, and stacks the blocks -with CSS's collapsing margins. At each release's own styles it matched 265 of the -270 measured heights within 1.5 px. The other five were the long text at narrow -widths, where the shared character table (the widest of each character at any -release) wraps a line or three more than 5.0.0 or 6.1.0 does. Two bounds come out: +**The dashboard's CSS.** Every `.dashboard-markdown ` rule beat Superset's +own styles on every release: a font size, a line height, margins and padding set on +each element all took. The exceptions are an h6's font size and the weight of h4 to +h6, which the markdown component sets inside its own class (`Markdown.jsx:92-104` at +4.1.4 and 5.0.0, `Markdown/Markdown.tsx:112-124` at 6.1.0); every release kept a +12 px h6. Superset's line heights are factors (1.4 for headings, 1.5715 for the +body), so a font size the CSS sets brings its own line height: a 17 px `####` +draws 23.8 px lines. + +The estimate reads the CSS in the spec. It applies rules on `.dashboard-markdown` +and its elements, with descendant, child and sibling combinators (`h4 + p`) and +`:first-child` and `:last-child`, in cascade order: `!important`, then specificity, +then source order, over Superset's own styles. It reads font sizes, line heights, +weights, letter and word spacing, margins, padding, borders and a table's border +spacing, in px, em, rem or a percentage. The styles it computed matched every +element's computed style on every release, with no CSS, with every property set +on every element, and under the styled dashboard's CSS: 570 elements in all. + +What it doesn't read that could size markdown, the finding names: a font family +other than Inter or a monospace font, attribute selectors and pseudo-elements +(colour keys drawn by `[itemprop]::before` add 18 px each to a line in the styled +dashboard), rules inside `@media`, a value in `var()` or `calc()`, the block's own +padding, and a rule with no class, such as `p { margin: 0 }`, whose order against +Superset's own element rules the spec can't show. + +A line is as tall as the inline text on it needs (CSS 2.1, 10.8). Chromium draws a +line holding smaller inline text, such as an `` set smaller than its paragraph, +up to a px taller than that arithmetic gives; the low bound takes the arithmetic, +the high one a px more. + +**The window.** The rule assumes a window 1440 px wide, a common 13-inch laptop's +screen; its grid is 1376 px wide. A wider window wraps fewer lines, so a height that +fits there fits in every wider window. The styled dashboard's notes needed 583 to +626 px in a 1600 px window and 626 to 636 px at 1440. + +**The estimate.** `chartwright/design/markdown_fit.py` parses the markdown into the +elements react-markdown draws, computes each one's style, wraps each line word by +word with Inter's measured character widths, and stacks the blocks with CSS's +collapsing margins. At each release's own styles it matched 582 of the 624 +measured heights within 1.5 px. The rest are long texts, where the shared character +table (the widest of each character at any release; 4.1.4 ships Inter 3, the others +Inter 4) wraps a line or a few more than a release does, or now and then one fewer. +Two bounds come out: - The low bound takes the more compact release, the widest grid and characters 3% - narrower. It never exceeded a measured height: 80 of 90 it matched, the rest it - undershot by a line or two, or 3.5% (132 px) on the long text at 3/12. + narrower. It never exceeded a measured height: of 208 (90 texts and 14 styled + blocks in each window) it matched 188 within a px, and undershot the rest by a + line or a few, at most 3.5% on the long text at 3/12 beside the filter bar. - The high bound takes the taller release, characters 1% wider, and two 17 px scrollbars: the page's own, which narrows the grid on Windows, and one in the - block. It never fell short: 75 of 90 it matched, and at 6/12 and wider it allowed - at most 44 px, or 110 px beside the filter bar. Narrow blocks of long text cost - the most, up to 10% at 3/12 beside the filter bar. + block. It never fell short: it matched 170 of the 208 within a px, and at 6/12 + and wider, without the filter bar, allowed at most 44 px in the 1600 px window + and 110 px (5.7%) in the 1440 px one. Narrow blocks of long text cost the most, + up to 13% at 3/12. The second scrollbar is there because of one render. A block that overflows while it loads (before its web font arrives, say) draws a scrollbar, and the narrower @@ -1399,10 +1449,24 @@ scrolled on any release. `size.markdown-fit` warns when the low bound's text runs 8 px (a grid row) or more past the bottom edge, so letters are cut on every release. A block whose last line only touches the edge, or whose padding doesn't fit, gets an info: it scrolls a few -px, and Windows draws a scrollbar in it. `--fix` raises the block to the high bound, -and `layout.markdown-height` now shrinks a one-line block to the height its line -takes on every release instead of a flat 2 units, which cut a `#` heading's -padding off. +px, and Windows draws a scrollbar in it. A block under half a px over doesn't +scroll (a scroll height is whole px): the styled dashboard's footer, one 11.5 px +`#####` line, takes 48.1 px and fits its 48. `--fix` raises the block to the high bound, and +`layout.markdown-height` shrinks a one-line block to the height its line takes on +every release instead of a flat 2 units, which cut a `#` heading's padding off. + +The estimate covers the releases the spec can go to: a field only 6.0.0 or later +takes, such as a theme, leaves out 4.1.4 and 5.0.0. Then a horizontal filter bar +sits across the top, as 6.1.0 draws it; on 4.1.4 and 5.0.0 it needs a feature flag +and is drawn on the left without it, so there the bar is bounded both ways. + +In the 1440 px window, the styled dashboard's blocks at heights that cut them off +scrolled on every release: the notes 26 to 36 px over at 15 units, the list 21 to +31 px over at 13, and the captions 8 to 28 px over at 2 to 2.8 units. The rule +reports each. It calls the notes and two three-line captions cut off, and the rest +scrolling: only their padding or the edge of their last line is past the bottom, +or, for the list, letters are cut on some releases only. The footer, exactly full, +and the title fit, and it leaves them alone. ### Not measured @@ -1419,11 +1483,13 @@ padding off. - The other thresholds this page names: axis heights, pie and heatmap geometry, the horizontal-bar height per bar in `size.hbar-window`, and `vbar_max_categories`. -- Markdown under another font, size or spacing: a theme, dashboard CSS that sets - type (both named in the finding), or a browser that blocks web fonts and falls - back to Helvetica or Arial. Characters outside ASCII take rough widths (0.6 em, - 1 em for East Asian wide ones). Images and embeds add a height the text can't - tell, so a block holding one gets no fix. +- Markdown under a theme, which can change Superset's own text sizes and spacing + (the finding names it), CSS the estimate doesn't read (named too), other window + widths, or a browser that blocks web fonts and falls back to Helvetica or Arial. + Characters outside the measured tables (ASCII, Latin-1, common punctuation and + symbols) take a narrow width in the low bound and a wide one in the high: 0.3 and + 0.8 em, or 0.9 and 1 em for East Asian wide ones. Images and embeds add a height + the text can't tell, so a block holding one gets no fix. `search_min_rows` (20) and `page_min_rows` (3) are usability judgement, not pixel facts, and stay so. On page sizes, Nielsen recommends that "it's usually diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8092647..43e1a6e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -66,7 +66,7 @@ into one. Everything below works from that one file. ## The Design Brain - **Codified BI/UX Practice**: An optional layer holding what Few, Tufte and IBCS teach about reading a dashboard, plus the Superset rendering quirks that break it, on by default and off with one flag ([full reference](DESIGN-BRAIN.md)). - **The Brief**: `chartwright brief` prints design guidance for the AI (or you) to read before writing a spec: budgets, chart choice, and composition, tuned to an audience preset (`executive`, `analytical`, or `operational`). -- **The Critic**: `chartwright advise` reviews a finished spec: readable minimum sizes, markdown blocks tall enough for their text, layout composition (KPIs first, fold budgets, row density), chart-choice limits, narrative polish. `--fix` applies the safe geometry subset; `--profile` adds data-aware checks (a time axis on a non-temporal column, a pie hiding 40 slices); `--chart` looks at one chart. +- **The Critic**: `chartwright advise` reviews a finished spec: readable minimum sizes, markdown blocks tall enough for their text at the sizes the dashboard's CSS sets, layout composition (KPIs first, fold budgets, row density), chart-choice limits, narrative polish. `--fix` applies the safe geometry subset; `--profile` adds data-aware checks (a time axis on a non-temporal column, a pie hiding 40 slices); `--chart` looks at one chart. - **Design Defaults in the Spec**: Leave the small display choices unset and `advise --fix` writes sensible values into the spec, where the diff shows them; `chartwright explain` says where each value came from and how to change it. - Monthly time axes labelled `Sep 2026`, counts shown as `12,345`, "vs previous month" after a trendline KPI's change. - Tables that page by what fits their panel, a search box on long raw tables, no cell bars behind id, code, year or zip columns. diff --git a/docs/LAYOUT-GUIDE.md b/docs/LAYOUT-GUIDE.md index 99f0cc1..1da3ece 100644 --- a/docs/LAYOUT-GUIDE.md +++ b/docs/LAYOUT-GUIDE.md @@ -210,8 +210,9 @@ Markdown heights take fifths of a unit, one Superset grid row (8 px) each, so a slim strip fits exactly: `1.6` is 64 px. Superset pads a text block 16 px on every side, so the block needs its content's height plus 32 px, or it scrolls, and it draws no block under 1 unit (40 px). `chartwright advise` estimates the -height each block's text takes at its width and names a block too short for it -(`size.markdown-fit`); `advise --fix` raises the block. +height each block's text takes at its width, in the sizes the dashboard's CSS +sets, and names a block too short for it (`size.markdown-fit`); `advise --fix` +raises the block. A footer row may hold charts as well as markdown; each chart is still placed exactly once. Decompile reads any rows below a dashboard's tabs as its footer, diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index b75a05c..59c193c 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -35,12 +35,12 @@ injection. | Layer | Proves | Where it runs | |---|---|---| -| Offline suite (1689 tests, 68 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | +| Offline suite (1875 tests, 68 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | | Chart-option contract | Every emitted chart option is declared by each version's plugin source | every PR and push to main | | Live guarantee check | Three specs applied (every chart type, every display control, every dashboard and filter control), per-chart data check, ids stable across re-apply | every PR and push to main, all 3 versions | | Standards content round trip | `standards apply` writes an org footer, a team header and two CSS blocks into a spec; it applies, `plan` stays clean, decompile reads the CSS markers and every managed row back, `--claim` rebuilds the record, re-apply stays clean | every PR and push to main, all 3 versions | | Locked text visible | `standards verify-visible` in headless Chromium: the standards fixture's locked footer shows, and the same dashboard with near-white footer CSS is caught. Installing the browser may fail without failing the job (the check is then skipped); once installed, a failing check fails it | every PR and push to main, all 3 versions | -| Markdown fit | 17 markdown texts at 6/12 in headless Chromium with Windows-style scrollbars: at the height `advise --fix` gives each, none scrolls; a unit less, each block `size.markdown-fit` calls cut off is, and each it reports scrolls. Skipped, like the row above, when the browser fails to install | every PR and push to main, all 3 versions | +| Markdown fit | 17 markdown texts at 6/12, and a dashboard's own 14 blocks under its own CSS, in headless Chromium in a 1440 px window with Windows-style scrollbars: at the height `advise --fix` gives each, none scrolls; a unit less, each block `size.markdown-fit` calls cut off is, and each it reports scrolls. Skipped, like the row above, when the browser fails to install | every PR and push to main, all 3 versions | | Lifecycle soak | 500 randomized edit cycles with invariants held | 500 cycles on 6.1.0 and 4.1.4 before release; 25 cycles per version on every PR and push to main | | Second-writer scenarios | Stale-tab overwrites detected by `plan`, repaired by `apply` | every PR and push to main, all 3 versions | | Fault injection | A typed failure at every stage boundary; complete restore | every PR and push to main, all 3 versions | @@ -177,15 +177,20 @@ collects, the same "generated, not hand-maintained" rule the rule table in live test runs the real browser when `CHARTWRIGHT_VISIBLE_LIVE` names a Superset. - **Markdown fit** (`test_markdown_fit.py`): what 4.1.4, 5.0.0 and 6.1.0 - drew in headless Chromium for 17 markdown texts at five widths, and beside - the filter bar (`fixtures/markdown_fit/measurements.json`): the estimate's low - bound never exceeds any of those heights and its high bound never falls short, - each within a set margin; at the fix's height no block scrolled with - Windows-style scrollbars on, a block the rule calls cut off was, and one it - reports at all scrolled; the grid widths it assumes are the ones drawn, in - rows, header, footer and sub-tabs; the parser reads each markdown shape; and - `size.markdown-fit` warns, informs, fixes in every container and stays - silent as documented, never resizing a standard's row. + drew in headless Chromium, in windows 1600 and 1440 px wide, for 17 markdown + texts at five widths, beside the filter bar, and for a dashboard's own 14 + blocks under its own CSS (`fixtures/markdown_fit/measurements.json`): the + estimate's low bound never exceeds any of those heights and its high bound + never falls short, each within a set margin; at the fix's height no block + scrolled with Windows-style scrollbars on, a block the rule calls cut off + was, and one it reports at all scrolled; the CSS cascade it computes gives + every element each release drew its computed size, line height, margins, + padding, borders and weight, with no CSS, with every property set on every + element, and under the dashboard's CSS; the CSS it can't read is named; the + grid widths it assumes are the ones drawn, in rows, header, footer and + sub-tabs; the parser reads each markdown shape; and `size.markdown-fit` + warns, informs, fixes in every container and stays silent as documented, + never resizing a standard's row. - **Dashboard owners** (`test_dashboard_owners.py`): owners never reach the bundle; usernames resolve where the security API answers and emails everywhere, an unknown or ambiguous owner is a resolve-stage error with diff --git a/tests/fixtures/markdown_fit/measurements.json b/tests/fixtures/markdown_fit/measurements.json index 705b3cd..76ac8e6 100644 --- a/tests/fixtures/markdown_fit/measurements.json +++ b/tests/fixtures/markdown_fit/measurements.json @@ -1,5 +1,5 @@ { - "about": "Markdown blocks Superset drew in headless Chromium at a 1600 x 1200 viewport, in px; written by tools/record_markdown_fit.py, which says what each number is.", + "about": "Markdown blocks Superset drew in headless Chromium, in px; written by tools/record_markdown_fit.py, which says what each number is.", "corpus": { "about": "## How to read this dashboard\n\nEvery figure on these tabs comes from the operations warehouse, refreshed nightly at 02:00 UTC. A day closes when the last flight of the schedule lands, so the most recent day can move until the following morning. Figures are shown in the network's reporting currency and converted at the month's average rate, never the daily rate, so a month never changes once it has closed. Where a chart says \"network\" it means every route the schedule operated in the period, including seasonal routes, and excludes charters, ferry flights and positioning flights, which carry no revenue passengers.\n\nHover over any point to see its exact value, and use the filter bar on the left to narrow every chart at once. A filter applies to every tab, not only the one in view. The period selector defaults to the last full quarter; widen it to compare a season with the same season a year earlier.\n\n## Punctuality\n\n- **On-time departure**: the share of departures that leave the gate within 15 minutes of the scheduled time. Cancelled flights are excluded from both sides of the ratio; diverted flights count by their original schedule.\n- **On-time arrival**: the share of arrivals that reach the gate within 15 minutes of the scheduled time. This is the measure the regulator publishes and the one the monthly report leads with.\n- **Average delay**: the mean minutes late across flights that were late, measured gate to gate. Early flights count as zero, never as negative minutes, so an early arrival cannot hide a late one.\n- **Delay cause**: the first code the station controller recorded. Reactionary delays, caused by a late inbound aircraft, are grouped separately so that a single morning disruption does not read as a day of independent failures.\n- **Completion factor**: flights operated as a share of flights scheduled two days before departure. Schedule changes made earlier than that are planning, not disruption, and do not count against it.\n\n## Capacity and demand\n\nCapacity is measured in available seat kilometres: every seat offered, multiplied by the great-circle distance of the sector it was offered on. Demand is measured in revenue passenger kilometres: every fare-paying passenger, multiplied by the same distance. Staff travel, infants without a seat and award tickets redeemed at zero fare are not revenue passengers.\n\nLoad factor is demand divided by capacity. It is shown both for the network and per route, and the route figures are weighted by distance, so a long sector moves the network average more than a short one. A route with a load factor above 90 percent for four consecutive weeks is flagged for a capacity review; one below 60 percent for the same period is flagged for a schedule review. Both flags appear in the route table on the Network tab and clear themselves once the route returns to the normal band.\n\nSeat counts come from the aircraft actually operated, not the aircraft planned, so an equipment swap on the day changes the capacity of that flight. Blocked seats, such as crew rest seats on long sectors, are removed from the count.\n\n## Revenue and yield\n\nPassenger revenue is the fare the passenger paid for each sector, net of taxes, airport charges and fuel surcharges, which pass through to third parties. A ticket covering several sectors is prorated across them by distance. Ancillary revenue, such as checked bags, seat selection and onboard sales, is reported separately and never mixed into yield.\n\nYield is passenger revenue per revenue passenger kilometre, shown in cents. Unit revenue is passenger revenue per available seat kilometre, which falls when seats fly empty even if every passenger paid more. Read the two together: rising yield with falling unit revenue usually means fares went up faster than the planes filled.\n\nRefunds are booked against the month of the original sale, not the month of the refund, so a large refund wave can revise a closed month's revenue. The revision is noted in the monthly summary and the chart marks the revised point with a hollow marker.\n\n## Cost and efficiency\n\nUnit cost is operating cost per available seat kilometre, shown in cents, with fuel shown both inside and outside the total because fuel moves with markets the airline does not control. Operating cost includes crew, maintenance, airport and navigation charges, aircraft ownership and overheads, and excludes one-off items such as restructuring charges, which are listed in the notes of the month in which they occur.\n\nFuel burn per block hour is measured from the aircraft's own fuel records, not from the flight plan, and compared with the same aircraft type's fleet average. A tail that burns more than three percent above its type's average for a month is listed on the Fleet tab for an engineering review.\n\nAircraft utilisation is block hours per aircraft per day, counting only aircraft in the operating fleet. Aircraft in heavy maintenance, in storage or on lease to another operator are left out, so a fleet with many aircraft parked does not look less productive than it is.\n\n## Data quality and changes\n\nEach tab shows the time of its last successful refresh in the footer. If a source system is late, the affected charts show the previous day's figures and a note says which source is behind. A missing day is never filled in or estimated; it stays visibly empty until the source delivers it.\n\nDefinitions change rarely and only at the start of a quarter. When one does, the change is listed here with its date, the previous definition is kept for one year, and charts that span the change mark it with a vertical line so that a step in the series is not mistaken for a change in performance.\n\nQuestions about a figure go to the analytics team through the request form linked in the header. Please include the tab, the chart title and the filters you had set, so the figure can be reproduced exactly.\n", "br-html": "Figures refresh nightly.
The most recent day can move.

Questions go to the analytics team.", @@ -21,2324 +21,5775 @@ }, "releases": { "4.1.4": { - "blocks": { - "about@12": { - "content_w": 1504, - "need": 1156, - "text": 1132.3 - }, - "about@3": { - "content_w": 340, - "need": 3246, - "text": 3222.3 - }, - "about@4": { - "content_w": 469, - "need": 2432, - "text": 2408.3 - }, - "about@6": { - "content_w": 728, - "need": 1772, - "text": 1748.3 - }, - "about@8": { - "content_w": 987, - "need": 1442, - "text": 1418.3 - }, - "br-html@12": { - "content_w": 1504, - "need": 128, - "text": 104 - }, - "br-html@3": { - "content_w": 340, - "need": 128, - "text": 104 - }, - "br-html@4": { - "content_w": 469, - "need": 128, - "text": 104 - }, - "br-html@6": { - "content_w": 728, - "need": 128, - "text": 104 - }, - "br-html@8": { - "content_w": 987, - "need": 128, - "text": 104 - }, - "caps-digits@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "caps-digits@3": { - "content_w": 340, - "need": 150, - "text": 126 - }, - "caps-digits@4": { - "content_w": 469, - "need": 128, - "text": 104 - }, - "caps-digits@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "caps-digits@8": { - "content_w": 987, - "need": 84, - "text": 60 - }, - "long-word@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "long-word@3": { - "content_w": 340, - "need": 128, - "text": 104 - }, - "long-word@4": { - "content_w": 469, - "need": 106, - "text": 82 - }, - "long-word@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "long-word@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "loose-list@12": { - "content_w": 1504, - "need": 124, - "text": 98 - }, - "loose-list@3": { - "content_w": 340, - "need": 146, - "text": 120 - }, - "loose-list@4": { - "content_w": 469, - "need": 124, - "text": 98 - }, - "loose-list@6": { - "content_w": 728, - "need": 124, - "text": 98 - }, - "loose-list@8": { - "content_w": 987, - "need": 124, - "text": 98 - }, - "nested-list@12": { - "content_w": 1504, - "need": 196, - "text": 170 - }, - "nested-list@3": { - "content_w": 340, - "need": 196, - "text": 170 - }, - "nested-list@4": { - "content_w": 469, - "need": 196, - "text": 170 - }, - "nested-list@6": { - "content_w": 728, - "need": 196, - "text": 170 - }, - "nested-list@8": { - "content_w": 987, - "need": 196, - "text": 170 - }, - "quote-code@12": { - "content_w": 1504, - "need": 215, - "text": 190.9 - }, - "quote-code@3": { - "content_w": 340, - "need": 242, - "text": 218.4 - }, - "quote-code@4": { - "content_w": 469, - "need": 242, - "text": 218.4 - }, - "quote-code@6": { - "content_w": 728, - "need": 215, - "text": 190.9 - }, - "quote-code@8": { - "content_w": 987, - "need": 215, - "text": 190.9 - }, - "section@12": { - "content_w": 1504, - "need": 187, - "text": 161.4 - }, - "section@3": { - "content_w": 340, - "need": 297, - "text": 271.4 - }, - "section@4": { - "content_w": 469, - "need": 231, - "text": 205.4 - }, - "section@6": { - "content_w": 728, - "need": 209, - "text": 183.4 - }, - "section@8": { - "content_w": 987, - "need": 209, - "text": 183.4 - }, - "setext@12": { - "content_w": 1504, - "need": 163, - "text": 138.6 - }, - "setext@3": { - "content_w": 340, - "need": 163, - "text": 138.6 - }, - "setext@4": { - "content_w": 469, - "need": 163, - "text": 138.6 - }, - "setext@6": { - "content_w": 728, - "need": 163, - "text": 138.6 - }, - "setext@8": { - "content_w": 987, - "need": 163, - "text": 138.6 - }, - "strip-bold@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "strip-bold@3": { - "content_w": 340, - "need": 62, - "text": 38 - }, - "strip-bold@4": { - "content_w": 469, - "need": 62, - "text": 38 - }, - "strip-bold@6": { - "content_w": 728, - "need": 62, - "text": 38 - }, - "strip-bold@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "strip-h1@12": { - "content_w": 1504, - "need": 95, - "text": 67.2 - }, - "strip-h1@3": { - "content_w": 340, - "need": 95, - "text": 67.2 - }, - "strip-h1@4": { - "content_w": 469, - "need": 95, - "text": 67.2 - }, - "strip-h1@6": { - "content_w": 728, - "need": 95, - "text": 67.2 - }, - "strip-h1@8": { - "content_w": 987, - "need": 95, - "text": 67.2 - }, - "strip-h2@12": { - "content_w": 1504, - "need": 81, - "text": 57.4 - }, - "strip-h2@3": { - "content_w": 340, - "need": 81, - "text": 57.4 - }, - "strip-h2@4": { - "content_w": 469, - "need": 81, - "text": 57.4 - }, - "strip-h2@6": { - "content_w": 728, - "need": 81, - "text": 57.4 - }, - "strip-h2@8": { - "content_w": 987, - "need": 81, - "text": 57.4 - }, - "strip-h3@12": { - "content_w": 1504, - "need": 66, - "text": 46.4 - }, - "strip-h3@3": { - "content_w": 340, - "need": 66, - "text": 46.4 - }, - "strip-h3@4": { - "content_w": 469, - "need": 66, - "text": 46.4 - }, - "strip-h3@6": { - "content_w": 728, - "need": 66, - "text": 46.4 - }, - "strip-h3@8": { - "content_w": 987, - "need": 66, - "text": 46.4 - }, - "strip-h4@12": { - "content_w": 1504, - "need": 66, - "text": 46.4 - }, - "strip-h4@3": { - "content_w": 340, - "need": 66, - "text": 46.4 - }, - "strip-h4@4": { - "content_w": 469, - "need": 66, - "text": 46.4 - }, - "strip-h4@6": { - "content_w": 728, - "need": 66, - "text": 46.4 - }, - "strip-h4@8": { - "content_w": 987, - "need": 66, - "text": 46.4 - }, - "strip-text@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "strip-text@3": { - "content_w": 340, - "need": 62, - "text": 38 - }, - "strip-text@4": { - "content_w": 469, - "need": 62, - "text": 38 - }, - "strip-text@6": { - "content_w": 728, - "need": 62, - "text": 38 - }, - "strip-text@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "table@12": { - "content_w": 1504, - "need": 164, - "text": 148 - }, - "table@3": { - "content_w": 340, - "need": 230, - "text": 214 - }, - "table@4": { - "content_w": 469, - "need": 164, - "text": 148 - }, - "table@6": { - "content_w": 728, - "need": 164, - "text": 148 - }, - "table@8": { - "content_w": 987, - "need": 164, - "text": 148 - }, - "two-lines@12": { - "content_w": 1504, - "need": 84, - "text": 60 - }, - "two-lines@3": { - "content_w": 340, - "need": 84, - "text": 60 - }, - "two-lines@4": { - "content_w": 469, - "need": 84, - "text": 60 - }, - "two-lines@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "two-lines@8": { - "content_w": 987, - "need": 84, - "text": 60 - } - }, "containers": { "footer@4": 469, "header@12": 1504, "sub-tab@6": 728 }, - "filter_bar": { - "about@12": { - "content_w": 1244, - "need": 1332, - "text": 1308.3 - }, - "about@3": { - "content_w": 275, - "need": 3980, - "text": 3955.7 - }, - "about@4": { - "content_w": 383, - "need": 2916, - "text": 2892.3 - }, - "about@6": { - "content_w": 598, - "need": 2036, - "text": 2012.3 - }, - "about@8": { - "content_w": 813, - "need": 1618, - "text": 1594.3 + "styles": { + "default": { + "blocks": [ + ["h1", 28, 39.2, 12, 12, 0, 0, 0, 0, 0, 600], + ["h2", 21, 29.4, 12, 8, 0, 0, 0, 0, 0, 600], + ["h3", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 600], + ["h4", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 400], + ["h5", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 400], + ["h6", 12, 16.8, 8, 4, 0, 0, 0, 0, 0, 400], + ["p", 14, 22.001, 0, 8, 0, 0, 0, 0, 0, 400], + ["p", 14, 22.001, 0, 8, 0, 0, 0, 0, 0, 400], + ["ul", 14, 22.001, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["ul", 14, 22.001, 0, 0, 0, 0, 0, 0, 40, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["ol", 14, 22.001, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["table", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["thead", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["th", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 600], + ["th", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 600], + ["tbody", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["blockquote", 17.5, 27.501, 0, 19, 9.5, 9.5, 0, 0, 43, 400], + ["p", 17.5, 27.501, 0, 0, 0, 0, 0, 0, 0, 400], + ["pre", 13, 18.2, 0, 9.5, 9, 9, 1, 1, 20, 400], + ["hr", 14, 22.001, 15, 15, 0, 0, 1, 0, 0, 400], + ["p", 14, 22.001, 0, 8, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + [6, ["strong"], 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["code"], 12.6, 19.801, 0, 0, 2, 2, 0, 0, 8, 400], + [6, ["a"], 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "line": { + "blocks": [ + ["h1", 28, 30, 12, 12, 0, 0, 0, 0, 0, 600], + ["h2", 21, 30, 12, 8, 0, 0, 0, 0, 0, 600], + ["h3", 16, 30, 8, 4, 0, 0, 0, 0, 0, 600], + ["h4", 16, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["h5", 16, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["h6", 12, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400], + ["ul", 14, 30, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["ul", 14, 30, 0, 0, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["ol", 14, 30, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["table", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["thead", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["th", 14, 30, 0, 0, 0, 0, 0, 0, 0, 600], + ["th", 14, 30, 0, 0, 0, 0, 0, 0, 0, 600], + ["tbody", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["blockquote", 17.5, 30, 0, 19, 9.5, 9.5, 0, 0, 43, 400], + ["p", 17.5, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["pre", 13, 30, 0, 9.5, 9, 9, 1, 1, 20, 400], + ["hr", 14, 30, 15, 15, 0, 0, 1, 0, 0, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + [6, ["strong"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["code"], 12.6, 30, 0, 0, 2, 2, 0, 0, 8, 400], + [6, ["a"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "probe": { + "blocks": [ + ["h1", 10, 14, 3, 5, 1, 2, 0, 0, 0, 600], + ["h2", 10, 14, 3, 5, 1, 2, 0, 0, 0, 600], + ["h3", 10, 14, 3, 5, 1, 2, 0, 0, 0, 600], + ["h4", 10, 14, 3, 5, 1, 2, 0, 0, 0, 400], + ["h5", 10, 14, 3, 5, 1, 2, 0, 0, 0, 400], + ["h6", 12, 16.8, 3, 5, 1, 2, 0, 0, 0, 400], + ["p", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["p", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["ul", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["ul", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["ol", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["table", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["thead", 10, 15.715, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["th", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 600], + ["th", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 600], + ["tbody", 10, 15.715, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["td", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["td", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["blockquote", 10, 15.715, 3, 5, 1, 2, 0, 0, 5, 400], + ["p", 10, 15.715, 3, 0, 1, 2, 0, 0, 0, 400], + ["pre", 10, 14, 3, 5, 1, 2, 1, 1, 2, 400], + ["hr", 10, 15.715, 3, 5, 1, 2, 1, 0, 0, 400], + ["p", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + [6, ["strong"], 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 600], + [6, ["code"], 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + [6, ["a"], 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400] + ] + }, + "styled": { + "caption-change": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-floor": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-growth": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-next": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-rush": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-staff": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-stores": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-tills": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-week": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-years": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "footer": { + "blocks": [ + ["h5", 11.5, 16.1, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "notes": { + "blocks": [ + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["ul", 14, 22.001, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["em"], 12, 18, 0, 0, 0, 0, 0, 0, 0, 400], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [7, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [10, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [11, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "summary-list": { + "blocks": [ + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["ul", 14, 22.001, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["p", 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [2, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [4, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["em"], 12, 18, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "title": { + "blocks": [ + ["h2", 24, 33.6, 0, 0, 0, 0, 0, 0, 0, 700], + ["p", 13, 19.5, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["code"], 12, 18, 0, 0, 1, 1, 0, 0, 8, 400] + ] + } } }, - "grid_w": 1536, - "grid_w_filter_bar": 1276, "verify": { "about@fits": { - "box": 1776, - "height": 44.4, - "scrolls": false, - "text": 1748.3 + "box": 1928, + "height": 48.2, + "scrolls": true, + "text": 1968.3, + "width": 6 }, "about@fix": { - "box": 1800, - "height": 45.0, + "box": 2040, + "height": 51.0, "scrolls": false, - "text": 1748.3 + "text": 1924.3, + "width": 6 }, "about@short": { - "box": 1736, - "height": 43.4, + "box": 1888, + "height": 47.2, "scrolls": true, - "text": 1748.3 + "text": 1968.3, + "width": 6 }, "br-html@fits": { "box": 128, "height": 3.2, "scrolls": false, - "text": 104 + "text": 104, + "width": 6 }, "br-html@fix": { "box": 128, "height": 3.2, "scrolls": false, - "text": 104 + "text": 104, + "width": 6 }, "br-html@short": { "box": 88, "height": 2.2, "scrolls": true, - "text": 104 + "text": 104, + "width": 6 }, "caps-digits@fits": { - "box": 88, - "height": 2.2, + "box": 112, + "height": 2.8, "scrolls": false, - "text": 60 + "text": 82, + "width": 6 }, "caps-digits@fix": { "box": 112, "height": 2.8, "scrolls": false, - "text": 60 + "text": 82, + "width": 6 }, "caps-digits@short": { - "box": 48, - "height": 1.2, + "box": 72, + "height": 1.8, "scrolls": true, - "text": 82 + "text": 82, + "width": 6 }, "long-word@fits": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "long-word@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "long-word@short": { "box": 48, "height": 1.2, "scrolls": true, - "text": 60 + "text": 60, + "width": 6 }, "loose-list@fits": { "box": 128, "height": 3.2, "scrolls": false, - "text": 98 + "text": 98, + "width": 6 }, "loose-list@fix": { "box": 144, "height": 3.6, "scrolls": false, - "text": 98 + "text": 98, + "width": 6 }, "loose-list@short": { "box": 88, "height": 2.2, "scrolls": true, - "text": 98 + "text": 98, + "width": 6 }, "nested-list@fits": { "box": 200, "height": 5.0, "scrolls": false, - "text": 170 + "text": 170, + "width": 6 }, "nested-list@fix": { "box": 216, "height": 5.4, "scrolls": false, - "text": 170 + "text": 170, + "width": 6 }, "nested-list@short": { "box": 160, "height": 4.0, "scrolls": true, - "text": 170 + "text": 170, + "width": 6 }, "quote-code@fits": { "box": 216, "height": 5.4, "scrolls": false, - "text": 190.9 + "text": 190.9, + "width": 6 }, "quote-code@fix": { "box": 216, "height": 5.4, "scrolls": false, - "text": 190.9 + "text": 190.9, + "width": 6 }, "quote-code@short": { "box": 176, "height": 4.4, "scrolls": true, - "text": 190.9 + "text": 190.9, + "width": 6 }, "section@fits": { - "box": 216, - "height": 5.4, + "box": 232, + "height": 5.8, "scrolls": false, - "text": 183.4 + "text": 205.4, + "width": 6 }, "section@fix": { - "box": 224, - "height": 5.6, + "box": 240, + "height": 6.0, "scrolls": false, - "text": 183.4 + "text": 205.4, + "width": 6 }, "section@short": { - "box": 176, - "height": 4.4, + "box": 192, + "height": 4.8, "scrolls": true, - "text": 183.4 + "text": 205.4, + "width": 6 }, "setext@fits": { "box": 168, "height": 4.2, "scrolls": false, - "text": 138.6 + "text": 138.6, + "width": 6 }, "setext@fix": { "box": 168, "height": 4.2, "scrolls": false, - "text": 138.6 + "text": 138.6, + "width": 6 }, "setext@short": { "box": 128, "height": 3.2, "scrolls": true, - "text": 138.6 + "text": 138.6, + "width": 6 }, "strip-bold@fits": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-bold@fix": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-bold@short": { "box": 40, "height": 0.6, "scrolls": true, - "text": 38 + "text": 38, + "width": 6 }, "strip-h1@fits": { "box": 96, "height": 2.4, "scrolls": false, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h1@fix": { "box": 96, "height": 2.4, "scrolls": false, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h1@short": { "box": 56, "height": 1.4, "scrolls": true, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h2@fits": { "box": 88, "height": 2.2, "scrolls": false, - "text": 57.4 + "text": 57.4, + "width": 6 }, "strip-h2@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 57.4 + "text": 57.4, + "width": 6 }, "strip-h2@short": { "box": 48, "height": 1.2, "scrolls": true, - "text": 57.4 + "text": 57.4, + "width": 6 }, "strip-h3@fits": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h3@fix": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h3@short": { "box": 40, "height": 0.8, "scrolls": true, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@fits": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@fix": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@short": { "box": 40, "height": 0.8, "scrolls": true, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-text@fits": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-text@fix": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-text@short": { "box": 40, "height": 0.6, "scrolls": true, - "text": 38 + "text": 38, + "width": 6 }, "table@fits": { "box": 168, "height": 4.2, "scrolls": false, - "text": 148 + "text": 148, + "width": 6 }, "table@fix": { "box": 192, "height": 4.8, "scrolls": false, - "text": 148 + "text": 148, + "width": 6 }, "table@short": { "box": 128, "height": 3.2, "scrolls": true, - "text": 148 + "text": 148, + "width": 6 }, "two-lines@fits": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "two-lines@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "two-lines@short": { "box": 48, "height": 1.2, "scrolls": true, - "text": 60 + "text": 60, + "width": 6 + } + }, + "verify_css": { + "caption-change@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 108.4, + "width": 12 + }, + "caption-change@then": { + "box": 112, + "height": 2.8, + "scrolls": true, + "text": 108.4, + "width": 12 + }, + "caption-floor@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 108.4, + "width": 12 + }, + "caption-floor@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 108.4, + "width": 12 + }, + "caption-growth@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-growth@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-next@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-next@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-rush@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-rush@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-staff@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-staff@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-stores@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-stores@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "caption-tills@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-tills@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-week@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-week@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "caption-years@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-years@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "footer@fix": { + "box": 48, + "height": 1.2, + "scrolls": false, + "text": 32.1, + "width": 12 + }, + "footer@then": { + "box": 48, + "height": 1.2, + "scrolls": false, + "text": 32.1, + "width": 12 + }, + "notes@fix": { + "box": 640, + "height": 16.0, + "scrolls": false, + "text": 610.3, + "width": 12 + }, + "notes@then": { + "box": 600, + "height": 15, + "scrolls": true, + "text": 610.3, + "width": 12 + }, + "summary-list@fix": { + "box": 592, + "height": 14.8, + "scrolls": false, + "text": 556.4, + "width": 4 + }, + "summary-list@then": { + "box": 520, + "height": 13, + "scrolls": true, + "text": 556.4, + "width": 4 + }, + "title@fix": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 75.1, + "width": 9 + }, + "title@then": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 75.1, + "width": 9 + } + }, + "viewports": { + "1440": { + "blocks": { + "about@12": { + "content_w": 1344, + "need": 1222, + "text": 1198.3 + }, + "about@3": { + "content_w": 300, + "need": 3532, + "text": 3508.3 + }, + "about@4": { + "content_w": 416, + "need": 2674, + "text": 2650.3 + }, + "about@6": { + "content_w": 648, + "need": 1926, + "text": 1902.3 + }, + "about@8": { + "content_w": 880, + "need": 1552, + "text": 1528.3 + }, + "br-html@12": { + "content_w": 1344, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 300, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 416, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 648, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 880, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1344, + "need": 84, + "text": 60 + }, + "caps-digits@3": { + "content_w": 300, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 416, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 648, + "need": 106, + "text": 82 + }, + "caps-digits@8": { + "content_w": 880, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 300, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 416, + "need": 106, + "text": 82 + }, + "long-word@6": { + "content_w": 648, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 880, + "need": 84, + "text": 60 + }, + "loose-list@12": { + "content_w": 1344, + "need": 124, + "text": 98 + }, + "loose-list@3": { + "content_w": 300, + "need": 168, + "text": 142 + }, + "loose-list@4": { + "content_w": 416, + "need": 124, + "text": 98 + }, + "loose-list@6": { + "content_w": 648, + "need": 124, + "text": 98 + }, + "loose-list@8": { + "content_w": 880, + "need": 124, + "text": 98 + }, + "nested-list@12": { + "content_w": 1344, + "need": 196, + "text": 170 + }, + "nested-list@3": { + "content_w": 300, + "need": 196, + "text": 170 + }, + "nested-list@4": { + "content_w": 416, + "need": 196, + "text": 170 + }, + "nested-list@6": { + "content_w": 648, + "need": 196, + "text": 170 + }, + "nested-list@8": { + "content_w": 880, + "need": 196, + "text": 170 + }, + "quote-code@12": { + "content_w": 1344, + "need": 215, + "text": 190.9 + }, + "quote-code@3": { + "content_w": 300, + "need": 242, + "text": 218.4 + }, + "quote-code@4": { + "content_w": 416, + "need": 242, + "text": 218.4 + }, + "quote-code@6": { + "content_w": 648, + "need": 215, + "text": 190.9 + }, + "quote-code@8": { + "content_w": 880, + "need": 215, + "text": 190.9 + }, + "section@12": { + "content_w": 1344, + "need": 187, + "text": 161.4 + }, + "section@3": { + "content_w": 300, + "need": 341, + "text": 315.4 + }, + "section@4": { + "content_w": 416, + "need": 275, + "text": 249.4 + }, + "section@6": { + "content_w": 648, + "need": 231, + "text": 205.4 + }, + "section@8": { + "content_w": 880, + "need": 209, + "text": 183.4 + }, + "setext@12": { + "content_w": 1344, + "need": 163, + "text": 138.6 + }, + "setext@3": { + "content_w": 300, + "need": 163, + "text": 138.6 + }, + "setext@4": { + "content_w": 416, + "need": 163, + "text": 138.6 + }, + "setext@6": { + "content_w": 648, + "need": 163, + "text": 138.6 + }, + "setext@8": { + "content_w": 880, + "need": 163, + "text": 138.6 + }, + "strip-bold@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 300, + "need": 84, + "text": 60 + }, + "strip-bold@4": { + "content_w": 416, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 648, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 880, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1344, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 300, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 416, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 648, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 880, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1344, + "need": 81, + "text": 57.4 + }, + "strip-h2@3": { + "content_w": 300, + "need": 81, + "text": 57.4 + }, + "strip-h2@4": { + "content_w": 416, + "need": 81, + "text": 57.4 + }, + "strip-h2@6": { + "content_w": 648, + "need": 81, + "text": 57.4 + }, + "strip-h2@8": { + "content_w": 880, + "need": 81, + "text": 57.4 + }, + "strip-h3@12": { + "content_w": 1344, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 300, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 416, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 648, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 880, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1344, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 300, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 416, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 648, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 880, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 300, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 416, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 648, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 880, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1344, + "need": 164, + "text": 148 + }, + "table@3": { + "content_w": 300, + "need": 230, + "text": 214 + }, + "table@4": { + "content_w": 416, + "need": 164, + "text": 148 + }, + "table@6": { + "content_w": 648, + "need": 164, + "text": 148 + }, + "table@8": { + "content_w": 880, + "need": 164, + "text": 148 + }, + "two-lines@12": { + "content_w": 1344, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 300, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 416, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 648, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 880, + "need": 84, + "text": 60 + } + }, + "filter_bar": { + "about@12": { + "content_w": 1084, + "need": 1420, + "text": 1396.3 + }, + "about@3": { + "content_w": 235, + "need": 4559, + "text": 4535.1 + }, + "about@4": { + "content_w": 329, + "need": 3334, + "text": 3310.3 + }, + "about@6": { + "content_w": 518, + "need": 2212, + "text": 2188.3 + }, + "about@8": { + "content_w": 707, + "need": 1772, + "text": 1748.3 + } + }, + "grid_w": 1376, + "grid_w_filter_bar": 1116, + "styled": { + "caption-change": { + "content_w": 1344, + "need": 124, + "text": 108.4 + }, + "caption-floor": { + "content_w": 1344, + "need": 124, + "text": 108.4 + }, + "caption-growth": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-next": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-rush": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-staff": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-stores": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "caption-tills": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-week": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "caption-years": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "footer": { + "content_w": 1344, + "need": 48, + "text": 32.1 + }, + "notes": { + "content_w": 1344, + "need": 626, + "text": 610.3 + }, + "summary-list": { + "content_w": 416, + "need": 551, + "text": 534.6 + }, + "title": { + "content_w": 996, + "need": 91, + "text": 75.1 + } + } + }, + "1600": { + "blocks": { + "about@12": { + "content_w": 1504, + "need": 1156, + "text": 1132.3 + }, + "about@3": { + "content_w": 340, + "need": 3246, + "text": 3222.3 + }, + "about@4": { + "content_w": 469, + "need": 2432, + "text": 2408.3 + }, + "about@6": { + "content_w": 728, + "need": 1772, + "text": 1748.3 + }, + "about@8": { + "content_w": 987, + "need": 1442, + "text": 1418.3 + }, + "br-html@12": { + "content_w": 1504, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 728, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 987, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "caps-digits@3": { + "content_w": 340, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "caps-digits@8": { + "content_w": 987, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 469, + "need": 106, + "text": 82 + }, + "long-word@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "loose-list@12": { + "content_w": 1504, + "need": 124, + "text": 98 + }, + "loose-list@3": { + "content_w": 340, + "need": 146, + "text": 120 + }, + "loose-list@4": { + "content_w": 469, + "need": 124, + "text": 98 + }, + "loose-list@6": { + "content_w": 728, + "need": 124, + "text": 98 + }, + "loose-list@8": { + "content_w": 987, + "need": 124, + "text": 98 + }, + "nested-list@12": { + "content_w": 1504, + "need": 196, + "text": 170 + }, + "nested-list@3": { + "content_w": 340, + "need": 196, + "text": 170 + }, + "nested-list@4": { + "content_w": 469, + "need": 196, + "text": 170 + }, + "nested-list@6": { + "content_w": 728, + "need": 196, + "text": 170 + }, + "nested-list@8": { + "content_w": 987, + "need": 196, + "text": 170 + }, + "quote-code@12": { + "content_w": 1504, + "need": 215, + "text": 190.9 + }, + "quote-code@3": { + "content_w": 340, + "need": 242, + "text": 218.4 + }, + "quote-code@4": { + "content_w": 469, + "need": 242, + "text": 218.4 + }, + "quote-code@6": { + "content_w": 728, + "need": 215, + "text": 190.9 + }, + "quote-code@8": { + "content_w": 987, + "need": 215, + "text": 190.9 + }, + "section@12": { + "content_w": 1504, + "need": 187, + "text": 161.4 + }, + "section@3": { + "content_w": 340, + "need": 297, + "text": 271.4 + }, + "section@4": { + "content_w": 469, + "need": 231, + "text": 205.4 + }, + "section@6": { + "content_w": 728, + "need": 209, + "text": 183.4 + }, + "section@8": { + "content_w": 987, + "need": 209, + "text": 183.4 + }, + "setext@12": { + "content_w": 1504, + "need": 163, + "text": 138.6 + }, + "setext@3": { + "content_w": 340, + "need": 163, + "text": 138.6 + }, + "setext@4": { + "content_w": 469, + "need": 163, + "text": 138.6 + }, + "setext@6": { + "content_w": 728, + "need": 163, + "text": 138.6 + }, + "setext@8": { + "content_w": 987, + "need": 163, + "text": 138.6 + }, + "strip-bold@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-bold@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1504, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 340, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 469, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 728, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 987, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1504, + "need": 81, + "text": 57.4 + }, + "strip-h2@3": { + "content_w": 340, + "need": 81, + "text": 57.4 + }, + "strip-h2@4": { + "content_w": 469, + "need": 81, + "text": 57.4 + }, + "strip-h2@6": { + "content_w": 728, + "need": 81, + "text": 57.4 + }, + "strip-h2@8": { + "content_w": 987, + "need": 81, + "text": 57.4 + }, + "strip-h3@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1504, + "need": 164, + "text": 148 + }, + "table@3": { + "content_w": 340, + "need": 230, + "text": 214 + }, + "table@4": { + "content_w": 469, + "need": 164, + "text": 148 + }, + "table@6": { + "content_w": 728, + "need": 164, + "text": 148 + }, + "table@8": { + "content_w": 987, + "need": 164, + "text": 148 + }, + "two-lines@12": { + "content_w": 1504, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 340, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 469, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 987, + "need": 84, + "text": 60 + } + }, + "filter_bar": { + "about@12": { + "content_w": 1244, + "need": 1332, + "text": 1308.3 + }, + "about@3": { + "content_w": 275, + "need": 3980, + "text": 3955.7 + }, + "about@4": { + "content_w": 383, + "need": 2916, + "text": 2892.3 + }, + "about@6": { + "content_w": 598, + "need": 2036, + "text": 2012.3 + }, + "about@8": { + "content_w": 813, + "need": 1618, + "text": 1594.3 + } + }, + "grid_w": 1536, + "grid_w_filter_bar": 1276, + "styled": { + "caption-change": { + "content_w": 1504, + "need": 124, + "text": 108.4 + }, + "caption-floor": { + "content_w": 1504, + "need": 124, + "text": 108.4 + }, + "caption-growth": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-next": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-rush": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-staff": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-stores": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "caption-tills": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-week": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "caption-years": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "footer": { + "content_w": 1504, + "need": 48, + "text": 32.1 + }, + "notes": { + "content_w": 1504, + "need": 626, + "text": 610.3 + }, + "summary-list": { + "content_w": 469, + "need": 507, + "text": 491.1 + }, + "title": { + "content_w": 1116, + "need": 91, + "text": 75.1 + } + } } } }, "5.0.0": { - "blocks": { - "about@12": { - "content_w": 1504, - "need": 1156, - "text": 1132.3 - }, - "about@3": { - "content_w": 340, - "need": 3246, - "text": 3222.3 - }, - "about@4": { - "content_w": 469, - "need": 2388, - "text": 2364.3 - }, - "about@6": { - "content_w": 728, - "need": 1772, - "text": 1748.3 - }, - "about@8": { - "content_w": 987, - "need": 1442, - "text": 1418.3 - }, - "br-html@12": { - "content_w": 1504, - "need": 128, - "text": 104 - }, - "br-html@3": { - "content_w": 340, - "need": 128, - "text": 104 - }, - "br-html@4": { - "content_w": 469, - "need": 128, - "text": 104 - }, - "br-html@6": { - "content_w": 728, - "need": 128, - "text": 104 - }, - "br-html@8": { - "content_w": 987, - "need": 128, - "text": 104 - }, - "caps-digits@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "caps-digits@3": { - "content_w": 340, - "need": 150, - "text": 126 - }, - "caps-digits@4": { - "content_w": 469, - "need": 128, - "text": 104 - }, - "caps-digits@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "caps-digits@8": { - "content_w": 987, - "need": 84, - "text": 60 - }, - "long-word@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "long-word@3": { - "content_w": 340, - "need": 128, - "text": 104 - }, - "long-word@4": { - "content_w": 469, - "need": 106, - "text": 82 - }, - "long-word@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "long-word@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "loose-list@12": { - "content_w": 1504, - "need": 124, - "text": 98 - }, - "loose-list@3": { - "content_w": 340, - "need": 146, - "text": 120 - }, - "loose-list@4": { - "content_w": 469, - "need": 124, - "text": 98 - }, - "loose-list@6": { - "content_w": 728, - "need": 124, - "text": 98 - }, - "loose-list@8": { - "content_w": 987, - "need": 124, - "text": 98 - }, - "nested-list@12": { - "content_w": 1504, - "need": 196, - "text": 170 - }, - "nested-list@3": { - "content_w": 340, - "need": 196, - "text": 170 - }, - "nested-list@4": { - "content_w": 469, - "need": 196, - "text": 170 - }, - "nested-list@6": { - "content_w": 728, - "need": 196, - "text": 170 - }, - "nested-list@8": { - "content_w": 987, - "need": 196, - "text": 170 - }, - "quote-code@12": { - "content_w": 1504, - "need": 215, - "text": 190.9 - }, - "quote-code@3": { - "content_w": 340, - "need": 242, - "text": 218.4 - }, - "quote-code@4": { - "content_w": 469, - "need": 242, - "text": 218.4 - }, - "quote-code@6": { - "content_w": 728, - "need": 215, - "text": 190.9 - }, - "quote-code@8": { - "content_w": 987, - "need": 215, - "text": 190.9 - }, - "section@12": { - "content_w": 1504, - "need": 187, - "text": 161.4 - }, - "section@3": { - "content_w": 340, - "need": 297, - "text": 271.4 - }, - "section@4": { - "content_w": 469, - "need": 231, - "text": 205.4 - }, - "section@6": { - "content_w": 728, - "need": 209, - "text": 183.4 - }, - "section@8": { - "content_w": 987, - "need": 209, - "text": 183.4 - }, - "setext@12": { - "content_w": 1504, - "need": 163, - "text": 138.6 - }, - "setext@3": { - "content_w": 340, - "need": 163, - "text": 138.6 - }, - "setext@4": { - "content_w": 469, - "need": 163, - "text": 138.6 - }, - "setext@6": { - "content_w": 728, - "need": 163, - "text": 138.6 - }, - "setext@8": { - "content_w": 987, - "need": 163, - "text": 138.6 - }, - "strip-bold@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "strip-bold@3": { - "content_w": 340, - "need": 62, - "text": 38 - }, - "strip-bold@4": { - "content_w": 469, - "need": 62, - "text": 38 - }, - "strip-bold@6": { - "content_w": 728, - "need": 62, - "text": 38 - }, - "strip-bold@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "strip-h1@12": { - "content_w": 1504, - "need": 95, - "text": 67.2 - }, - "strip-h1@3": { - "content_w": 340, - "need": 95, - "text": 67.2 - }, - "strip-h1@4": { - "content_w": 469, - "need": 95, - "text": 67.2 - }, - "strip-h1@6": { - "content_w": 728, - "need": 95, - "text": 67.2 - }, - "strip-h1@8": { - "content_w": 987, - "need": 95, - "text": 67.2 - }, - "strip-h2@12": { - "content_w": 1504, - "need": 81, - "text": 57.4 - }, - "strip-h2@3": { - "content_w": 340, - "need": 81, - "text": 57.4 - }, - "strip-h2@4": { - "content_w": 469, - "need": 81, - "text": 57.4 - }, - "strip-h2@6": { - "content_w": 728, - "need": 81, - "text": 57.4 - }, - "strip-h2@8": { - "content_w": 987, - "need": 81, - "text": 57.4 - }, - "strip-h3@12": { - "content_w": 1504, - "need": 66, - "text": 46.4 - }, - "strip-h3@3": { - "content_w": 340, - "need": 66, - "text": 46.4 - }, - "strip-h3@4": { - "content_w": 469, - "need": 66, - "text": 46.4 - }, - "strip-h3@6": { - "content_w": 728, - "need": 66, - "text": 46.4 - }, - "strip-h3@8": { - "content_w": 987, - "need": 66, - "text": 46.4 - }, - "strip-h4@12": { - "content_w": 1504, - "need": 66, - "text": 46.4 - }, - "strip-h4@3": { - "content_w": 340, - "need": 66, - "text": 46.4 - }, - "strip-h4@4": { - "content_w": 469, - "need": 66, - "text": 46.4 - }, - "strip-h4@6": { - "content_w": 728, - "need": 66, - "text": 46.4 - }, - "strip-h4@8": { - "content_w": 987, - "need": 66, - "text": 46.4 - }, - "strip-text@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "strip-text@3": { - "content_w": 340, - "need": 62, - "text": 38 - }, - "strip-text@4": { - "content_w": 469, - "need": 62, - "text": 38 - }, - "strip-text@6": { - "content_w": 728, - "need": 62, - "text": 38 - }, - "strip-text@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "table@12": { - "content_w": 1504, - "need": 164, - "text": 148 - }, - "table@3": { - "content_w": 340, - "need": 230, - "text": 214 - }, - "table@4": { - "content_w": 469, - "need": 164, - "text": 148 - }, - "table@6": { - "content_w": 728, - "need": 164, - "text": 148 - }, - "table@8": { - "content_w": 987, - "need": 164, - "text": 148 - }, - "two-lines@12": { - "content_w": 1504, - "need": 84, - "text": 60 - }, - "two-lines@3": { - "content_w": 340, - "need": 84, - "text": 60 - }, - "two-lines@4": { - "content_w": 469, - "need": 84, - "text": 60 - }, - "two-lines@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "two-lines@8": { - "content_w": 987, - "need": 84, - "text": 60 - } - }, "containers": { "footer@4": 469, "header@12": 1504, "sub-tab@6": 728 }, - "filter_bar": { - "about@12": { - "content_w": 1244, - "need": 1332, - "text": 1308.3 - }, - "about@3": { - "content_w": 275, - "need": 3980, - "text": 3955.7 - }, - "about@4": { - "content_w": 383, - "need": 2894, - "text": 2870.3 - }, - "about@6": { - "content_w": 598, - "need": 2036, - "text": 2012.3 - }, - "about@8": { - "content_w": 813, - "need": 1618, - "text": 1594.3 + "styles": { + "default": { + "blocks": [ + ["h1", 28, 39.2, 12, 12, 0, 0, 0, 0, 0, 600], + ["h2", 21, 29.4, 12, 8, 0, 0, 0, 0, 0, 600], + ["h3", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 600], + ["h4", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 400], + ["h5", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 400], + ["h6", 12, 16.8, 8, 4, 0, 0, 0, 0, 0, 400], + ["p", 14, 22.001, 0, 8, 0, 0, 0, 0, 0, 400], + ["p", 14, 22.001, 0, 8, 0, 0, 0, 0, 0, 400], + ["ul", 14, 22.001, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["ul", 14, 22.001, 0, 0, 0, 0, 0, 0, 40, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["ol", 14, 22.001, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["table", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["thead", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["th", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 600], + ["th", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 600], + ["tbody", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + ["blockquote", 17.5, 27.501, 0, 19, 9.5, 9.5, 0, 0, 43, 400], + ["p", 17.5, 27.501, 0, 0, 0, 0, 0, 0, 0, 400], + ["pre", 13, 18.2, 0, 9.5, 9, 9, 1, 1, 20, 400], + ["hr", 14, 22.001, 15, 15, 0, 0, 1, 0, 0, 400], + ["p", 14, 22.001, 0, 8, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400], + [6, ["strong"], 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["code"], 12.6, 19.801, 0, 0, 2, 2, 0, 0, 8, 400], + [6, ["a"], 14, 22.001, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "line": { + "blocks": [ + ["h1", 28, 30, 12, 12, 0, 0, 0, 0, 0, 600], + ["h2", 21, 30, 12, 8, 0, 0, 0, 0, 0, 600], + ["h3", 16, 30, 8, 4, 0, 0, 0, 0, 0, 600], + ["h4", 16, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["h5", 16, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["h6", 12, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400], + ["ul", 14, 30, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["ul", 14, 30, 0, 0, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["ol", 14, 30, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["table", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["thead", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["th", 14, 30, 0, 0, 0, 0, 0, 0, 0, 600], + ["th", 14, 30, 0, 0, 0, 0, 0, 0, 0, 600], + ["tbody", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["blockquote", 17.5, 30, 0, 19, 9.5, 9.5, 0, 0, 43, 400], + ["p", 17.5, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["pre", 13, 30, 0, 9.5, 9, 9, 1, 1, 20, 400], + ["hr", 14, 30, 15, 15, 0, 0, 1, 0, 0, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + [6, ["strong"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["code"], 12.6, 30, 0, 0, 2, 2, 0, 0, 8, 400], + [6, ["a"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "probe": { + "blocks": [ + ["h1", 10, 14, 3, 5, 1, 2, 0, 0, 0, 600], + ["h2", 10, 14, 3, 5, 1, 2, 0, 0, 0, 600], + ["h3", 10, 14, 3, 5, 1, 2, 0, 0, 0, 600], + ["h4", 10, 14, 3, 5, 1, 2, 0, 0, 0, 400], + ["h5", 10, 14, 3, 5, 1, 2, 0, 0, 0, 400], + ["h6", 12, 16.8, 3, 5, 1, 2, 0, 0, 0, 400], + ["p", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["p", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["ul", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["ul", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["ol", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["table", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["thead", 10, 15.715, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["th", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 600], + ["th", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 600], + ["tbody", 10, 15.715, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["td", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["td", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + ["blockquote", 10, 15.715, 3, 5, 1, 2, 0, 0, 5, 400], + ["p", 10, 15.715, 3, 0, 1, 2, 0, 0, 0, 400], + ["pre", 10, 14, 3, 5, 1, 2, 1, 1, 2, 400], + ["hr", 10, 15.715, 3, 5, 1, 2, 1, 0, 0, 400], + ["p", 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + [6, ["strong"], 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 600], + [6, ["code"], 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400], + [6, ["a"], 10, 15.715, 3, 5, 1, 2, 0, 0, 0, 400] + ] + }, + "styled": { + "caption-change": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-floor": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-growth": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-next": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-rush": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-staff": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-stores": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-tills": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-week": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-years": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "footer": { + "blocks": [ + ["h5", 11.5, 16.1, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "notes": { + "blocks": [ + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["ul", 14, 22.001, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["em"], 12, 18, 0, 0, 0, 0, 0, 0, 0, 400], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [7, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [10, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [11, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "summary-list": { + "blocks": [ + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["ul", 14, 22.001, 0, 9.5, 0, 0, 0, 0, 40, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["p", 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [2, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [4, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["em"], 12, 18, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "title": { + "blocks": [ + ["h2", 24, 33.6, 0, 0, 0, 0, 0, 0, 0, 700], + ["p", 13, 19.5, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["code"], 12, 18, 0, 0, 1, 1, 0, 0, 8, 400] + ] + } } }, - "grid_w": 1536, - "grid_w_filter_bar": 1276, "verify": { "about@fits": { - "box": 1776, - "height": 44.4, - "scrolls": false, - "text": 1748.3 + "box": 1904, + "height": 47.6, + "scrolls": true, + "text": 1968.3, + "width": 6 }, "about@fix": { - "box": 1800, - "height": 45.0, + "box": 2040, + "height": 51.0, "scrolls": false, - "text": 1748.3 + "text": 1924.3, + "width": 6 }, "about@short": { - "box": 1736, - "height": 43.4, + "box": 1864, + "height": 46.6, "scrolls": true, - "text": 1748.3 + "text": 1968.3, + "width": 6 }, "br-html@fits": { "box": 128, "height": 3.2, "scrolls": false, - "text": 104 + "text": 104, + "width": 6 }, "br-html@fix": { "box": 128, "height": 3.2, "scrolls": false, - "text": 104 + "text": 104, + "width": 6 }, "br-html@short": { "box": 88, "height": 2.2, "scrolls": true, - "text": 104 + "text": 104, + "width": 6 }, "caps-digits@fits": { - "box": 88, - "height": 2.2, - "scrolls": true, - "text": 82 + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 82, + "width": 6 }, "caps-digits@fix": { "box": 112, "height": 2.8, "scrolls": false, - "text": 82 + "text": 82, + "width": 6 }, "caps-digits@short": { - "box": 48, - "height": 1.2, + "box": 72, + "height": 1.8, "scrolls": true, - "text": 82 + "text": 82, + "width": 6 }, "long-word@fits": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "long-word@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "long-word@short": { "box": 48, "height": 1.2, "scrolls": true, - "text": 60 + "text": 60, + "width": 6 }, "loose-list@fits": { "box": 128, "height": 3.2, "scrolls": false, - "text": 98 + "text": 98, + "width": 6 }, "loose-list@fix": { "box": 144, "height": 3.6, "scrolls": false, - "text": 98 + "text": 98, + "width": 6 }, "loose-list@short": { "box": 88, "height": 2.2, "scrolls": true, - "text": 98 + "text": 98, + "width": 6 }, "nested-list@fits": { "box": 200, "height": 5.0, "scrolls": false, - "text": 170 + "text": 170, + "width": 6 }, "nested-list@fix": { "box": 216, "height": 5.4, "scrolls": false, - "text": 170 + "text": 170, + "width": 6 }, "nested-list@short": { "box": 160, "height": 4.0, "scrolls": true, - "text": 170 + "text": 170, + "width": 6 }, "quote-code@fits": { "box": 216, "height": 5.4, "scrolls": false, - "text": 190.9 + "text": 190.9, + "width": 6 }, "quote-code@fix": { "box": 216, "height": 5.4, "scrolls": false, - "text": 190.9 + "text": 190.9, + "width": 6 }, "quote-code@short": { "box": 176, "height": 4.4, "scrolls": true, - "text": 190.9 + "text": 190.9, + "width": 6 }, "section@fits": { - "box": 216, - "height": 5.4, + "box": 232, + "height": 5.8, "scrolls": false, - "text": 183.4 + "text": 205.4, + "width": 6 }, "section@fix": { - "box": 224, - "height": 5.6, + "box": 240, + "height": 6.0, "scrolls": false, - "text": 183.4 + "text": 205.4, + "width": 6 }, "section@short": { - "box": 176, - "height": 4.4, + "box": 192, + "height": 4.8, "scrolls": true, - "text": 183.4 + "text": 205.4, + "width": 6 }, "setext@fits": { "box": 168, "height": 4.2, "scrolls": false, - "text": 138.6 + "text": 138.6, + "width": 6 }, "setext@fix": { "box": 168, "height": 4.2, "scrolls": false, - "text": 138.6 + "text": 138.6, + "width": 6 }, "setext@short": { "box": 128, "height": 3.2, "scrolls": true, - "text": 138.6 + "text": 138.6, + "width": 6 }, "strip-bold@fits": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-bold@fix": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-bold@short": { "box": 40, "height": 0.6, "scrolls": true, - "text": 38 + "text": 38, + "width": 6 }, "strip-h1@fits": { "box": 96, "height": 2.4, "scrolls": false, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h1@fix": { "box": 96, "height": 2.4, "scrolls": false, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h1@short": { "box": 56, "height": 1.4, "scrolls": true, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h2@fits": { "box": 88, "height": 2.2, "scrolls": false, - "text": 57.4 + "text": 57.4, + "width": 6 }, "strip-h2@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 57.4 + "text": 57.4, + "width": 6 }, "strip-h2@short": { "box": 48, "height": 1.2, "scrolls": true, - "text": 57.4 + "text": 57.4, + "width": 6 }, "strip-h3@fits": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h3@fix": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h3@short": { "box": 40, "height": 0.8, "scrolls": true, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@fits": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@fix": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@short": { "box": 40, "height": 0.8, "scrolls": true, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-text@fits": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-text@fix": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-text@short": { "box": 40, "height": 0.6, "scrolls": true, - "text": 38 + "text": 38, + "width": 6 }, "table@fits": { "box": 168, "height": 4.2, "scrolls": false, - "text": 148 + "text": 148, + "width": 6 }, "table@fix": { "box": 192, "height": 4.8, "scrolls": false, - "text": 148 + "text": 148, + "width": 6 }, "table@short": { "box": 128, "height": 3.2, "scrolls": true, - "text": 148 + "text": 148, + "width": 6 }, "two-lines@fits": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "two-lines@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "two-lines@short": { "box": 48, "height": 1.2, "scrolls": true, - "text": 60 + "text": 60, + "width": 6 + } + }, + "verify_css": { + "caption-change@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 108.4, + "width": 12 + }, + "caption-change@then": { + "box": 112, + "height": 2.8, + "scrolls": true, + "text": 108.4, + "width": 12 + }, + "caption-floor@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 108.4, + "width": 12 + }, + "caption-floor@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 108.4, + "width": 12 + }, + "caption-growth@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-growth@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-next@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-next@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-rush@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-rush@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-staff@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-staff@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-stores@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-stores@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "caption-tills@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-tills@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-week@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-week@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "caption-years@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-years@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "footer@fix": { + "box": 48, + "height": 1.2, + "scrolls": false, + "text": 32.1, + "width": 12 + }, + "footer@then": { + "box": 48, + "height": 1.2, + "scrolls": false, + "text": 32.1, + "width": 12 + }, + "notes@fix": { + "box": 640, + "height": 16.0, + "scrolls": false, + "text": 610.3, + "width": 12 + }, + "notes@then": { + "box": 600, + "height": 15, + "scrolls": true, + "text": 610.3, + "width": 12 + }, + "summary-list@fix": { + "box": 592, + "height": 14.8, + "scrolls": false, + "text": 534.6, + "width": 4 + }, + "summary-list@then": { + "box": 520, + "height": 13, + "scrolls": true, + "text": 556.4, + "width": 4 + }, + "title@fix": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 75.1, + "width": 9 + }, + "title@then": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 75.1, + "width": 9 + } + }, + "viewports": { + "1440": { + "blocks": { + "about@12": { + "content_w": 1344, + "need": 1222, + "text": 1198.3 + }, + "about@3": { + "content_w": 300, + "need": 3510, + "text": 3486.3 + }, + "about@4": { + "content_w": 416, + "need": 2674, + "text": 2650.3 + }, + "about@6": { + "content_w": 648, + "need": 1904, + "text": 1880.3 + }, + "about@8": { + "content_w": 880, + "need": 1552, + "text": 1528.3 + }, + "br-html@12": { + "content_w": 1344, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 300, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 416, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 648, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 880, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1344, + "need": 84, + "text": 60 + }, + "caps-digits@3": { + "content_w": 300, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 416, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 648, + "need": 106, + "text": 82 + }, + "caps-digits@8": { + "content_w": 880, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 300, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 416, + "need": 128, + "text": 104 + }, + "long-word@6": { + "content_w": 648, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 880, + "need": 84, + "text": 60 + }, + "loose-list@12": { + "content_w": 1344, + "need": 124, + "text": 98 + }, + "loose-list@3": { + "content_w": 300, + "need": 168, + "text": 142 + }, + "loose-list@4": { + "content_w": 416, + "need": 124, + "text": 98 + }, + "loose-list@6": { + "content_w": 648, + "need": 124, + "text": 98 + }, + "loose-list@8": { + "content_w": 880, + "need": 124, + "text": 98 + }, + "nested-list@12": { + "content_w": 1344, + "need": 196, + "text": 170 + }, + "nested-list@3": { + "content_w": 300, + "need": 196, + "text": 170 + }, + "nested-list@4": { + "content_w": 416, + "need": 196, + "text": 170 + }, + "nested-list@6": { + "content_w": 648, + "need": 196, + "text": 170 + }, + "nested-list@8": { + "content_w": 880, + "need": 196, + "text": 170 + }, + "quote-code@12": { + "content_w": 1344, + "need": 215, + "text": 190.9 + }, + "quote-code@3": { + "content_w": 300, + "need": 242, + "text": 218.4 + }, + "quote-code@4": { + "content_w": 416, + "need": 242, + "text": 218.4 + }, + "quote-code@6": { + "content_w": 648, + "need": 215, + "text": 190.9 + }, + "quote-code@8": { + "content_w": 880, + "need": 215, + "text": 190.9 + }, + "section@12": { + "content_w": 1344, + "need": 187, + "text": 161.4 + }, + "section@3": { + "content_w": 300, + "need": 341, + "text": 315.4 + }, + "section@4": { + "content_w": 416, + "need": 275, + "text": 249.4 + }, + "section@6": { + "content_w": 648, + "need": 231, + "text": 205.4 + }, + "section@8": { + "content_w": 880, + "need": 209, + "text": 183.4 + }, + "setext@12": { + "content_w": 1344, + "need": 163, + "text": 138.6 + }, + "setext@3": { + "content_w": 300, + "need": 163, + "text": 138.6 + }, + "setext@4": { + "content_w": 416, + "need": 163, + "text": 138.6 + }, + "setext@6": { + "content_w": 648, + "need": 163, + "text": 138.6 + }, + "setext@8": { + "content_w": 880, + "need": 163, + "text": 138.6 + }, + "strip-bold@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 300, + "need": 84, + "text": 60 + }, + "strip-bold@4": { + "content_w": 416, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 648, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 880, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1344, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 300, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 416, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 648, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 880, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1344, + "need": 81, + "text": 57.4 + }, + "strip-h2@3": { + "content_w": 300, + "need": 81, + "text": 57.4 + }, + "strip-h2@4": { + "content_w": 416, + "need": 81, + "text": 57.4 + }, + "strip-h2@6": { + "content_w": 648, + "need": 81, + "text": 57.4 + }, + "strip-h2@8": { + "content_w": 880, + "need": 81, + "text": 57.4 + }, + "strip-h3@12": { + "content_w": 1344, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 300, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 416, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 648, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 880, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1344, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 300, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 416, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 648, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 880, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 300, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 416, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 648, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 880, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1344, + "need": 164, + "text": 148 + }, + "table@3": { + "content_w": 300, + "need": 230, + "text": 214 + }, + "table@4": { + "content_w": 416, + "need": 164, + "text": 148 + }, + "table@6": { + "content_w": 648, + "need": 164, + "text": 148 + }, + "table@8": { + "content_w": 880, + "need": 164, + "text": 148 + }, + "two-lines@12": { + "content_w": 1344, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 300, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 416, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 648, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 880, + "need": 84, + "text": 60 + } + }, + "filter_bar": { + "about@12": { + "content_w": 1084, + "need": 1398, + "text": 1374.3 + }, + "about@3": { + "content_w": 235, + "need": 4559, + "text": 4535.1 + }, + "about@4": { + "content_w": 329, + "need": 3290, + "text": 3266.3 + }, + "about@6": { + "content_w": 518, + "need": 2212, + "text": 2188.3 + }, + "about@8": { + "content_w": 707, + "need": 1772, + "text": 1748.3 + } + }, + "grid_w": 1376, + "grid_w_filter_bar": 1116, + "styled": { + "caption-change": { + "content_w": 1344, + "need": 124, + "text": 108.4 + }, + "caption-floor": { + "content_w": 1344, + "need": 124, + "text": 108.4 + }, + "caption-growth": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-next": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-rush": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-staff": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-stores": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "caption-tills": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-week": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "caption-years": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "footer": { + "content_w": 1344, + "need": 48, + "text": 32.1 + }, + "notes": { + "content_w": 1344, + "need": 626, + "text": 610.3 + }, + "summary-list": { + "content_w": 416, + "need": 551, + "text": 534.6 + }, + "title": { + "content_w": 996, + "need": 91, + "text": 75.1 + } + } + }, + "1600": { + "blocks": { + "about@12": { + "content_w": 1504, + "need": 1156, + "text": 1132.3 + }, + "about@3": { + "content_w": 340, + "need": 3246, + "text": 3222.3 + }, + "about@4": { + "content_w": 469, + "need": 2388, + "text": 2364.3 + }, + "about@6": { + "content_w": 728, + "need": 1772, + "text": 1748.3 + }, + "about@8": { + "content_w": 987, + "need": 1442, + "text": 1418.3 + }, + "br-html@12": { + "content_w": 1504, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 728, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 987, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "caps-digits@3": { + "content_w": 340, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "caps-digits@8": { + "content_w": 987, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 469, + "need": 106, + "text": 82 + }, + "long-word@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "loose-list@12": { + "content_w": 1504, + "need": 124, + "text": 98 + }, + "loose-list@3": { + "content_w": 340, + "need": 146, + "text": 120 + }, + "loose-list@4": { + "content_w": 469, + "need": 124, + "text": 98 + }, + "loose-list@6": { + "content_w": 728, + "need": 124, + "text": 98 + }, + "loose-list@8": { + "content_w": 987, + "need": 124, + "text": 98 + }, + "nested-list@12": { + "content_w": 1504, + "need": 196, + "text": 170 + }, + "nested-list@3": { + "content_w": 340, + "need": 196, + "text": 170 + }, + "nested-list@4": { + "content_w": 469, + "need": 196, + "text": 170 + }, + "nested-list@6": { + "content_w": 728, + "need": 196, + "text": 170 + }, + "nested-list@8": { + "content_w": 987, + "need": 196, + "text": 170 + }, + "quote-code@12": { + "content_w": 1504, + "need": 215, + "text": 190.9 + }, + "quote-code@3": { + "content_w": 340, + "need": 242, + "text": 218.4 + }, + "quote-code@4": { + "content_w": 469, + "need": 242, + "text": 218.4 + }, + "quote-code@6": { + "content_w": 728, + "need": 215, + "text": 190.9 + }, + "quote-code@8": { + "content_w": 987, + "need": 215, + "text": 190.9 + }, + "section@12": { + "content_w": 1504, + "need": 187, + "text": 161.4 + }, + "section@3": { + "content_w": 340, + "need": 297, + "text": 271.4 + }, + "section@4": { + "content_w": 469, + "need": 231, + "text": 205.4 + }, + "section@6": { + "content_w": 728, + "need": 209, + "text": 183.4 + }, + "section@8": { + "content_w": 987, + "need": 209, + "text": 183.4 + }, + "setext@12": { + "content_w": 1504, + "need": 163, + "text": 138.6 + }, + "setext@3": { + "content_w": 340, + "need": 163, + "text": 138.6 + }, + "setext@4": { + "content_w": 469, + "need": 163, + "text": 138.6 + }, + "setext@6": { + "content_w": 728, + "need": 163, + "text": 138.6 + }, + "setext@8": { + "content_w": 987, + "need": 163, + "text": 138.6 + }, + "strip-bold@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-bold@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1504, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 340, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 469, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 728, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 987, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1504, + "need": 81, + "text": 57.4 + }, + "strip-h2@3": { + "content_w": 340, + "need": 81, + "text": 57.4 + }, + "strip-h2@4": { + "content_w": 469, + "need": 81, + "text": 57.4 + }, + "strip-h2@6": { + "content_w": 728, + "need": 81, + "text": 57.4 + }, + "strip-h2@8": { + "content_w": 987, + "need": 81, + "text": 57.4 + }, + "strip-h3@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1504, + "need": 164, + "text": 148 + }, + "table@3": { + "content_w": 340, + "need": 230, + "text": 214 + }, + "table@4": { + "content_w": 469, + "need": 164, + "text": 148 + }, + "table@6": { + "content_w": 728, + "need": 164, + "text": 148 + }, + "table@8": { + "content_w": 987, + "need": 164, + "text": 148 + }, + "two-lines@12": { + "content_w": 1504, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 340, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 469, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 987, + "need": 84, + "text": 60 + } + }, + "filter_bar": { + "about@12": { + "content_w": 1244, + "need": 1332, + "text": 1308.3 + }, + "about@3": { + "content_w": 275, + "need": 3980, + "text": 3955.7 + }, + "about@4": { + "content_w": 383, + "need": 2894, + "text": 2870.3 + }, + "about@6": { + "content_w": 598, + "need": 2036, + "text": 2012.3 + }, + "about@8": { + "content_w": 813, + "need": 1618, + "text": 1594.3 + } + }, + "grid_w": 1536, + "grid_w_filter_bar": 1276, + "styled": { + "caption-change": { + "content_w": 1504, + "need": 124, + "text": 108.4 + }, + "caption-floor": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-growth": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-next": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-rush": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-staff": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-stores": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "caption-tills": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-week": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "caption-years": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "footer": { + "content_w": 1504, + "need": 48, + "text": 32.1 + }, + "notes": { + "content_w": 1504, + "need": 583, + "text": 566.8 + }, + "summary-list": { + "content_w": 469, + "need": 485, + "text": 469.4 + }, + "title": { + "content_w": 1116, + "need": 91, + "text": 75.1 + } + } } } }, "6.1.0": { - "blocks": { - "about@12": { - "content_w": 1504, - "need": 1156, - "text": 1132 - }, - "about@3": { - "content_w": 340, - "need": 3246, - "text": 3222 - }, - "about@4": { - "content_w": 469, - "need": 2432, - "text": 2408 - }, - "about@6": { - "content_w": 728, - "need": 1772, - "text": 1748 - }, - "about@8": { - "content_w": 987, - "need": 1442, - "text": 1418 - }, - "br-html@12": { - "content_w": 1504, - "need": 128, - "text": 104 - }, - "br-html@3": { - "content_w": 340, - "need": 128, - "text": 104 - }, - "br-html@4": { - "content_w": 469, - "need": 128, - "text": 104 - }, - "br-html@6": { - "content_w": 728, - "need": 128, - "text": 104 - }, - "br-html@8": { - "content_w": 987, - "need": 128, - "text": 104 - }, - "caps-digits@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "caps-digits@3": { - "content_w": 340, - "need": 150, - "text": 126 - }, - "caps-digits@4": { - "content_w": 469, - "need": 128, - "text": 104 - }, - "caps-digits@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "caps-digits@8": { - "content_w": 987, - "need": 84, - "text": 60 - }, - "long-word@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "long-word@3": { - "content_w": 340, - "need": 128, - "text": 104 - }, - "long-word@4": { - "content_w": 469, - "need": 106, - "text": 82 - }, - "long-word@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "long-word@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "loose-list@12": { - "content_w": 1504, - "need": 142, - "text": 112 - }, - "loose-list@3": { - "content_w": 340, - "need": 164, - "text": 134 - }, - "loose-list@4": { - "content_w": 469, - "need": 142, - "text": 112 - }, - "loose-list@6": { - "content_w": 728, - "need": 142, - "text": 112 - }, - "loose-list@8": { - "content_w": 987, - "need": 142, - "text": 112 - }, - "nested-list@12": { - "content_w": 1504, - "need": 214, - "text": 184 - }, - "nested-list@3": { - "content_w": 340, - "need": 214, - "text": 184 - }, - "nested-list@4": { - "content_w": 469, - "need": 214, - "text": 184 - }, - "nested-list@6": { - "content_w": 728, - "need": 214, - "text": 184 - }, - "nested-list@8": { - "content_w": 987, - "need": 214, - "text": 184 - }, - "quote-code@12": { - "content_w": 1504, - "need": 179, - "text": 155 - }, - "quote-code@3": { - "content_w": 340, - "need": 201, - "text": 177 - }, - "quote-code@4": { - "content_w": 469, - "need": 179, - "text": 155 - }, - "quote-code@6": { - "content_w": 728, - "need": 179, - "text": 155 - }, - "quote-code@8": { - "content_w": 987, - "need": 179, - "text": 155 - }, - "section@12": { - "content_w": 1504, - "need": 196, - "text": 166 - }, - "section@3": { - "content_w": 340, - "need": 306, - "text": 276 - }, - "section@4": { - "content_w": 469, - "need": 240, - "text": 210 - }, - "section@6": { - "content_w": 728, - "need": 218, - "text": 188 - }, - "section@8": { - "content_w": 987, - "need": 218, - "text": 188 - }, - "setext@12": { - "content_w": 1504, - "need": 161, - "text": 137.2 - }, - "setext@3": { - "content_w": 340, - "need": 161, - "text": 137.2 - }, - "setext@4": { - "content_w": 469, - "need": 161, - "text": 137.2 - }, - "setext@6": { - "content_w": 728, - "need": 161, - "text": 137.2 - }, - "setext@8": { - "content_w": 987, - "need": 161, - "text": 137.2 - }, - "strip-bold@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "strip-bold@3": { - "content_w": 340, - "need": 62, - "text": 38 - }, - "strip-bold@4": { - "content_w": 469, - "need": 62, - "text": 38 - }, - "strip-bold@6": { - "content_w": 728, - "need": 62, - "text": 38 - }, - "strip-bold@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "strip-h1@12": { - "content_w": 1504, - "need": 95, - "text": 67.2 - }, - "strip-h1@3": { - "content_w": 340, - "need": 95, - "text": 67.2 - }, - "strip-h1@4": { - "content_w": 469, - "need": 95, - "text": 67.2 - }, - "strip-h1@6": { - "content_w": 728, - "need": 95, - "text": 67.2 - }, - "strip-h1@8": { - "content_w": 987, - "need": 95, - "text": 67.2 - }, - "strip-h2@12": { - "content_w": 1504, - "need": 80, - "text": 56 - }, - "strip-h2@3": { - "content_w": 340, - "need": 80, - "text": 56 - }, - "strip-h2@4": { - "content_w": 469, - "need": 80, - "text": 56 - }, - "strip-h2@6": { - "content_w": 728, - "need": 80, - "text": 56 - }, - "strip-h2@8": { - "content_w": 987, - "need": 80, - "text": 56 - }, - "strip-h3@12": { - "content_w": 1504, - "need": 66, - "text": 46.4 - }, - "strip-h3@3": { - "content_w": 340, - "need": 66, - "text": 46.4 - }, - "strip-h3@4": { - "content_w": 469, - "need": 66, - "text": 46.4 - }, - "strip-h3@6": { - "content_w": 728, - "need": 66, - "text": 46.4 - }, - "strip-h3@8": { - "content_w": 987, - "need": 66, - "text": 46.4 - }, - "strip-h4@12": { - "content_w": 1504, - "need": 66, - "text": 46.4 - }, - "strip-h4@3": { - "content_w": 340, - "need": 66, - "text": 46.4 - }, - "strip-h4@4": { - "content_w": 469, - "need": 66, - "text": 46.4 - }, - "strip-h4@6": { - "content_w": 728, - "need": 66, - "text": 46.4 - }, - "strip-h4@8": { - "content_w": 987, - "need": 66, - "text": 46.4 - }, - "strip-text@12": { - "content_w": 1504, - "need": 62, - "text": 38 - }, - "strip-text@3": { - "content_w": 340, - "need": 62, - "text": 38 - }, - "strip-text@4": { - "content_w": 469, - "need": 62, - "text": 38 - }, - "strip-text@6": { - "content_w": 728, - "need": 62, - "text": 38 - }, - "strip-text@8": { - "content_w": 987, - "need": 62, - "text": 38 - }, - "table@12": { - "content_w": 1504, - "need": 190, - "text": 174 - }, - "table@3": { - "content_w": 340, - "need": 256, - "text": 240 - }, - "table@4": { - "content_w": 469, - "need": 190, - "text": 174 - }, - "table@6": { - "content_w": 728, - "need": 190, - "text": 174 - }, - "table@8": { - "content_w": 987, - "need": 190, - "text": 174 - }, - "two-lines@12": { - "content_w": 1504, - "need": 84, - "text": 60 - }, - "two-lines@3": { - "content_w": 340, - "need": 84, - "text": 60 - }, - "two-lines@4": { - "content_w": 469, - "need": 84, - "text": 60 - }, - "two-lines@6": { - "content_w": 728, - "need": 84, - "text": 60 - }, - "two-lines@8": { - "content_w": 987, - "need": 84, - "text": 60 - } - }, "containers": { "footer@4": 469, "header@12": 1504, "sub-tab@6": 728 }, - "filter_bar": { - "about@12": { - "content_w": 1276, - "need": 1288, - "text": 1264 - }, - "about@3": { - "content_w": 283, - "need": 3818, - "text": 3794 - }, - "about@4": { - "content_w": 393, - "need": 2806, - "text": 2782 - }, - "about@6": { - "content_w": 614, - "need": 2014, - "text": 1990 - }, - "about@8": { - "content_w": 835, - "need": 1618, - "text": 1594 + "styles": { + "default": { + "blocks": [ + ["h1", 28, 39.2, 12, 12, 0, 0, 0, 0, 0, 500], + ["h2", 20, 28, 12, 8, 0, 0, 0, 0, 0, 500], + ["h3", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 500], + ["h4", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 400], + ["h5", 16, 22.4, 8, 4, 0, 0, 0, 0, 0, 400], + ["h6", 12, 16.8, 8, 4, 0, 0, 0, 0, 0, 400], + ["p", 14, 22, 0, 8, 0, 0, 0, 0, 0, 400], + ["p", 14, 22, 0, 8, 0, 0, 0, 0, 0, 400], + ["ul", 14, 22, 14, 14, 0, 0, 0, 0, 40, 400], + ["li", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["ul", 14, 22, 0, 0, 0, 0, 0, 0, 40, 400], + ["li", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["ol", 14, 22, 14, 14, 0, 0, 0, 0, 40, 400], + ["li", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["table", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["thead", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["th", 14, 22, 0, 0, 1, 1, 0, 0, 2, 500], + ["th", 14, 22, 0, 0, 1, 1, 0, 0, 2, 500], + ["tbody", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 22, 0, 0, 1, 1, 0, 0, 2, 400], + ["td", 14, 22, 0, 0, 1, 1, 0, 0, 2, 400], + ["blockquote", 14, 22, 14, 14, 0, 0, 0, 0, 80, 400], + ["p", 14, 22, 0, 8, 0, 0, 0, 0, 0, 400], + ["pre", 14, 22, 14, 14, 0, 0, 0, 0, 0, 400], + ["hr", 14, 22, 7, 7, 0, 0, 1, 1, 2, 400], + ["p", 14, 22, 0, 8, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + [6, ["strong"], 14, 22, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["code"], 14, 22, 0, 0, 0, 0, 0, 0, 0, 400], + [6, ["a"], 14, 22, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "line": { + "blocks": [ + ["h1", 28, 30, 12, 12, 0, 0, 0, 0, 0, 500], + ["h2", 20, 30, 12, 8, 0, 0, 0, 0, 0, 500], + ["h3", 16, 30, 8, 4, 0, 0, 0, 0, 0, 500], + ["h4", 16, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["h5", 16, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["h6", 12, 30, 8, 4, 0, 0, 0, 0, 0, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400], + ["ul", 14, 30, 14, 14, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["ul", 14, 30, 0, 0, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["ol", 14, 30, 14, 14, 0, 0, 0, 0, 40, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["li", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["table", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["thead", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["th", 14, 30, 0, 0, 1, 1, 0, 0, 2, 500], + ["th", 14, 30, 0, 0, 1, 1, 0, 0, 2, 500], + ["tbody", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + ["td", 14, 30, 0, 0, 1, 1, 0, 0, 2, 400], + ["td", 14, 30, 0, 0, 1, 1, 0, 0, 2, 400], + ["blockquote", 14, 30, 14, 14, 0, 0, 0, 0, 80, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400], + ["pre", 14, 30, 14, 14, 0, 0, 0, 0, 0, 400], + ["hr", 14, 30, 7, 7, 0, 0, 1, 1, 2, 400], + ["p", 14, 30, 0, 8, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + [6, ["strong"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["code"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 400], + [6, ["a"], 14, 30, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "probe": { + "blocks": [ + ["h1", 10, 14, 3, 5, 1, 2, 0, 0, 0, 500], + ["h2", 10, 14, 3, 5, 1, 2, 0, 0, 0, 500], + ["h3", 10, 14, 3, 5, 1, 2, 0, 0, 0, 500], + ["h4", 10, 14, 3, 5, 1, 2, 0, 0, 0, 400], + ["h5", 10, 14, 3, 5, 1, 2, 0, 0, 0, 400], + ["h6", 12, 16.8, 3, 5, 1, 2, 0, 0, 0, 400], + ["p", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["p", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["ul", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["ul", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["ol", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["li", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["table", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["thead", 10, 15.714, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["th", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 500], + ["th", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 500], + ["tbody", 10, 15.714, 0, 0, 0, 0, 0, 0, 0, 400], + ["tr", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["td", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["td", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["blockquote", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["p", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["pre", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + ["hr", 10, 15.714, 3, 5, 1, 2, 1, 1, 2, 400], + ["p", 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400] + ], + "inline": [ + [6, ["em"], 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + [6, ["strong"], 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 600], + [6, ["code"], 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400], + [6, ["a"], 10, 15.714, 3, 5, 1, 2, 0, 0, 0, 400] + ] + }, + "styled": { + "caption-change": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-floor": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-growth": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-next": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-rush": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-staff": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-stores": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-tills": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "caption-week": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "caption-years": { + "blocks": [ + ["h4", 17, 23.8, 0, 0, 0, 6, 0, 2, 0, 600], + ["p", 13, 18.2, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["span"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 400], + [1, ["strong"], 13, 18.2, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "footer": { + "blocks": [ + ["h5", 11.5, 16.1, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [] + }, + "notes": { + "blocks": [ + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 8, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["ul", 14, 22, 14, 14, 0, 0, 0, 0, 40, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["p", 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [1, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["em"], 12, 18, 0, 0, 0, 0, 0, 0, 0, 400], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [7, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [10, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [11, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600] + ] + }, + "summary-list": { + "blocks": [ + ["h3", 16, 22.4, 12, 6, 0, 0, 0, 0, 0, 600], + ["ul", 14, 22, 14, 14, 0, 0, 0, 0, 40, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["li", 14.5, 21.75, 0, 4, 0, 0, 0, 0, 0, 400], + ["p", 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [2, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [3, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [4, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [5, ["strong"], 14.5, 21.75, 0, 0, 0, 0, 0, 0, 0, 600], + [6, ["em"], 12, 18, 0, 0, 0, 0, 0, 0, 0, 400] + ] + }, + "title": { + "blocks": [ + ["h2", 24, 33.6, 0, 0, 0, 0, 0, 0, 0, 700], + ["p", 13, 19.5, 6, 0, 0, 0, 0, 0, 0, 400] + ], + "inline": [ + [1, ["code"], 12, 18, 0, 0, 1, 1, 0, 0, 8, 400] + ] + } } }, - "grid_w": 1536, - "grid_w_filter_bar": 1308, "verify": { "about@fits": { - "box": 1776, - "height": 44.4, - "scrolls": false, - "text": 1748 + "box": 1904, + "height": 47.6, + "scrolls": true, + "text": 1968, + "width": 6 }, "about@fix": { - "box": 1800, - "height": 45.0, + "box": 2040, + "height": 51.0, "scrolls": false, - "text": 1748 + "text": 1924, + "width": 6 }, "about@short": { - "box": 1736, - "height": 43.4, + "box": 1864, + "height": 46.6, "scrolls": true, - "text": 1748 + "text": 1968, + "width": 6 }, "br-html@fits": { "box": 128, "height": 3.2, "scrolls": false, - "text": 104 + "text": 104, + "width": 6 }, "br-html@fix": { "box": 128, "height": 3.2, "scrolls": false, - "text": 104 + "text": 104, + "width": 6 }, "br-html@short": { "box": 88, "height": 2.2, "scrolls": true, - "text": 104 + "text": 104, + "width": 6 }, "caps-digits@fits": { - "box": 88, - "height": 2.2, + "box": 112, + "height": 2.8, "scrolls": false, - "text": 60 + "text": 82, + "width": 6 }, "caps-digits@fix": { "box": 112, "height": 2.8, "scrolls": false, - "text": 60 + "text": 82, + "width": 6 }, "caps-digits@short": { - "box": 48, - "height": 1.2, + "box": 72, + "height": 1.8, "scrolls": true, - "text": 60 + "text": 82, + "width": 6 }, "long-word@fits": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "long-word@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "long-word@short": { "box": 48, "height": 1.2, "scrolls": true, - "text": 60 + "text": 60, + "width": 6 }, "loose-list@fits": { "box": 144, "height": 3.6, "scrolls": false, - "text": 112 + "text": 112, + "width": 6 }, "loose-list@fix": { "box": 144, "height": 3.6, "scrolls": false, - "text": 112 + "text": 112, + "width": 6 }, "loose-list@short": { "box": 104, "height": 2.6, "scrolls": true, - "text": 112 + "text": 112, + "width": 6 }, "nested-list@fits": { "box": 216, "height": 5.4, "scrolls": false, - "text": 184 + "text": 184, + "width": 6 }, "nested-list@fix": { "box": 216, "height": 5.4, "scrolls": false, - "text": 184 + "text": 184, + "width": 6 }, "nested-list@short": { "box": 176, "height": 4.4, "scrolls": true, - "text": 184 + "text": 184, + "width": 6 }, "quote-code@fits": { "box": 184, "height": 4.6, "scrolls": false, - "text": 155 + "text": 155, + "width": 6 }, "quote-code@fix": { "box": 216, "height": 5.4, "scrolls": false, - "text": 155 + "text": 155, + "width": 6 }, "quote-code@short": { "box": 144, "height": 3.6, "scrolls": true, - "text": 155 + "text": 155, + "width": 6 }, "section@fits": { - "box": 224, - "height": 5.6, + "box": 240, + "height": 6.0, "scrolls": false, - "text": 188 + "text": 210, + "width": 6 }, "section@fix": { - "box": 224, - "height": 5.6, + "box": 240, + "height": 6.0, "scrolls": false, - "text": 188 + "text": 210, + "width": 6 }, "section@short": { - "box": 184, - "height": 4.6, + "box": 200, + "height": 5.0, "scrolls": true, - "text": 188 + "text": 210, + "width": 6 }, "setext@fits": { "box": 168, "height": 4.2, "scrolls": false, - "text": 137.2 + "text": 137.2, + "width": 6 }, "setext@fix": { "box": 168, "height": 4.2, "scrolls": false, - "text": 137.2 + "text": 137.2, + "width": 6 }, "setext@short": { "box": 128, "height": 3.2, "scrolls": true, - "text": 137.2 + "text": 137.2, + "width": 6 }, "strip-bold@fits": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-bold@fix": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-bold@short": { "box": 40, "height": 0.6, "scrolls": true, - "text": 38 + "text": 38, + "width": 6 }, "strip-h1@fits": { "box": 96, "height": 2.4, "scrolls": false, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h1@fix": { "box": 96, "height": 2.4, "scrolls": false, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h1@short": { "box": 56, "height": 1.4, "scrolls": true, - "text": 67.2 + "text": 67.2, + "width": 6 }, "strip-h2@fits": { "box": 80, "height": 2.0, "scrolls": false, - "text": 56 + "text": 56, + "width": 6 }, "strip-h2@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 56 + "text": 56, + "width": 6 }, "strip-h2@short": { "box": 40, "height": 1.0, "scrolls": true, - "text": 56 + "text": 56, + "width": 6 }, "strip-h3@fits": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h3@fix": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h3@short": { "box": 40, "height": 0.8, "scrolls": true, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@fits": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@fix": { "box": 72, "height": 1.8, "scrolls": false, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-h4@short": { "box": 40, "height": 0.8, "scrolls": true, - "text": 46.4 + "text": 46.4, + "width": 6 }, "strip-text@fits": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-text@fix": { "box": 64, "height": 1.6, "scrolls": false, - "text": 38 + "text": 38, + "width": 6 }, "strip-text@short": { "box": 40, "height": 0.6, "scrolls": true, - "text": 38 + "text": 38, + "width": 6 }, "table@fits": { "box": 192, "height": 4.8, "scrolls": false, - "text": 174 + "text": 174, + "width": 6 }, "table@fix": { "box": 192, "height": 4.8, "scrolls": false, - "text": 174 + "text": 174, + "width": 6 }, "table@short": { "box": 152, "height": 3.8, "scrolls": true, - "text": 174 + "text": 174, + "width": 6 }, "two-lines@fits": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "two-lines@fix": { "box": 88, "height": 2.2, "scrolls": false, - "text": 60 + "text": 60, + "width": 6 }, "two-lines@short": { "box": 48, "height": 1.2, "scrolls": true, - "text": 60 + "text": 60, + "width": 6 + } + }, + "verify_css": { + "caption-change@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 108.4, + "width": 12 + }, + "caption-change@then": { + "box": 112, + "height": 2.8, + "scrolls": true, + "text": 108.4, + "width": 12 + }, + "caption-floor@fix": { + "box": 128, + "height": 3.2, + "scrolls": false, + "text": 108.4, + "width": 12 + }, + "caption-floor@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 108.4, + "width": 12 + }, + "caption-growth@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-growth@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-next@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-next@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-rush@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-rush@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-staff@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-staff@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-stores@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-stores@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "caption-tills@fix": { + "box": 112, + "height": 2.8, + "scrolls": false, + "text": 90.2, + "width": 12 + }, + "caption-tills@then": { + "box": 96, + "height": 2.4, + "scrolls": true, + "text": 90.2, + "width": 12 + }, + "caption-week@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-week@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "caption-years@fix": { + "box": 88, + "height": 2.2, + "scrolls": false, + "text": 72, + "width": 12 + }, + "caption-years@then": { + "box": 80, + "height": 2.0, + "scrolls": true, + "text": 72, + "width": 12 + }, + "footer@fix": { + "box": 48, + "height": 1.2, + "scrolls": false, + "text": 32.1, + "width": 12 + }, + "footer@then": { + "box": 48, + "height": 1.2, + "scrolls": false, + "text": 32.1, + "width": 12 + }, + "notes@fix": { + "box": 640, + "height": 16.0, + "scrolls": false, + "text": 620.3, + "width": 12 + }, + "notes@then": { + "box": 600, + "height": 15, + "scrolls": true, + "text": 620.3, + "width": 12 + }, + "summary-list@fix": { + "box": 592, + "height": 14.8, + "scrolls": false, + "text": 525.4, + "width": 4 + }, + "summary-list@then": { + "box": 520, + "height": 13, + "scrolls": true, + "text": 568.9, + "width": 4 + }, + "title@fix": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 75.1, + "width": 9 + }, + "title@then": { + "box": 96, + "height": 2.4, + "scrolls": false, + "text": 75.1, + "width": 9 + } + }, + "viewports": { + "1440": { + "blocks": { + "about@12": { + "content_w": 1344, + "need": 1222, + "text": 1198 + }, + "about@3": { + "content_w": 300, + "need": 3510, + "text": 3486 + }, + "about@4": { + "content_w": 416, + "need": 2674, + "text": 2650 + }, + "about@6": { + "content_w": 648, + "need": 1904, + "text": 1880 + }, + "about@8": { + "content_w": 880, + "need": 1552, + "text": 1528 + }, + "br-html@12": { + "content_w": 1344, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 300, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 416, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 648, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 880, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1344, + "need": 84, + "text": 60 + }, + "caps-digits@3": { + "content_w": 300, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 416, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 648, + "need": 106, + "text": 82 + }, + "caps-digits@8": { + "content_w": 880, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 300, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 416, + "need": 106, + "text": 82 + }, + "long-word@6": { + "content_w": 648, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 880, + "need": 84, + "text": 60 + }, + "loose-list@12": { + "content_w": 1344, + "need": 142, + "text": 112 + }, + "loose-list@3": { + "content_w": 300, + "need": 186, + "text": 156 + }, + "loose-list@4": { + "content_w": 416, + "need": 142, + "text": 112 + }, + "loose-list@6": { + "content_w": 648, + "need": 142, + "text": 112 + }, + "loose-list@8": { + "content_w": 880, + "need": 142, + "text": 112 + }, + "nested-list@12": { + "content_w": 1344, + "need": 214, + "text": 184 + }, + "nested-list@3": { + "content_w": 300, + "need": 214, + "text": 184 + }, + "nested-list@4": { + "content_w": 416, + "need": 214, + "text": 184 + }, + "nested-list@6": { + "content_w": 648, + "need": 214, + "text": 184 + }, + "nested-list@8": { + "content_w": 880, + "need": 214, + "text": 184 + }, + "quote-code@12": { + "content_w": 1344, + "need": 179, + "text": 155 + }, + "quote-code@3": { + "content_w": 300, + "need": 201, + "text": 177 + }, + "quote-code@4": { + "content_w": 416, + "need": 201, + "text": 177 + }, + "quote-code@6": { + "content_w": 648, + "need": 179, + "text": 155 + }, + "quote-code@8": { + "content_w": 880, + "need": 179, + "text": 155 + }, + "section@12": { + "content_w": 1344, + "need": 196, + "text": 166 + }, + "section@3": { + "content_w": 300, + "need": 350, + "text": 320 + }, + "section@4": { + "content_w": 416, + "need": 284, + "text": 254 + }, + "section@6": { + "content_w": 648, + "need": 240, + "text": 210 + }, + "section@8": { + "content_w": 880, + "need": 218, + "text": 188 + }, + "setext@12": { + "content_w": 1344, + "need": 161, + "text": 137.2 + }, + "setext@3": { + "content_w": 300, + "need": 161, + "text": 137.2 + }, + "setext@4": { + "content_w": 416, + "need": 161, + "text": 137.2 + }, + "setext@6": { + "content_w": 648, + "need": 161, + "text": 137.2 + }, + "setext@8": { + "content_w": 880, + "need": 161, + "text": 137.2 + }, + "strip-bold@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 300, + "need": 84, + "text": 60 + }, + "strip-bold@4": { + "content_w": 416, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 648, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 880, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1344, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 300, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 416, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 648, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 880, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1344, + "need": 80, + "text": 56 + }, + "strip-h2@3": { + "content_w": 300, + "need": 80, + "text": 56 + }, + "strip-h2@4": { + "content_w": 416, + "need": 80, + "text": 56 + }, + "strip-h2@6": { + "content_w": 648, + "need": 80, + "text": 56 + }, + "strip-h2@8": { + "content_w": 880, + "need": 80, + "text": 56 + }, + "strip-h3@12": { + "content_w": 1344, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 300, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 416, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 648, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 880, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1344, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 300, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 416, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 648, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 880, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1344, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 300, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 416, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 648, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 880, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1344, + "need": 190, + "text": 174 + }, + "table@3": { + "content_w": 300, + "need": 278, + "text": 262 + }, + "table@4": { + "content_w": 416, + "need": 190, + "text": 174 + }, + "table@6": { + "content_w": 648, + "need": 190, + "text": 174 + }, + "table@8": { + "content_w": 880, + "need": 190, + "text": 174 + }, + "two-lines@12": { + "content_w": 1344, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 300, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 416, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 648, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 880, + "need": 84, + "text": 60 + } + }, + "filter_bar": { + "about@12": { + "content_w": 1116, + "need": 1398, + "text": 1374 + }, + "about@3": { + "content_w": 243, + "need": 4396, + "text": 4372 + }, + "about@4": { + "content_w": 340, + "need": 3246, + "text": 3222 + }, + "about@6": { + "content_w": 534, + "need": 2190, + "text": 2166 + }, + "about@8": { + "content_w": 728, + "need": 1772, + "text": 1748 + } + }, + "grid_w": 1376, + "grid_w_filter_bar": 1148, + "styled": { + "caption-change": { + "content_w": 1344, + "need": 124, + "text": 108.4 + }, + "caption-floor": { + "content_w": 1344, + "need": 124, + "text": 108.4 + }, + "caption-growth": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-next": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-rush": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-staff": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-stores": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "caption-tills": { + "content_w": 1344, + "need": 106, + "text": 90.2 + }, + "caption-week": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "caption-years": { + "content_w": 1344, + "need": 88, + "text": 72 + }, + "footer": { + "content_w": 1344, + "need": 48, + "text": 32.1 + }, + "notes": { + "content_w": 1344, + "need": 636, + "text": 620.3 + }, + "summary-list": { + "content_w": 416, + "need": 541, + "text": 525.4 + }, + "title": { + "content_w": 996, + "need": 91, + "text": 75.1 + } + } + }, + "1600": { + "blocks": { + "about@12": { + "content_w": 1504, + "need": 1156, + "text": 1132 + }, + "about@3": { + "content_w": 340, + "need": 3246, + "text": 3222 + }, + "about@4": { + "content_w": 469, + "need": 2432, + "text": 2408 + }, + "about@6": { + "content_w": 728, + "need": 1772, + "text": 1748 + }, + "about@8": { + "content_w": 987, + "need": 1442, + "text": 1418 + }, + "br-html@12": { + "content_w": 1504, + "need": 128, + "text": 104 + }, + "br-html@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "br-html@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "br-html@6": { + "content_w": 728, + "need": 128, + "text": 104 + }, + "br-html@8": { + "content_w": 987, + "need": 128, + "text": 104 + }, + "caps-digits@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "caps-digits@3": { + "content_w": 340, + "need": 150, + "text": 126 + }, + "caps-digits@4": { + "content_w": 469, + "need": 128, + "text": 104 + }, + "caps-digits@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "caps-digits@8": { + "content_w": 987, + "need": 84, + "text": 60 + }, + "long-word@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "long-word@3": { + "content_w": 340, + "need": 128, + "text": 104 + }, + "long-word@4": { + "content_w": 469, + "need": 106, + "text": 82 + }, + "long-word@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "long-word@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "loose-list@12": { + "content_w": 1504, + "need": 142, + "text": 112 + }, + "loose-list@3": { + "content_w": 340, + "need": 164, + "text": 134 + }, + "loose-list@4": { + "content_w": 469, + "need": 142, + "text": 112 + }, + "loose-list@6": { + "content_w": 728, + "need": 142, + "text": 112 + }, + "loose-list@8": { + "content_w": 987, + "need": 142, + "text": 112 + }, + "nested-list@12": { + "content_w": 1504, + "need": 214, + "text": 184 + }, + "nested-list@3": { + "content_w": 340, + "need": 214, + "text": 184 + }, + "nested-list@4": { + "content_w": 469, + "need": 214, + "text": 184 + }, + "nested-list@6": { + "content_w": 728, + "need": 214, + "text": 184 + }, + "nested-list@8": { + "content_w": 987, + "need": 214, + "text": 184 + }, + "quote-code@12": { + "content_w": 1504, + "need": 179, + "text": 155 + }, + "quote-code@3": { + "content_w": 340, + "need": 201, + "text": 177 + }, + "quote-code@4": { + "content_w": 469, + "need": 179, + "text": 155 + }, + "quote-code@6": { + "content_w": 728, + "need": 179, + "text": 155 + }, + "quote-code@8": { + "content_w": 987, + "need": 179, + "text": 155 + }, + "section@12": { + "content_w": 1504, + "need": 196, + "text": 166 + }, + "section@3": { + "content_w": 340, + "need": 306, + "text": 276 + }, + "section@4": { + "content_w": 469, + "need": 240, + "text": 210 + }, + "section@6": { + "content_w": 728, + "need": 218, + "text": 188 + }, + "section@8": { + "content_w": 987, + "need": 218, + "text": 188 + }, + "setext@12": { + "content_w": 1504, + "need": 161, + "text": 137.2 + }, + "setext@3": { + "content_w": 340, + "need": 161, + "text": 137.2 + }, + "setext@4": { + "content_w": 469, + "need": 161, + "text": 137.2 + }, + "setext@6": { + "content_w": 728, + "need": 161, + "text": 137.2 + }, + "setext@8": { + "content_w": 987, + "need": 161, + "text": 137.2 + }, + "strip-bold@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-bold@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-bold@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-bold@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-bold@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "strip-h1@12": { + "content_w": 1504, + "need": 95, + "text": 67.2 + }, + "strip-h1@3": { + "content_w": 340, + "need": 95, + "text": 67.2 + }, + "strip-h1@4": { + "content_w": 469, + "need": 95, + "text": 67.2 + }, + "strip-h1@6": { + "content_w": 728, + "need": 95, + "text": 67.2 + }, + "strip-h1@8": { + "content_w": 987, + "need": 95, + "text": 67.2 + }, + "strip-h2@12": { + "content_w": 1504, + "need": 80, + "text": 56 + }, + "strip-h2@3": { + "content_w": 340, + "need": 80, + "text": 56 + }, + "strip-h2@4": { + "content_w": 469, + "need": 80, + "text": 56 + }, + "strip-h2@6": { + "content_w": 728, + "need": 80, + "text": 56 + }, + "strip-h2@8": { + "content_w": 987, + "need": 80, + "text": 56 + }, + "strip-h3@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h3@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h3@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h3@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h3@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-h4@12": { + "content_w": 1504, + "need": 66, + "text": 46.4 + }, + "strip-h4@3": { + "content_w": 340, + "need": 66, + "text": 46.4 + }, + "strip-h4@4": { + "content_w": 469, + "need": 66, + "text": 46.4 + }, + "strip-h4@6": { + "content_w": 728, + "need": 66, + "text": 46.4 + }, + "strip-h4@8": { + "content_w": 987, + "need": 66, + "text": 46.4 + }, + "strip-text@12": { + "content_w": 1504, + "need": 62, + "text": 38 + }, + "strip-text@3": { + "content_w": 340, + "need": 62, + "text": 38 + }, + "strip-text@4": { + "content_w": 469, + "need": 62, + "text": 38 + }, + "strip-text@6": { + "content_w": 728, + "need": 62, + "text": 38 + }, + "strip-text@8": { + "content_w": 987, + "need": 62, + "text": 38 + }, + "table@12": { + "content_w": 1504, + "need": 190, + "text": 174 + }, + "table@3": { + "content_w": 340, + "need": 256, + "text": 240 + }, + "table@4": { + "content_w": 469, + "need": 190, + "text": 174 + }, + "table@6": { + "content_w": 728, + "need": 190, + "text": 174 + }, + "table@8": { + "content_w": 987, + "need": 190, + "text": 174 + }, + "two-lines@12": { + "content_w": 1504, + "need": 84, + "text": 60 + }, + "two-lines@3": { + "content_w": 340, + "need": 84, + "text": 60 + }, + "two-lines@4": { + "content_w": 469, + "need": 84, + "text": 60 + }, + "two-lines@6": { + "content_w": 728, + "need": 84, + "text": 60 + }, + "two-lines@8": { + "content_w": 987, + "need": 84, + "text": 60 + } + }, + "filter_bar": { + "about@12": { + "content_w": 1276, + "need": 1288, + "text": 1264 + }, + "about@3": { + "content_w": 283, + "need": 3818, + "text": 3794 + }, + "about@4": { + "content_w": 393, + "need": 2806, + "text": 2782 + }, + "about@6": { + "content_w": 614, + "need": 2014, + "text": 1990 + }, + "about@8": { + "content_w": 835, + "need": 1618, + "text": 1594 + } + }, + "grid_w": 1536, + "grid_w_filter_bar": 1308, + "styled": { + "caption-change": { + "content_w": 1504, + "need": 124, + "text": 108.4 + }, + "caption-floor": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-growth": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-next": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-rush": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-staff": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-stores": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "caption-tills": { + "content_w": 1504, + "need": 106, + "text": 90.2 + }, + "caption-week": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "caption-years": { + "content_w": 1504, + "need": 88, + "text": 72 + }, + "footer": { + "content_w": 1504, + "need": 48, + "text": 32.1 + }, + "notes": { + "content_w": 1504, + "need": 615, + "text": 598.6 + }, + "summary-list": { + "content_w": 469, + "need": 498, + "text": 481.9 + }, + "title": { + "content_w": 1116, + "need": 91, + "text": 75.1 + } + } } } } }, - "widths": [ - 12, - 8, - 6, - 4, - 3 - ] + "style_probe": { + "line": ".dashboard-markdown h1 { line-height: 30px; }\n.dashboard-markdown h2 { line-height: 30px; }\n.dashboard-markdown h3 { line-height: 30px; }\n.dashboard-markdown h4 { line-height: 30px; }\n.dashboard-markdown h5 { line-height: 30px; }\n.dashboard-markdown h6 { line-height: 30px; }\n.dashboard-markdown p { line-height: 30px; }\n.dashboard-markdown ul { line-height: 30px; }\n.dashboard-markdown ol { line-height: 30px; }\n.dashboard-markdown li { line-height: 30px; }\n.dashboard-markdown table { line-height: 30px; }\n.dashboard-markdown th { line-height: 30px; }\n.dashboard-markdown td { line-height: 30px; }\n.dashboard-markdown tr { line-height: 30px; }\n.dashboard-markdown blockquote { line-height: 30px; }\n.dashboard-markdown pre { line-height: 30px; }\n.dashboard-markdown hr { line-height: 30px; }\n.dashboard-markdown em { line-height: 30px; }\n.dashboard-markdown strong { line-height: 30px; }\n.dashboard-markdown code { line-height: 30px; }\n.dashboard-markdown a { line-height: 30px; }", + "markdown": "# H1 text\n## H2 text\n### H3 text\n#### H4 text\n##### H5 text\n###### H6 text\n\nPara with *em* and **strong** and `code` and [a link](http://x).\n\nSecond para.\n\n- item one\n- item two\n - nested\n\n1. first\n2. second\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n> quoted\n\n```\npre code\n```\n\n---\n\nLast para.", + "probe": ".dashboard-markdown h1 { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown h2 { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown h3 { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown h4 { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown h5 { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown h6 { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown p { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown ul { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown ol { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown li { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown table { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown th { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown td { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown tr { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown blockquote { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown pre { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown hr { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown em { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown strong { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown code { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }\n.dashboard-markdown a { font-size: 10px; margin: 3px 0 5px; padding: 1px 0 2px; }" + }, + "styled": [ + ["title", 9, 2.4, "## STOREFRONT\nQuarterly sales review for the regional store network · 48 stores, 46 ranked · orders since 2016, returns since Mar 2019 · built from `storefront.json`"], + ["caption-week", 12, 2.0, "#### Orders across the week\n**Apr–Jun 2026** · **Apr–Jun 2025** · **Target** — orders as a share of each week's busiest day, by weekday. Titles: the typical Monday lift; two quiet outlets show a 3-day sum."], + ["caption-next", 12, 2.4, "#### Stores to visit next\nBranches that gained 150 orders a week or more. Shaded: growth well outside the usual seasonal swing. A jump over one year that vanishes over two is a bounce. Lift ÷ peak: the hard-week 3-day lift in orders against the best week. ◇ an estimate; a greyed yearly change has no till count behind it, and greyed refunds sit outside ±2% (Notes tab)."], + ["summary-list", 4, 13, "### What changed · 12 months to Sep 2026\n- **North region grew fastest:** repeat orders rose 3.5 k a week (+4.8%). The city branch sells 53% more than in 2019, the mall 23% more; Store 12 (+12.3%) and Store 7 (+7.8%) led.\n- **Weekend staffing falls short.** On the busiest Saturday in ten, ◇ over two extra shifts are needed in 18 of 20 large stores: Store 3 needs 2.9 and Store 9 needs 3.2. Only Stores 1 and 4 cope.\n- **Two stores already have slack.** ◇ Their rotas exceed peak demand (1.48×, 1.27×), and their part-time pools hold 2.3× and 4.5× the hours a busy Saturday uses. Store 3 covers just 0.04× of its own Saturday peak.\n- **Some gains look durable.** Across the year, weekly orders in 5 stores (2, 5, 7, 12 and 15) sat above their seasonal range for three months or longer, and basket size did in 7. Delivery has a full year of history in only 5 stores, none above its range so far. With 46 stores tested, roughly one such result could be luck alone.\n\n*Updated Sat 3 Oct 2026 from the overnight load. Rankings cover store sales only; regional budgets come from finance.*"], + ["caption-rush", 12, 2.4, "#### When the rush arrives\nShows the store selected in the filters. Left, orders per hour across the months: a darker cell means more orders, and the lunch peak grows as delivery spreads. Right, each day's evening rush, whiskers at the busiest day in ten: rotas are planned for the whisker, not the box."], + ["caption-change", 12, 2.8, "#### What moved the basket, and will it hold?\nThe waterfall breaks the quarter-on-quarter movement in the **average** basket, Apr–Jun 2025 to Apr–Jun 2026, into price, volume and product mix: each bar is the part one driver added to the total. **raises the basket** · **lowers it**. The cards above use the median order. Right, each month's basket change scaled by how much that store normally varies: **outside the range** for three months in a row counts as durable, as does a **12-month average** past the dotted line. The waterfall needs sales in both quarters (none for the two new stores); the range needs four prior years of history (not Stores 41 or 46)."], + ["caption-years", 12, 2.0, "#### Eight years of Saturday peaks\n**Median Saturday** · **Busiest Saturday**, by month since online orders were first logged; gaps mark months a store was shut for a refit."], + ["caption-growth", 12, 2.4, "#### Which stores grow, and how steadily?\nRepeat orders, the share from returning customers: loyalty schemes push it up and promotions hardly shift it. **Repeat orders added** · **All orders added**: where the two agree, growth comes from regulars. All orders swing with promotions and track the sale calendar. Listed branches gained 200 weekly orders or more."], + ["caption-stores", 12, 2.0, "#### Where repeat customers shop\nLeft, the same stores against their own 2019 sales. Right, two city branches, the riverside store (RIV) and the station store (STN), whose busiest hours fall at lunchtime, so their weekday base is mostly office trade."], + ["caption-staff", 12, 2.0, "#### Which stores are staffed for Saturday, and which need help?\nRostered staff per region, and on the left the rota's hours against the busiest Saturday: **on the rota** · **with open vacancies**; 1 means the rota exactly meets the peak (stores needing 40 hours or more). On the right, extra shifts needed to shorten the Saturday queue by a tenth on the hardest day in ten: above 2, the pool falls short and agency cover is needed."], + ["caption-floor", 12, 2.4, "#### Who serves the evening rush?\nFor the selected store: who is on the shop floor at 13:00, the lunch peak, and at 19:00, after offices close; stockroom and delivery staff aren't counted. Self-checkout use appears only where a store reports it to head office, and some stores count assisted sales twice while others count them once (Store 9 changed method last December), which is why the hourly view shows assisted sales alone. A month where tills and ledger disagree by over 5% stays empty (Store 22 from Dec 2025)."], + ["notes", 12, 15, "### Busy hours\n**Busy hours** are the three consecutive hours with the most till transactions in a trading day, counted from opening to closing time and only on days the store is open for at least eight hours. Click-and-collect orders are left out, because they are picked before the doors open and would pull the busiest window into the early morning. Each store shows two figures: a **typical Saturday**, the median, and a **hard Saturday**, the ninth worst in ten.\n\n### Loyal customers\n**◇ Loyalty** is an estimate: the share of transactions paid with a card the store has seen in the previous ninety days. Cards stand in for identity, so cash customers are invisible to it, and stores near tourist sites read low all summer. The ranking greys any store whose cash share moved by more than five points. **Basket size** is the value of an average transaction before discounts and refunds, with gift cards left out. Figures marked *index* are scaled so the first full year is one hundred.\n\n### Is it lasting?\nA store's monthly result is compared with the same month of the previous year, then scaled by how much that store usually varies in that month, using every year from 2019–2025. Holidays that move between months, such as Easter, are averaged out first. A change is **lasting** when the scaled result stays outside the usual range for three consecutive months; with 46 stores, about one store a month would cross the range on noise alone, and about two would do so at least once in a year. A change is also **lasting** when the full year, averaged, lies far beyond the range. Stores open for fewer than four full years have no usual range yet and show no verdict.\n\n### The waterfall\nThe waterfall breaks the movement in **average basket value** between two quarters into price, volume and product mix, in that order, so the three bars always add up to the total. The order matters: moving mix first would shift a little from price to mix.\n\n### ◇ Estimates\n- **Rota ÷ Saturday peak:** staffed hours on the published rota divided by the hours the busiest Saturday in ten would need. Rotas change during the week and sickness is not recorded, so the ratio overstates cover on bad days. The ranking table uses the same ratio against the typical Saturday. Rotas older than six weeks fall back to the store's template.\n- **Extra shifts:** how many eight-hour shifts it takes to bring the hard Saturday's queue down by a tenth, assuming every shift starts on time.\n\n### What the figures don't cover\nOnline-only orders, wholesale accounts, staff costs and margins are not in the till data. Use this page to choose which stores to visit; it does not set any budgets."], + ["caption-tills", 12, 2.4, "#### Do the tills add up?\nTill variance is (card takings + cash takings − ledger sales) ÷ ledger sales and should stay close to zero; months outside ±2% are greyed. The remaining 39 stores agree within ±1%. Store 22's card data is incomplete from Dec 2025; its basket figures, which use card payments only, still hold. Store 9 has run above +2% since June."], + ["footer", 12, 1.2, "##### Source: store till system and head-office ledger, nightly extract (September 2026), for internal use only · ◇ = estimated figure, see the Notes tab for how each one is made · made with Chartwright from storefront.json"] + ], + "styled_css": "/* Storefront: layout first, then the markdown's text sizes. */\n.dashboard-component-tabs,\n.dashboard-component-tabs .dashboard-component-tabs-content { background-color: #F5F6F8 !important; }\n.dashboard-component-tabs .ant-tabs-tab { font-size: 13px; padding: 8px 0; }\n.dashboard-component-chart-holder { border: 1px solid #E2E5EA; border-radius: 4px; }\n.background--white .dashboard-component-chart-holder { border: 0; }\n.dashboard-markdown .dashboard-component-chart-holder { background: transparent; border: 0; }\n.header-title { font-size: 14px !important; font-weight: 600 !important; }\n.dashboard-markdown { color: #20242C; }\n.dashboard-markdown h2 { font-size: 24px; font-weight: 700; letter-spacing: -.02em; margin: 0; }\n.dashboard-markdown h2 + p { font-size: 13px; color: #5F6672; margin: 6px 0 0; }\n.dashboard-markdown h3 { font-size: 16px; font-weight: 600; margin: 12px 0 6px; }\n.dashboard-markdown h4 { font-size: 17px; font-weight: 600 !important; margin: 0;\n padding-bottom: 6px; border-bottom: 2px solid #DDE1E7; }\n.dashboard-markdown h4 + p { font-size: 13px; color: #5F6672; margin: 6px 0 0; line-height: 1.4; }\n.dashboard-markdown h4 + p strong { color: #20242C; font-weight: 600; }\n.dashboard-markdown p, .dashboard-markdown li { font-size: 14.5px; line-height: 1.5; }\n/* no gap under the last paragraph or list */\n.dashboard-markdown p:last-child, .dashboard-markdown ul:last-child { margin-bottom: 0 !important; }\n.dashboard-markdown li { margin: 0 0 4px; }\n/* 11.5 px on 1.4 lines: 16.1 px, a tenth of a px past a 48 px block's room */\n.dashboard-markdown h5 { font-size: 11.5px; font-weight: 400; color: #5F6672; margin: 0; }\n.dashboard-markdown em { color: #5F6672; font-style: normal; font-size: 12px; }\n.dashboard-markdown code { font-family: inherit; font-size: 12px; background: #EEF0F3; padding: 1px 4px; }\n/* colour keys: survives the sanitizer */\n.dashboard-markdown [itemprop]::before { content: \"\"; display: inline-block; width: 12px; height: 8px;\n margin: 0 4px 0 2px; }\n.dashboard-markdown [itemprop=\"this-year\"]::before { background: #1F3A5F; }\n.dashboard-markdown [itemprop=\"last-year\"]::before { background: #B9C2CE; }\n.dashboard-markdown [itemprop=\"target\"]::before { background: #E3B04B; }\n.dashboard-markdown [itemprop=\"up\"]::before { background: #2E7D5B; }\n.dashboard-markdown [itemprop=\"down\"]::before { background: #B4463A; }\n.dashboard-markdown [itemprop=\"trend\"]::before { background: #55606E; }\n/* other components */\n[data-test-viz-type=\"big_number_total\"] .header-line { font-size: 32px !important; line-height: 1.1 !important; }\n[data-test-chart-name=\"Updated\"] { position: relative; }\n.superset-chart-table table tbody td { border-top: 1px solid #EEF0F3; }", + "widths": [12, 8, 6, 4, 3] } diff --git a/tests/test_markdown_fit.py b/tests/test_markdown_fit.py index 06b408e..4c7a04c 100644 --- a/tests/test_markdown_fit.py +++ b/tests/test_markdown_fit.py @@ -2,16 +2,22 @@ (chartwright/design/markdown_fit.py), against what Superset drew. tests/fixtures/markdown_fit/measurements.json holds what 4.1.4, 5.0.0 and 6.1.0 drew in -headless Chromium at a 1600 px viewport on 2026-10-05 (tools/record_markdown_fit.py): +headless Chromium on 2026-10-05 (tools/record_markdown_fit.py), in windows 1600 and +1440 px wide: - 17 texts (definitions in five sections, one-line strips of each heading level, lists tight, loose and nested, a table, a quote with code and a rule, hard breaks,
, setext headings, a long URL, capitals and digits) at 12, 8, 6, 4 and 3 twelfths, and the long text beside the filter bar. Each with the height it needs to show with no inner scrollbar and how far down its text reaches, in px; -- each text at 6/12 at three heights, with Windows-style scrollbars on: the height the - fix writes, the height it needs (to an 8 px grid row) and a unit less. Whether it - scrolled, and where its text ended. The screenshots behind them showed text cut off - at the bottom edge exactly where `text` passes `box`. +- a dashboard's own markdown blocks under its own CSS (a sales review: a title, + section captions with colour keys, a list, notes in sections, a footer), the same + way; +- every element's computed type with no CSS, with each property the estimate reads + set on each element from `.dashboard-markdown X`, and under that dashboard's CSS; +- in the 1440 px window, with Windows-style scrollbars on: each text at 6/12 and each + styled block at its own width, at the height the fix writes and at heights that + cut it off. Whether it scrolled, and where its text ended. The screenshots behind + them showed text cut off at the bottom edge exactly where `text` passes `box`. The low bound must never exceed what any release drew (or the rule warns of a block that fits), and the high bound never fall short of it (or the fix leaves it cut off). @@ -23,8 +29,9 @@ import pytest from chartwright.design import advise, advise_and_fix -from chartwright.design.markdown_fit import (TYPOGRAPHY, box_px, estimate, parse, plain, - text_width, wrapped_lines) +from chartwright.design import markdown_fit as mf +from chartwright.design.markdown_fit import (RELEASES, VIEWPORT, box_px, estimate, parse, + plain, read_css, text_width, tree, wrapped_lines) from chartwright.design.model import RULES from chartwright.design.presets import Overlay from chartwright.spec import load_spec @@ -32,86 +39,257 @@ MEASURED = json.loads((Path(__file__).parent / "fixtures" / "markdown_fit" / "measurements.json").read_text(encoding="utf-8")) CORPUS = MEASURED["corpus"] -RELEASES = MEASURED["releases"] +STYLED = {name: (width, then, text) for name, width, then, text in MEASURED["styled"]} +STYLED_CSS = MEASURED["styled_css"] +DRAWN = MEASURED["releases"] ABOUT = CORPUS["about"] EMPTY = Overlay() DS = {"database": "db", "table": "orders"} +MODEL = {"4.1.4": RELEASES[0], "5.0.0": RELEASES[0], "6.1.0": RELEASES[1]} +WINDOWS = ["1600", "1440"] -def drawn(key: str, filter_bar: bool) -> list[dict]: - return [r["filter_bar" if filter_bar else "blocks"][key] for r in RELEASES.values() - if key in r["filter_bar" if filter_bar else "blocks"]] +def drawn(window: str, part: str, key: str) -> list[dict]: + return [r["viewports"][window][part][key] for r in DRAWN.values()] -CASES = [(f"{n}@{w}", False) for n in CORPUS for w in MEASURED["widths"]] -CASES += [(f"about@{w}", True) for w in MEASURED["widths"]] +CASES = [(w, "blocks", f"{n}@{c}") for w in WINDOWS for n in CORPUS for c in MEASURED["widths"]] +CASES += [(w, "filter_bar", f"about@{c}") for w in WINDOWS for c in MEASURED["widths"]] +CASES += [(w, "styled", name) for w in WINDOWS for name in STYLED] def test_every_release_was_measured(): - assert set(RELEASES) == {"4.1.4", "5.0.0", "6.1.0"} - for r in RELEASES.values(): - assert len(r["blocks"]) == len(CORPUS) * len(MEASURED["widths"]) - assert len(r["verify"]) == len(CORPUS) * 3 + assert set(DRAWN) == {"4.1.4", "5.0.0", "6.1.0"} + for r in DRAWN.values(): + for window in WINDOWS: + assert len(r["viewports"][window]["blocks"]) == len(CORPUS) * len(MEASURED["widths"]) + assert len(r["viewports"][window]["styled"]) == len(STYLED) + assert len(r["verify"]) == len(CORPUS) * 3 and len(r["verify_css"]) == len(STYLED) * 2 -@pytest.mark.parametrize("key,filter_bar", CASES) -def test_the_bounds_hold_what_every_release_drew(key, filter_bar): +@pytest.mark.parametrize("window,part,key", CASES) +def test_the_bounds_hold_what_every_release_drew(window, part, key): """Measured px are whole (scrollHeight rounds), so each bound may sit half a px past.""" - name, w = key.split("@") - seen = drawn(key, filter_bar) - fit = estimate(CORPUS[name], int(w), filter_bar) + if part == "styled": + cols, _, text = STYLED[key] + fit = estimate(text, cols, css=STYLED_CSS, viewport=int(window)) + else: + name, cols = key.split("@") + fit = estimate(CORPUS[name], int(cols), part == "filter_bar", viewport=int(window)) + seen = drawn(window, part, key) low_text, low_need = min(d["text"] for d in seen), min(d["need"] for d in seen) high_need = max(d["need"] for d in seen) assert fit.text_low <= low_text + 0.5 and fit.need_low <= low_need + 0.5 assert fit.need_high >= high_need - 0.5 # Tight enough to be worth having: the low bound within a line or 4%, the high one - # within a line or 11% (narrow blocks of long text beside the filter bar are where - # the scrollbar allowance costs the most). + # within a line (an h1's, 39.2 px) or 14%. Narrow blocks of long text beside the + # filter bar are where the scrollbar allowance costs the most. assert low_need - fit.need_low <= max(23, 0.04 * low_need) - assert fit.need_high - high_need <= max(23, 0.11 * high_need) + assert fit.need_high - high_need <= max(40, 0.14 * high_need) -@pytest.mark.parametrize("release", sorted(RELEASES)) +@pytest.mark.parametrize("release", sorted(DRAWN)) def test_the_rule_agrees_with_what_each_release_showed(release): - """At 6/12 with Windows-style scrollbars: a block at the fix's height or taller never - scrolled, a block the rule calls cut off was, and one it reports at all scrolled.""" + """In a 1440 px window with Windows-style scrollbars: a block at the fix's height or + taller never scrolled, a block the rule calls cut off was, and one it reports at all + scrolled.""" checked = {"fits": 0, "warn": 0, "finding": 0} - for key, seen in RELEASES[release]["verify"].items(): - fit = estimate(CORPUS[key.split("@")[0]], 6) - box = box_px(seen["height"]) - assert box == seen["box"], key - if fit.units <= seen["height"]: - checked["fits"] += 1 - assert not seen["scrolls"], key - if fit.text_low - box >= 8: - checked["warn"] += 1 - assert seen["text"] - seen["box"] >= 8, key - if fit.need_low > box: - checked["finding"] += 1 - assert seen["scrolls"], key + r = DRAWN[release] + for part, css in (("verify", None), ("verify_css", STYLED_CSS)): + for key, seen in r[part].items(): + name = key.split("@")[0] + text = CORPUS[name] if part == "verify" else STYLED[name][2] + fit = estimate(text, seen["width"], css=css) + box = box_px(seen["height"]) + assert box == seen["box"], key + if fit.units <= seen["height"]: + checked["fits"] += 1 + assert not seen["scrolls"], key + if fit.text_low - box >= 8: + checked["warn"] += 1 + assert seen["text"] - seen["box"] >= 8, key + if fit.short(box): + checked["finding"] += 1 + assert seen["scrolls"], key assert min(checked.values()) >= 5, checked -def test_the_grid_widths_the_estimate_assumes_are_the_ones_drawn(): - for release, r in RELEASES.items(): - t = TYPOGRAPHY[1] if release == "6.1.0" else TYPOGRAPHY[0] - assert (r["grid_w"], r["grid_w_filter_bar"]) == (t.grid, t.grid_filter_bar) - for key, seen in r["blocks"].items(): - assert abs(text_width(int(key.split("@")[1]), t.grid) - seen["content_w"]) < 1 - # A header row, a sub-tab and a footer row wrap text as wide as layout rows do. - for where, width in r["containers"].items(): - assert abs(text_width(int(where.split("@")[1]), t.grid) - width) < 1 +@pytest.mark.parametrize("release", sorted(DRAWN)) +def test_the_grid_widths_the_estimate_assumes_are_the_ones_drawn(release): + r, model = DRAWN[release], MODEL[release] + for window in WINDOWS: + at = r["viewports"][window] + grid = model.grid(int(window), False) + assert (at["grid_w"], at["grid_w_filter_bar"]) == (grid, model.grid(int(window), True)) + for key, seen in at["blocks"].items(): + assert abs(text_width(int(key.split("@")[1]), grid) - seen["content_w"]) < 1 + # A header row, a sub-tab and a footer row wrap text as wide as layout rows do. + for where, width in r["containers"].items(): + assert abs(text_width(int(where.split("@")[1]), model.grid(1600, False)) - width) < 1 def test_heights_under_a_unit_still_draw_40_px(): """The markdown component's minimum (GRID_MIN_ROW_UNITS rows of 8 px).""" assert [box_px(h) for h in (0.2, 0.6, 1, 1.6)] == [40, 40, 40, 64] - shown = {k: v["box"] for r in RELEASES.values() for k, v in r["verify"].items() + shown = {k: v["box"] for r in DRAWN.values() for k, v in r["verify"].items() if v["height"] < 1} assert shown and set(shown.values()) == {40} -# -- parsing ---------------------------------------------------------------------- +# -- the cascade -------------------------------------------------------------------- + +STYLE_CASES = [(release, case) for release in sorted(DRAWN) + for case in ("default", "probe", "line", *STYLED)] + + +def _blocks(holder): + out = [] + + def walk(node): + for child in node.children: + if child.tag in ("p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "table", + "thead", "tbody", "tr", "th", "td", "blockquote", "pre", "hr"): + out.append(child) + if child.tag != "pre": + walk(child) + walk(holder) + return out + + +def _row(st): + return [st.font, st.line, st.margin[0], st.margin[2], st.padding[0], st.padding[2], + st.border[0], st.border[2], st.inset, st.weight] + + +@pytest.mark.parametrize("release,case", STYLE_CASES) +def test_the_cascade_gives_each_element_its_drawn_type(release, case): + """Superset's own styles under the dashboard's CSS, as the estimate applies them, + against the computed style of every element each release drew: font size, line + height, vertical margins, padding and borders, horizontal inset and weight.""" + probe = MEASURED["style_probe"] + if case in STYLED: + markdown, css, seen = STYLED[case][2], STYLED_CSS, DRAWN[release]["styles"]["styled"][case] + else: + markdown, css = probe["markdown"], probe.get(case) + seen = DRAWN[release]["styles"][case] + styles = mf._Styles(MODEL[release], read_css(css)) + nodes = _blocks(tree(markdown)) + assert [n.tag for n in nodes] == [b[0] for b in seen["blocks"]] + for node, drawn_row in zip(nodes, seen["blocks"]): + assert _row(styles.of(node)) == pytest.approx(drawn_row[1:], abs=0.01), node.tag + for at, tags, *drawn_row in seen["inline"]: + got = styles.inline(nodes[at], tuple(tags)) + assert _row(got) == pytest.approx(drawn_row, abs=0.01), (nodes[at].tag, tags) + + +def sheet(css): + return read_css(css) + + +def styled(css, markdown, release=RELEASES[1]): + styles = mf._Styles(release, css if isinstance(css, mf.Stylesheet) else read_css(css)) + return [(n.tag, styles.of(n)) for n in _blocks(tree(markdown))] + + +def test_rules_apply_in_cascade_order(): + css = """ + .dashboard-markdown h4 + p { font-size: 12.5px; margin: 5px 0 0; line-height: 1.6; } + .dashboard-markdown p { font-size: 14px; line-height: 1.5; margin-bottom: 12px; } + .dashboard-markdown p:last-child { margin-bottom: 0 !important; } + .dashboard-markdown p:last-child { margin-bottom: 30px; } + """ + (_, h4), (_, caption), (_, body), (_, last) = styled( + css, "#### Title\nA caption.\n\nBody one.\n\nBody two.") + # The adjacent sibling outranks the plain element, later order or not. + assert (caption.font, round(caption.line, 3), caption.margin[0], caption.margin[2]) == ( + 12.5, 20, 5, 0) + assert (body.font, body.line, body.margin[2]) == (14, 21, 12) + # !important beats a later rule of the same specificity. + assert last.margin[2] == 0 + # Superset's own styles fill in what the CSS leaves alone; line heights are factors. + assert (h4.font, h4.line, h4.margin[0], h4.margin[2]) == (16, 22.4, 8, 4) + + +def test_values_resolve_as_css_resolves_them(): + css = """ + .dashboard-markdown ul { font-size: 12px; } + .dashboard-markdown li { line-height: 2; margin: 0.5em 0 1em; padding: 2px 4px 6px; } + .dashboard-markdown h3 { font-size: 1.5em; line-height: 20px; border-bottom: 2px solid #ccc; } + .dashboard-markdown h5 { border-top: 3px; letter-spacing: 0.1em; } + """ + found = dict(styled(css, "### Head\n\n- one\n\n##### Small")) + li, h3, h5 = found["li"], found["h3"], found["h5"] + assert (li.font, li.line, li.margin[0], li.margin[2], li.padding[0], li.padding[2]) == ( + 12, 24, 6, 12, 2, 6) + assert li.inset == 8 + assert (h3.font, h3.line, h3.border[2]) == (21, 20, 2) # 1.5em of the 14 px body + # A border with no style draws nothing; letter-spacing counts in the element's em. + assert h5.border[0] == 0 and h5.letter == pytest.approx(1.6) + + +def test_h6_keeps_supersets_font_size(): + """Superset sets an h6's size inside the markdown component's own class, more + specifically than `.dashboard-markdown h6`: every release kept 12 px.""" + [(_, h6)] = styled(".dashboard-markdown h6 { font-size: 20px; margin: 0; }", "###### Six") + assert (h6.font, h6.margin[0]) == (12, 0) + [(_, h6)] = styled(".dashboard-markdown h6 { font-size: 20px !important; }", "###### Six") + assert h6.font == 20 + + +def test_what_the_estimate_reads_and_what_it_names(): + s = sheet(""" + @import url('https://fonts.googleapis.com/css2?family=Inter'); + /* .dashboard-markdown p { font-size: 99px; } */ + .dashboard-markdown p { font-size: 15px; color: #333; } + .dashboard-markdown .safe-markdown > :last-child { margin-bottom: 0; } + .dashboard-markdown [itemprop]::before { content: ""; width: 10px; margin: 0 6px; } + .dashboard-markdown h2 { font-family: Georgia, serif; } + .dashboard-markdown code { font-family: inherit; } + @media (max-width: 900px) { .dashboard-markdown p { font-size: 12px; } } + p { margin: 0; } + .dashboard-markdown .dashboard-component-chart-holder { padding: 4px; border: 0; } + .superset-chart-table td { border-top: 1px solid #eee; } + .header-title { font-size: 15px !important; } + .dashboard-markdown h3 { font-size: var(--title); } + .dashboard-markdown li { margin: 5% 0; } + .dashboard-markdown { margin: 8px; } + """) + names = {(sel, prop) for sel, prop in s.unread} + assert names == { + (".dashboard-markdown li", "margin"), + (".dashboard-markdown [itemprop]::before", "content"), + (".dashboard-markdown [itemprop]::before", "width"), + (".dashboard-markdown [itemprop]::before", "margin"), + (".dashboard-markdown h2", "font-family"), + ("@media (max-width: 900px) { .dashboard-markdown p }", "font-size"), + ("p", "margin"), + (".dashboard-markdown .dashboard-component-chart-holder", "padding"), + (".dashboard-markdown h3", "font-size"), + } + # The comment, the class markdown never carries and other components' rules are + # read and match nothing; the holder's border is read. + [(_, p)] = styled(s, "Text.") + assert (p.font, p.margin[2]) == (15, 8) + + +def test_the_holders_border_counts(): + plain_fit = estimate("Figures refresh nightly.", 12) + framed = estimate("Figures refresh nightly.", 12, + css=".dashboard-component-chart-holder { border: 2px solid #ccc; }") + assert framed.need_low == plain_fit.need_low + 4 and framed.need_high == plain_fit.need_high + 4 + + +def test_smaller_inline_text_needs_a_px_more_per_line_at_most(): + """A 12 px in 15 px text on 22.5 px lines fits inside the line by CSS's + arithmetic, and Chromium draws such a line up to a px taller: the low bound keeps + 22.5, the high one allows 23.5.""" + css = ".dashboard-markdown p { font-size: 15px; line-height: 1.5; } " \ + ".dashboard-markdown em { font-size: 12px; }" + fit = estimate("*Updated nightly.*", 12, css=css) + assert (fit.need_low, fit.need_high) == (16 + 22.5 + 8 + 16, 16 + 23.5 + 8 + 16) + + +# -- parsing ------------------------------------------------------------------------ def kinds(markdown): @@ -128,14 +306,27 @@ def test_parse_reads_the_common_shapes(): assert p.texts == ["one", "two", "three", "four still four"] lst = parse("1. a\n - b\n - c\n2. d\n\n more of d\n3. e")[0][0] assert [d for d, _ in lst.items] == [0, 1, 1, 0, 0] and lst.loose + assert lst.ordered == [True, False, False, True, True] assert lst.items[3][1] == "d more of d" assert parse("![chart](x.png)")[1] and not parse("text")[1] +def test_the_tree_is_what_react_markdown_draws(): + holder = tree("1. a\n - b\n2. c\n\n> q\n\n| x |\n|---|\n| 1 |") + assert [n.tag for n in _blocks(holder)] == [ + "ol", "li", "ul", "li", "li", "blockquote", "p", "table", "thead", "tr", "th", + "tbody", "tr", "td"] + ol = holder.children[0] + assert (ol.first, ol.last, holder.children[1].prev is ol) == (True, False, True) + assert holder.parent.parent.classes == {"dashboard-markdown"} + + def test_plain_text_is_what_the_reader_sees(): assert plain("**Bold** and _em_, [a link](http://x), `code`, ~~gone~~ & tag") == \ "Bold and em, a link, code, gone & tag" assert plain("snake_case stays") == "snake_case stays" + assert plain('**Apr–Jun** \\*not em\\*') == "Apr–Jun *not em*" + assert mf._runs("a *b* `c*d*`") == [("a ", ()), ("b", ("em",)), (" ", ()), ("c*d*", ("code",))] def test_text_wraps_at_spaces_and_after_hyphens_between_letters(): @@ -144,6 +335,7 @@ def test_text_wraps_at_spaces_and_after_hyphens_between_letters(): # One unbreakable word wider than the line keeps a line of its own. assert wrapped_lines("a " + "x" * 200 + " b", 300) == 3 assert wrapped_lines("great-circle", 60) == 2 and wrapped_lines("-5", 1) == 1 + assert wrapped_lines("Jul–Sep", 30) == 2 # -- the rule ----------------------------------------------------------------------- @@ -175,21 +367,77 @@ def test_the_rule_is_registered_with_its_levels(): def test_definitions_cut_off_mid_section_warn_and_the_fix_shows_them(): - """The About tab: a full-width block of about 1,000 words in five sections, 10 units - high. Every release drew it 1,156 px tall; the block showed 400.""" + """A full-width block of about 1,000 words in five sections, 10 units high: every + release drew it over 1,150 px tall; the block showed 400.""" data = mk(rows({"markdown": ABOUT, "width": 12, "height": 10})) [f] = fits(data) - need = estimate(ABOUT, 12).units - assert f.severity == "warn" and f.where == "layout row 0" and 28.9 < need < 30 - assert f.detail.startswith("markdown text runs past its block: about 33 lines cut off") - assert f"it needs ~{need:g} units at 12/12, has 10; raise the height" in f.detail + fit = estimate(ABOUT, 12) + assert f.severity == "warn" and f.where == "layout row 0" and 30 < fit.units < 33 + hidden = round((fit.text_low - 400) / 22) + assert f.detail.startswith(f"markdown text runs past its block: about {hidden} lines cut off") + assert (f"it needs ~{fit.units:g} units at 12/12 in a {VIEWPORT} px wide window, has 10; " + "raise the height") in f.detail fixed, report = advise_and_fix(data, overlay=EMPTY) - assert fixed["layout"]["rows"][0][0]["height"] == need + assert fixed["layout"]["rows"][0][0]["height"] == fit.units entry = next(e for e in report.fixed if e["rule"] == "size.markdown-fit") assert entry["md"] == [None, 0, 0] and entry["was"] == {"height": 10} assert not fits(fixed) +def styled_block(name, height=None, **dashboard): + cols, then, text = STYLED[name] + return mk(rows({"markdown": text, "width": cols, "height": height or then}), + css=STYLED_CSS, **dashboard) + + +@pytest.mark.parametrize("name,level", [ + # As every release drew them in a 1440 px window: the notes 26 to 36 px over at 15 + # units and two three-line captions 26 and 28 px over, their last line cut by a + # grid row or more; captions 8 to 12 px over, where only the padding or the edge + # of the last line is (one of them within a few percent of wrapping onto another + # line, which the high bound allows for); the footer exactly full at 1.2 units, and + # the title, which fit. + ("notes", "warn"), ("caption-staff", "warn"), ("caption-floor", "warn"), + ("caption-stores", "info"), ("caption-change", "info"), ("caption-years", "info"), + ("footer", None), ("title", None), +]) +def test_a_dashboard_read_with_its_own_css(name, level): + found = fits(styled_block(name)) + assert [f.severity for f in found] == ([level] if level else []) + if found: + fixed, _ = advise_and_fix(styled_block(name), overlay=EMPTY) + height = fixed["layout"]["rows"][0][0]["height"] + seen = max(r["verify_css"][f"{name}@fix"]["box"] for r in DRAWN.values()) + assert box_px(height) == seen and not fits(fixed) + + +def test_the_caveat_names_only_what_the_estimate_doesnt_read(): + [f] = fits(styled_block("caption-change")) + assert f.detail.endswith("; the dashboard's CSS also styles markdown in a way this " + "estimate doesn't read (.dashboard-markdown [itemprop]::before), " + "so the height may be off") + read_only = ".dashboard-markdown p { font-size: 15px; line-height: 1.5; }" + [f] = fits(mk(rows({"markdown": ABOUT, "height": 10}), css=read_only)) + assert f.detail.endswith("raise the height") + [f] = fits(mk(rows({"markdown": ABOUT, "height": 10}), theme="Brand")) + assert f.detail.endswith("; this assumes Superset's own text sizes and spacing, which the " + "theme 'Brand' may change") + + +def test_without_its_css_the_estimate_misses_both_ways(): + """The footer: 220 characters of 11.5 px h5 fill its 48 px exactly, where + Superset's own 16 px h5 needs 22.4 px lines. A caption: Superset's own sizes put + the low bound above what every release drew, its 13 px caption inside it.""" + cols, _, footer = STYLED["footer"] + assert fits(mk(rows({"markdown": footer, "width": cols, "height": 1.2}))) + assert not fits(styled_block("footer")) + text = STYLED["caption-stores"][2] + drew = max(r["viewports"]["1440"]["styled"]["caption-stores"]["need"] for r in DRAWN.values()) + assert estimate(text, 12).need_low > drew + read = estimate(text, 12, css=STYLED_CSS) + assert read.need_low <= drew <= read.need_high + + @pytest.mark.parametrize("markdown,height,level", [ # Short strips: the 16 px padding and the heading's margins come before the text. ("## Punctuality", 1.2, "warn"), ("## Punctuality", 1.6, "info"), ("## Punctuality", 2, None), @@ -226,20 +474,34 @@ def test_every_place_a_block_can_sit_is_checked_and_fixed_in_place(): def test_the_filter_bar_narrows_the_text(): - block = {"markdown": ABOUT, "width": 12, "height": 30} + height = estimate(ABOUT, 12).units + block = {"markdown": ABOUT, "width": 12, "height": height} select = [{"name": "Region", "type": "select", "dataset": DS, "column": "region"}] assert not fits(mk(rows(block))) [f] = fits(mk(rows(block), filters=select)) assert f.severity == "warn" # A horizontal bar sits above the grid; without its feature flag 4.1.4 and 5.0.0 draw - # it on the left, so nothing is sure enough to warn about. - assert not fits(mk(rows(block), filters=select, filter_bar_orientation="horizontal")) + # it on the left, so nothing is sure enough to warn about... + horizontal = mk(rows(block), filters=select, filter_bar_orientation="horizontal") + assert [f.severity for f in fits(horizontal)] in ([], ["info"]) + # ...unless the spec can only go to 6.1.0, which draws it across the top. + assert not fits(mk(rows(block), filters=select, filter_bar_orientation="horizontal", + theme="Brand")) + + +def test_a_spec_for_newer_releases_is_estimated_on_them_alone(): + """A theme takes 6.0.0 or later, so the estimate leaves out 4.1.4 and 5.0.0's type: + a 21 px ## heading is 1.4 px taller than 6.1.0's 20 px one.""" + assert estimate("## Punctuality", 12).need_high == pytest.approx(81.4) + newer = mf.releases_from((6, 0, 0)) + assert newer == RELEASES[1:] + assert estimate("## Punctuality", 12, releases=newer).need_high == pytest.approx(80) def test_a_narrow_block_is_told_it_can_widen(): data = mk({"rows": [[{"markdown": CORPUS["section"], "width": 4, "height": 4}, "K"]]}) [f] = fits(data) - assert "at 4/12, has 4; raise the height or widen it" in f.detail + assert f"at 4/12 in a {VIEWPORT} px wide window, has 4; raise the height or widen it" in f.detail def test_no_fix_when_the_height_cannot_be_known_or_written(): @@ -250,17 +512,6 @@ def test_no_fix_when_the_height_cannot_be_known_or_written(): assert huge.fix is None and "past the 100-unit maximum" in huge.detail -@pytest.mark.parametrize("dashboard,caveat", [ - ({"css": ".dashboard-markdown p { font-size: 16px; }"}, "the dashboard's CSS sets its own type"), - ({"theme": "Brand"}, "which the theme 'Brand' may change"), - ({"css": ".dashboard-markdown h2 { color: #003366; }"}, None), -]) -def test_custom_type_is_named_and_the_fix_still_offered(dashboard, caveat): - [f] = fits(mk(rows({"markdown": ABOUT, "height": 10}), **dashboard)) - assert f.fix is not None - assert (caveat in f.detail) if caveat else f.detail.endswith("raise the height") - - def test_a_finding_can_be_ignored_by_its_position(): data = mk(rows({"markdown": ABOUT, "height": 10})) data["design"] = {"ignore": ["size.markdown-fit@layout-row-0"]} diff --git a/tools/ci_live_markdown_fit.py b/tools/ci_live_markdown_fit.py index b4e9cc8..bac0d88 100644 --- a/tools/ci_live_markdown_fit.py +++ b/tools/ci_live_markdown_fit.py @@ -1,12 +1,14 @@ """Live check of size.markdown-fit: a markdown block at the height `advise --fix` gives it shows every line, and a block the rule calls cut off is. -Flow: each text of tools/record_markdown_fit.py's corpus at 6/12, at the height the -estimate's fix writes and a unit less, on one dashboard (slug cw-live-mdfit), opened in -headless Chromium at a 1600 px viewport with classic scrollbars on, as Windows draws -them. At the fix's height no block may scroll; a unit less, every block the rule warns -about must have its text cut off by a grid row or more, and every block it reports at -all must scroll. The dashboard is deleted at the end. +Flow: each text of tools/record_markdown_fit.py's corpus at 6/12, and each block of +its styled dashboard under that dashboard's own CSS at its own width, at the height +the estimate's fix writes and a unit less. Each set on a dashboard of its own (slugs +cw-live-mdfit and cw-live-mdfit-css), opened in headless Chromium in a 1440 px wide +window with classic scrollbars on, as Windows draws them. At the fix's height no +block may scroll; a unit less, every block the rule warns about must have its text +cut off by a grid row or more, and every block it reports at all must scroll. The +dashboards are deleted at the end. Needs the visual extra: pip install -e ".[visual]" && playwright install chromium. @@ -28,42 +30,51 @@ from chartwright.client import SupersetClient # noqa: E402 from chartwright.design.markdown_fit import box_px, estimate # noqa: E402 -from record_markdown_fit import CORPUS, verify # noqa: E402 +from record_markdown_fit import CORPUS, STYLED, STYLED_CSS, verify # noqa: E402 def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--base-url", default=os.environ.get("SDC_CI_BASE_URL", "http://localhost:8098")) ap.add_argument("--username", default="admin") - ap.add_argument("--prefix", default="cw-live", help="slug prefix of the dashboard") + ap.add_argument("--prefix", default="cw-live", help="slug prefix of the dashboards") args = ap.parse_args(argv) password = os.environ.get("SDC_CI_PASSWORD", "admin") # sandbox default; never in argv base = args.base_url.rstrip("/") client = SupersetClient(base, args.username, password) client.login() - fits = {name: estimate(text, 6) for name, text in CORPUS.items()} - heights = [] - for name, fit in fits.items(): - heights += [(name, "fix", fit.units), (name, "short", round(max(0.2, fit.units - 1), 1))] - seen = verify(client, base, args.username, password, f"{args.prefix}-mdfit", heights) + sets = { + "": ([(name, text, 6) for name, text in CORPUS.items()], None), + "-css": ([(name, text, width) for name, width, _, text in STYLED], STYLED_CSS), + } failures: list[str] = [] - for key, got in seen.items(): - name, kind = key.split("@") - fit, box = fits[name], box_px(got["height"]) - expect = [] - if kind == "fix": - expect.append(("shows every line", not got["scrolls"])) - else: - if fit.text_low - box >= 8: - expect.append(("has its text cut off", got["text"] - got["box"] >= 8)) - if fit.need_low > box: - expect.append(("scrolls", got["scrolls"])) - for what, ok in expect: - line = f"{name} at {got['height']:g} units {what} (box {got['box']} px, text to {got['text']} px)" - print(("ok " if ok else "FAIL ") + line) - if not ok: - failures.append(line) + for suffix, (texts, css) in sets.items(): + fits = {name: estimate(text, width, css=css) for name, text, width in texts} + blocks = [] + for name, text, width in texts: + units = fits[name].units + blocks += [(f"{name}@fix", text, width, units), + (f"{name}@short", text, width, round(max(0.2, units - 1), 1))] + seen = verify(client, base, args.username, password, f"{args.prefix}-mdfit{suffix}", + blocks, css=css) + for key, got in seen.items(): + name, kind = key.split("@") + fit, box = fits[name], box_px(got["height"]) + expect = [] + if kind == "fix": + expect.append(("shows every line", not got["scrolls"])) + else: + if fit.text_low - box >= 8: + expect.append(("has its text cut off", got["text"] - got["box"] >= 8)) + if fit.short(box): + expect.append(("scrolls", got["scrolls"])) + for what, ok in expect: + line = (f"{name} at {got['width']}/12, {got['height']:g} units {what} " + f"(box {got['box']} px, text to {got['text']} px)") + print(("ok " if ok else "FAIL ") + line) + if not ok: + failures.append(line) print(f"{len(failures)} failure(s)" if failures else "markdown fit holds") return 1 if failures else 0 diff --git a/tools/record_markdown_fit.py b/tools/record_markdown_fit.py index 3b8651e..604847f 100644 --- a/tools/record_markdown_fit.py +++ b/tools/record_markdown_fit.py @@ -3,7 +3,8 @@ against real renders (tests/test_markdown_fit.py reads tests/fixtures/markdown_fit/measurements.json). -For each instance, in headless Chromium at a 1600 x 1200 viewport: +For each instance, in headless Chromium, in a window 1600 and 1440 px wide (VIEWPORT, +the window the rule assumes): - `blocks`: a dashboard (slug cw-test-mdfit-) holding every CORPUS text at every width in WIDTHS. For each block it reads `content_w`, the width the text wraps in (the holder less its padding); `need`, the height that shows everything with no @@ -12,13 +13,19 @@ `text`, how far down the last line of text (or rule, or table) reaches; - `filter_bar`: the about text at every width on a dashboard with a native filter, whose bar opens on the left and narrows the grid; -- `containers`: the width a block wraps its text in from a header row, a sub-tab and - a footer row, against layout rows; -- `verify`: with Chromium's classic scrollbars on, as Windows draws them, each text at - 6/12 at three heights: the one the estimate's fix writes (`fix`), the one `need` - asks for rounded up to an 8 px grid row, and a unit less. For each it records the - box, whether it scrolls, and where the text ends (read before anything is - squeezed), and with --shots saves a screenshot of the block. +- `styled`: a dashboard's own markdown blocks under its own CSS (STYLED); +- `containers` (1600 px): the width a block wraps its text in from a header row, a + sub-tab and a footer row, against layout rows; +- `styles` (1600 px): every markdown element's computed type, with no CSS, with + every property set on every element from `.dashboard-markdown X` (PROBE_CSS, + LINE_CSS), and under the styled dashboard's CSS: what the cascade gives each element; +- `verify` and `verify_css` (1440 px), with Chromium's classic scrollbars on, as + Windows draws them: each corpus text at 6/12 at the height the estimate's fix + writes (`fix`), the height `need` asks for rounded up to an 8 px grid row, and a + unit less; each styled block at the fix's height and at a height that cuts it + off (`then`). For each it records the box, whether it scrolls, and + where the text ends (read before anything is squeezed), and with --shots saves a + screenshot of the block. Every dashboard is deleted at the end. @@ -42,15 +49,15 @@ from chartwright.apply import apply as run_apply # noqa: E402 from chartwright.client import SupersetClient # noqa: E402 from chartwright.compiler import _position # noqa: E402 -from chartwright.design.markdown_fit import estimate # noqa: E402 +from chartwright.design.markdown_fit import VIEWPORT, estimate # noqa: E402 from chartwright.spec import load_spec # noqa: E402 OUT = REPO / "tests" / "fixtures" / "markdown_fit" / "measurements.json" WIDTHS = [12, 8, 6, 4, 3] DS = {"database": "examples", "table": "cleaned_sales_data"} -# Definitions in five sections, with headings and a bullet list, like the About tab -# that rendered cut off mid-section at 10 units (about 1,000 words). +# Definitions in five sections, with headings and a bullet list, like a dashboard's +# notes tab (about 1,000 words). ABOUT = """## How to read this dashboard Every figure on these tabs comes from the operations warehouse, refreshed nightly at 02:00 UTC. A day closes when the last flight of the schedule lands, so the most recent day can move until the following morning. Figures are shown in the network's reporting currency and converted at the month's average rate, never the daily rate, so a month never changes once it has closed. Where a chart says "network" it means every route the schedule operated in the period, including seasonal routes, and excludes charters, ferry flights and positioning flights, which carry no revenue passengers. @@ -136,15 +143,149 @@ "setext": "Network report\n==============\n\nQuarterly figures\n-----------------\n\nBody text.", } - - - -def spec_for(slug: str, title: str, layout: dict, filters: bool = False) -> dict: +# A dashboard with its own CSS: its markdown blocks as (name, width, a height that +# cuts it off in a 1440 px window, text), the kind a sales review carries: a title +# with a subtitle, section captions with colour keys, a list, notes in sections, a +# footer. Its CSS sets the text sizes the way such a dashboard does: 17 px section +# headings with a rule under them, 13 px captions after them (`h4 + p`), 14.5 px +# text on 1.5 lines, no margin under the last paragraph or list, list items 4 px +# apart, colour keys drawn by `[itemprop]::before`, and rules for other components. +STYLED = [ + ('title', 9, 2.4, """## STOREFRONT +Quarterly sales review for the regional store network · 48 stores, 46 ranked · orders since 2016, returns since Mar 2019 · built from `storefront.json`"""), + ('caption-week', 12, 2.0, """#### Orders across the week +**Apr–Jun 2026** · **Apr–Jun 2025** · **Target** — orders as a share of each week's busiest day, by weekday. Titles: the typical Monday lift; two quiet outlets show a 3-day sum."""), + ('caption-next', 12, 2.4, """#### Stores to visit next +Branches that gained 150 orders a week or more. Shaded: growth well outside the usual seasonal swing. A jump over one year that vanishes over two is a bounce. Lift ÷ peak: the hard-week 3-day lift in orders against the best week. ◇ an estimate; a greyed yearly change has no till count behind it, and greyed refunds sit outside ±2% (Notes tab)."""), + ('summary-list', 4, 13, """### What changed · 12 months to Sep 2026 +- **North region grew fastest:** repeat orders rose 3.5 k a week (+4.8%). The city branch sells 53% more than in 2019, the mall 23% more; Store 12 (+12.3%) and Store 7 (+7.8%) led. +- **Weekend staffing falls short.** On the busiest Saturday in ten, ◇ over two extra shifts are needed in 18 of 20 large stores: Store 3 needs 2.9 and Store 9 needs 3.2. Only Stores 1 and 4 cope. +- **Two stores already have slack.** ◇ Their rotas exceed peak demand (1.48×, 1.27×), and their part-time pools hold 2.3× and 4.5× the hours a busy Saturday uses. Store 3 covers just 0.04× of its own Saturday peak. +- **Some gains look durable.** Across the year, weekly orders in 5 stores (2, 5, 7, 12 and 15) sat above their seasonal range for three months or longer, and basket size did in 7. Delivery has a full year of history in only 5 stores, none above its range so far. With 46 stores tested, roughly one such result could be luck alone. + +*Updated Sat 3 Oct 2026 from the overnight load. Rankings cover store sales only; regional budgets come from finance.*"""), + ('caption-rush', 12, 2.4, """#### When the rush arrives +Shows the store selected in the filters. Left, orders per hour across the months: a darker cell means more orders, and the lunch peak grows as delivery spreads. Right, each day's evening rush, whiskers at the busiest day in ten: rotas are planned for the whisker, not the box."""), + ('caption-change', 12, 2.8, """#### What moved the basket, and will it hold? +The waterfall breaks the quarter-on-quarter movement in the **average** basket, Apr–Jun 2025 to Apr–Jun 2026, into price, volume and product mix: each bar is the part one driver added to the total. **raises the basket** · **lowers it**. The cards above use the median order. Right, each month's basket change scaled by how much that store normally varies: **outside the range** for three months in a row counts as durable, as does a **12-month average** past the dotted line. The waterfall needs sales in both quarters (none for the two new stores); the range needs four prior years of history (not Stores 41 or 46)."""), + ('caption-years', 12, 2.0, """#### Eight years of Saturday peaks +**Median Saturday** · **Busiest Saturday**, by month since online orders were first logged; gaps mark months a store was shut for a refit."""), + ('caption-growth', 12, 2.4, """#### Which stores grow, and how steadily? +Repeat orders, the share from returning customers: loyalty schemes push it up and promotions hardly shift it. **Repeat orders added** · **All orders added**: where the two agree, growth comes from regulars. All orders swing with promotions and track the sale calendar. Listed branches gained 200 weekly orders or more."""), + ('caption-stores', 12, 2.0, """#### Where repeat customers shop +Left, the same stores against their own 2019 sales. Right, two city branches, the riverside store (RIV) and the station store (STN), whose busiest hours fall at lunchtime, so their weekday base is mostly office trade."""), + ('caption-staff', 12, 2.0, """#### Which stores are staffed for Saturday, and which need help? +Rostered staff per region, and on the left the rota's hours against the busiest Saturday: **on the rota** · **with open vacancies**; 1 means the rota exactly meets the peak (stores needing 40 hours or more). On the right, extra shifts needed to shorten the Saturday queue by a tenth on the hardest day in ten: above 2, the pool falls short and agency cover is needed."""), + ('caption-floor', 12, 2.4, """#### Who serves the evening rush? +For the selected store: who is on the shop floor at 13:00, the lunch peak, and at 19:00, after offices close; stockroom and delivery staff aren't counted. Self-checkout use appears only where a store reports it to head office, and some stores count assisted sales twice while others count them once (Store 9 changed method last December), which is why the hourly view shows assisted sales alone. A month where tills and ledger disagree by over 5% stays empty (Store 22 from Dec 2025)."""), + ('notes', 12, 15, """### Busy hours +**Busy hours** are the three consecutive hours with the most till transactions in a trading day, counted from opening to closing time and only on days the store is open for at least eight hours. Click-and-collect orders are left out, because they are picked before the doors open and would pull the busiest window into the early morning. Each store shows two figures: a **typical Saturday**, the median, and a **hard Saturday**, the ninth worst in ten. + +### Loyal customers +**◇ Loyalty** is an estimate: the share of transactions paid with a card the store has seen in the previous ninety days. Cards stand in for identity, so cash customers are invisible to it, and stores near tourist sites read low all summer. The ranking greys any store whose cash share moved by more than five points. **Basket size** is the value of an average transaction before discounts and refunds, with gift cards left out. Figures marked *index* are scaled so the first full year is one hundred. + +### Is it lasting? +A store's monthly result is compared with the same month of the previous year, then scaled by how much that store usually varies in that month, using every year from 2019–2025. Holidays that move between months, such as Easter, are averaged out first. A change is **lasting** when the scaled result stays outside the usual range for three consecutive months; with 46 stores, about one store a month would cross the range on noise alone, and about two would do so at least once in a year. A change is also **lasting** when the full year, averaged, lies far beyond the range. Stores open for fewer than four full years have no usual range yet and show no verdict. + +### The waterfall +The waterfall breaks the movement in **average basket value** between two quarters into price, volume and product mix, in that order, so the three bars always add up to the total. The order matters: moving mix first would shift a little from price to mix. + +### ◇ Estimates +- **Rota ÷ Saturday peak:** staffed hours on the published rota divided by the hours the busiest Saturday in ten would need. Rotas change during the week and sickness is not recorded, so the ratio overstates cover on bad days. The ranking table uses the same ratio against the typical Saturday. Rotas older than six weeks fall back to the store's template. +- **Extra shifts:** how many eight-hour shifts it takes to bring the hard Saturday's queue down by a tenth, assuming every shift starts on time. + +### What the figures don't cover +Online-only orders, wholesale accounts, staff costs and margins are not in the till data. Use this page to choose which stores to visit; it does not set any budgets."""), + ('caption-tills', 12, 2.4, """#### Do the tills add up? +Till variance is (card takings + cash takings − ledger sales) ÷ ledger sales and should stay close to zero; months outside ±2% are greyed. The remaining 39 stores agree within ±1%. Store 22's card data is incomplete from Dec 2025; its basket figures, which use card payments only, still hold. Store 9 has run above +2% since June."""), + ('footer', 12, 1.2, """##### Source: store till system and head-office ledger, nightly extract (September 2026), for internal use only · ◇ = estimated figure, see the Notes tab for how each one is made · made with Chartwright from storefront.json"""), +] +STYLED_CSS = """/* Storefront: layout first, then the markdown's text sizes. */ +.dashboard-component-tabs, +.dashboard-component-tabs .dashboard-component-tabs-content { background-color: #F5F6F8 !important; } +.dashboard-component-tabs .ant-tabs-tab { font-size: 13px; padding: 8px 0; } +.dashboard-component-chart-holder { border: 1px solid #E2E5EA; border-radius: 4px; } +.background--white .dashboard-component-chart-holder { border: 0; } +.dashboard-markdown .dashboard-component-chart-holder { background: transparent; border: 0; } +.header-title { font-size: 14px !important; font-weight: 600 !important; } +.dashboard-markdown { color: #20242C; } +.dashboard-markdown h2 { font-size: 24px; font-weight: 700; letter-spacing: -.02em; margin: 0; } +.dashboard-markdown h2 + p { font-size: 13px; color: #5F6672; margin: 6px 0 0; } +.dashboard-markdown h3 { font-size: 16px; font-weight: 600; margin: 12px 0 6px; } +.dashboard-markdown h4 { font-size: 17px; font-weight: 600 !important; margin: 0; + padding-bottom: 6px; border-bottom: 2px solid #DDE1E7; } +.dashboard-markdown h4 + p { font-size: 13px; color: #5F6672; margin: 6px 0 0; line-height: 1.4; } +.dashboard-markdown h4 + p strong { color: #20242C; font-weight: 600; } +.dashboard-markdown p, .dashboard-markdown li { font-size: 14.5px; line-height: 1.5; } +/* no gap under the last paragraph or list */ +.dashboard-markdown p:last-child, .dashboard-markdown ul:last-child { margin-bottom: 0 !important; } +.dashboard-markdown li { margin: 0 0 4px; } +/* 11.5 px on 1.4 lines: 16.1 px, a tenth of a px past a 48 px block's room */ +.dashboard-markdown h5 { font-size: 11.5px; font-weight: 400; color: #5F6672; margin: 0; } +.dashboard-markdown em { color: #5F6672; font-style: normal; font-size: 12px; } +.dashboard-markdown code { font-family: inherit; font-size: 12px; background: #EEF0F3; padding: 1px 4px; } +/* colour keys: survives the sanitizer */ +.dashboard-markdown [itemprop]::before { content: ""; display: inline-block; width: 12px; height: 8px; + margin: 0 4px 0 2px; } +.dashboard-markdown [itemprop="this-year"]::before { background: #1F3A5F; } +.dashboard-markdown [itemprop="last-year"]::before { background: #B9C2CE; } +.dashboard-markdown [itemprop="target"]::before { background: #E3B04B; } +.dashboard-markdown [itemprop="up"]::before { background: #2E7D5B; } +.dashboard-markdown [itemprop="down"]::before { background: #B4463A; } +.dashboard-markdown [itemprop="trend"]::before { background: #55606E; } +/* other components */ +[data-test-viz-type="big_number_total"] .header-line { font-size: 32px !important; line-height: 1.1 !important; } +[data-test-chart-name="Updated"] { position: relative; } +.superset-chart-table table tbody td { border-top: 1px solid #EEF0F3; }""" + +# Every element markdown draws, for the computed-style probes below. +STYLE_PROBE = """# H1 text +## H2 text +### H3 text +#### H4 text +##### H5 text +###### H6 text + +Para with *em* and **strong** and `code` and [a link](http://x). + +Second para. + +- item one +- item two + - nested + +1. first +2. second + +| a | b | +|---|---| +| 1 | 2 | + +> quoted + +``` +pre code +``` + +--- + +Last para.""" +_ELEMENTS = ["h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li", "table", "th", "td", + "tr", "blockquote", "pre", "hr", "em", "strong", "code", "a"] +# Every property the estimate reads, on every element, from `.dashboard-markdown X`: +# which of them beat Superset's own styles. +PROBE_CSS = "\n".join(f".dashboard-markdown {e} {{ font-size: 10px; margin: 3px 0 5px; " + f"padding: 1px 0 2px; }}" for e in _ELEMENTS) +LINE_CSS = "\n".join(f".dashboard-markdown {e} {{ line-height: 30px; }}" for e in _ELEMENTS) + + +def spec_for(slug: str, title: str, layout: dict, filters: bool = False, + css: str | None = None) -> dict: """A dashboard of markdown blocks; the one chart a spec needs sits in its own row.""" count = f"{title} count" data = { "spec_version": "1", - "dashboard": {"title": title, "slug": slug}, + "dashboard": {"title": title, "slug": slug, **({"css": css} if css else {})}, "charts": [{"name": count, "type": "big_number_total", "dataset": DS, "metric": "COUNT(*)"}], "layout": layout, @@ -190,6 +331,39 @@ def rows_of(blocks: list[dict]) -> dict: } """ +# Each markdown block's elements in document order with their computed type: block +# elements as [tag, font size, line height, margin top, bottom, padding top, bottom, +# border top, bottom, horizontal inset, weight], inline ones with the index of the +# block element they sit in and their tags, outermost first. +STYLES_JS = r""" +() => { + const BLOCK = new Set(['P','H1','H2','H3','H4','H5','H6','UL','OL','LI','TABLE','THEAD','TBODY', + 'TR','TH','TD','BLOCKQUOTE','PRE','HR']); + const px = v => Math.round(parseFloat(v) * 1000) / 1000; + const out = {}; + for (const md of document.querySelectorAll('.dashboard-markdown')) { + const holder = md.querySelector('.dashboard-component-chart-holder'); + if (!holder) continue; + const blocks = [], inline = []; + for (const el of holder.querySelectorAll('*')) { + const s = getComputedStyle(el); + const row = [px(s.fontSize), px(s.lineHeight), px(s.marginTop), px(s.marginBottom), + px(s.paddingTop), px(s.paddingBottom), px(s.borderTopWidth), px(s.borderBottomWidth), + px(s.marginLeft) + px(s.marginRight) + px(s.paddingLeft) + px(s.paddingRight) + + px(s.borderLeftWidth) + px(s.borderRightWidth), Number(s.fontWeight)]; + if (BLOCK.has(el.tagName)) { blocks.push([el.tagName.toLowerCase(), ...row]); el.dataset.cwAt = blocks.length - 1; continue; } + if (el.closest('pre')) continue; + const tags = []; + let up = el; + while (up && !BLOCK.has(up.tagName)) { tags.unshift(up.tagName.toLowerCase()); up = up.parentElement; } + inline.push([Number(up.dataset.cwAt), tags, ...row]); + } + out[md.id] = {blocks, inline}; + } + return out; +} +""" + def _login(page, base: str, username: str, password: str) -> None: page.goto(f"{base}/login/") @@ -204,14 +378,19 @@ def _ids(data: dict) -> list[str]: return [k for k in _position(load_spec(data)) if k.startswith("MARKDOWN-")] -def _measure(page, base: str, slug: str, ids: list[str]) -> tuple[float, dict[str, dict]]: - """Open the dashboard and measure its markdown blocks, scrolling until each has - drawn. A block's first reading is kept: squeezing it for `need` can leave a - classic scrollbar behind, which narrows the text on the next reading.""" +def _open(page, base: str, slug: str) -> None: page.goto(f"{base}/superset/dashboard/{slug}/") page.wait_for_load_state("networkidle") page.wait_for_selector(".dashboard-markdown") + page.evaluate("document.fonts.ready.then(() => true)") page.wait_for_timeout(1500) + + +def _measure(page, base: str, slug: str, ids: list[str]) -> tuple[float, dict[str, dict]]: + """Open the dashboard and measure its markdown blocks, scrolling until each has + drawn. A block's first reading is kept: squeezing it for `need` can leave a + classic scrollbar behind, which narrows the text on the next reading.""" + _open(page, base, slug) seen: dict[str, dict] = {} grid_w = 0.0 for _ in range(400): @@ -229,6 +408,21 @@ def _measure(page, base: str, slug: str, ids: list[str]) -> tuple[float, dict[st return grid_w, seen +def _styles(page, base: str, slug: str, ids: list[str]) -> dict[str, dict]: + """Each markdown block's computed styles (STYLES_JS), scrolling until all drew.""" + _open(page, base, slug) + seen: dict[str, dict] = {} + for _ in range(400): + for key, value in page.evaluate(STYLES_JS).items(): + if value["blocks"]: + seen.setdefault(key, value) + if all(i in seen for i in ids): + return seen + page.mouse.wheel(0, 600) + page.wait_for_timeout(250) + raise SystemExit(f"{slug}: never drew {[i for i in ids if i not in seen]}") + + def _apply(client, data: dict) -> None: report = run_apply(load_spec(data), client, "record") if not report.ok: @@ -250,41 +444,61 @@ def _keep(b: dict) -> dict: return {k: b[k] for k in ("content_w", "need", "text")} +def _page(p, width: int, scrollbars: bool = False): + # Playwright hides scrollbars in headless Chromium; a reader on Windows sees them. + browser = p.chromium.launch(ignore_default_args=["--hide-scrollbars"] if scrollbars else None) + page = browser.new_context(viewport={"width": width, "height": 1200}).new_page() + page.set_default_timeout(90000) + return browser, page + + def verify(client, base: str, username: str, password: str, slug: str, - heights: list[tuple[str, str, float]], shots: Path | None = None) -> dict: - """Apply each CORPUS text at 6/12 at the heights given, as (text, kind, height), open - the dashboard with Chromium's classic scrollbars on, as Windows draws them, and read - each block's box, whether it scrolls and where its text ends. Deletes the dashboard.""" + blocks: list[tuple[str, str, int, float]], css: str | None = None, + shots: Path | None = None, viewport: int = VIEWPORT) -> dict: + """Apply each block, as (key, markdown, width, height), open the dashboard with + Chromium's classic scrollbars on, as Windows draws them, and read each block's + box, whether it scrolls and where its text ends. Deletes the dashboard.""" from playwright.sync_api import sync_playwright data = spec_for(slug, slug.replace("-", " "), rows_of( - [{"markdown": CORPUS[n], "width": 6, "height": h} for n, _, h in heights])) + [{"markdown": md, "width": w, "height": h} for _, md, w, h in blocks]), css=css) out: dict = {} try: _apply(client, data) with sync_playwright() as p: - # Playwright hides scrollbars in headless Chromium; a reader on Windows sees them. - browser = p.chromium.launch(ignore_default_args=["--hide-scrollbars"]) - page = browser.new_context(viewport={"width": 1600, "height": 1200}).new_page() - page.set_default_timeout(90000) + browser, page = _page(p, viewport, scrollbars=True) _login(page, base, username, password) ids = _ids(data) _, seen = _measure(page, base, slug, ids) - for (name, kind, h), i in zip(heights, ids): + for (key, _, w, h), i in zip(blocks, ids): b = seen[i] - out[f"{name}@{kind}"] = {"height": h, "box": b["box"], "scrolls": b["scrolls"], - "text": b["text"]} + out[key] = {"width": w, "height": h, "box": b["box"], "scrolls": b["scrolls"], + "text": b["text"]} if shots is not None: shots.mkdir(parents=True, exist_ok=True) # The resizable container inside carries the same id. page.locator(f".dashboard-markdown[id='{i}']").screenshot( - path=str(shots / f"{slug}-{name}-{kind}-{h:g}.png")) + path=str(shots / f"{slug}-{key.replace('@', '-')}-{h:g}.png")) browser.close() finally: _delete(client, slug) return out +def verify_heights() -> tuple[list, list]: + """The verify pass: each corpus text at 6/12 at the fix's height, at the height it + measured (whole 8 px grid rows) and a unit less; each styled block at its own + width, at the fix's height and at the height it had when it was cut off.""" + corpus = [] + for name, text in CORPUS.items(): + corpus += [(f"{name}@fix", text, 6, estimate(text, 6).units)] + styled = [] + for name, width, then, text in STYLED: + styled += [(f"{name}@fix", text, width, estimate(text, width, css=STYLED_CSS).units), + (f"{name}@then", text, width, then)] + return corpus, styled + + def record(base: str, username: str, password: str, shots: Path | None) -> tuple[str, dict]: from playwright.sync_api import sync_playwright @@ -304,43 +518,76 @@ def record(base: str, username: str, password: str, shots: Path | None) -> tuple "tabs": [{"title": "Tab", "tabs": [{"title": "Sub-tab", "rows": [[md("about", 6)]]}]}], "footer": [[md("about", 4)]], }), + "styled": spec_for(f"{slug}-s", f"cw test mdfit {tag} s", rows_of( + [{"markdown": text, "width": w, "height": h} for _, w, h, text in STYLED]), + css=STYLED_CSS), } - out: dict = {"blocks": {}, "filter_bar": {}, "containers": {}} + probes = {name: spec_for(f"{slug}-{name}", f"cw test mdfit {tag} {name}", rows_of( + [{"markdown": STYLE_PROBE, "width": 12, "height": 30}]), css=css) + for name, css in (("default", None), ("probe", PROBE_CSS), ("line", LINE_CSS))} + out: dict = {"viewports": {}, "containers": {}, "styles": {}} try: + for data in [*specs.values(), *probes.values()]: + _apply(client, data) with sync_playwright() as p: - browser = p.chromium.launch() - page = browser.new_context(viewport={"width": 1600, "height": 1200}).new_page() - page.set_default_timeout(90000) - _login(page, base, username, password) - for key, data in specs.items(): - _apply(client, data) - ids = _ids(data) - grid_w, seen = _measure(page, base, data["dashboard"]["slug"], ids) - if key == "blocks": - out["grid_w"] = grid_w - out["blocks"] = {f"{n}@{w}": _keep(seen[i]) for (n, w), i in zip(names, ids)} - elif key == "filter_bar": - out["grid_w_filter_bar"] = grid_w - out["filter_bar"] = {f"about@{w}": _keep(seen[i]) for w, i in zip(WIDTHS, ids)} - else: + for width in (1600, VIEWPORT): + browser, page = _page(p, width) + _login(page, base, username, password) + at = out["viewports"][str(width)] = {} + ids = _ids(specs["blocks"]) + at["grid_w"], seen = _measure(page, base, specs["blocks"]["dashboard"]["slug"], ids) + at["blocks"] = {f"{n}@{w}": _keep(seen[i]) for (n, w), i in zip(names, ids)} + ids = _ids(specs["filter_bar"]) + at["grid_w_filter_bar"], seen = _measure( + page, base, specs["filter_bar"]["dashboard"]["slug"], ids) + at["filter_bar"] = {f"about@{w}": _keep(seen[i]) for w, i in zip(WIDTHS, ids)} + ids = _ids(specs["styled"]) + _, seen = _measure(page, base, specs["styled"]["dashboard"]["slug"], ids) + at["styled"] = {name: _keep(seen[i]) for (name, *_), i in zip(STYLED, ids)} + if width == 1600: + ids = _ids(specs["containers"]) + _, seen = _measure(page, base, specs["containers"]["dashboard"]["slug"], ids) where = {"header": "header@12", "footer": "footer@4"} out["containers"] = {where.get(i.split("-")[2], "sub-tab@6"): seen[i]["content_w"] for i in ids} - browser.close() + ids = _ids(specs["styled"]) + seen = _styles(page, base, specs["styled"]["dashboard"]["slug"], ids) + out["styles"]["styled"] = {name: seen[i] for (name, *_), i in zip(STYLED, ids)} + for name, data in probes.items(): + ids = _ids(data) + out["styles"][name] = _styles(page, base, data["dashboard"]["slug"], ids)[ids[0]] + browser.close() finally: - for data in specs.values(): + for data in [*specs.values(), *probes.values()]: _delete(client, data["dashboard"]["slug"]) - # Each text at the fix's height, at the height it measured (whole 8 px grid rows), - # and a unit less. - heights = [] - for name in CORPUS: - fits = min(100, math.ceil(out["blocks"][f"{name}@6"]["need"] / 8) / 5) - heights += [(name, "fix", estimate(CORPUS[name], 6).units), (name, "fits", fits), - (name, "short", round(max(0.2, fits - 1), 1))] - out["verify"] = verify(client, base, username, password, f"{slug}-v", heights, shots) + corpus, styled = verify_heights() + # A corpus text at the height it measured, and a unit less. + for name, text in CORPUS.items(): + fits = min(100, math.ceil(out["viewports"][str(VIEWPORT)]["blocks"][f"{name}@6"]["need"] / 8) / 5) + corpus += [(f"{name}@fits", text, 6, fits), + (f"{name}@short", text, 6, round(max(0.2, fits - 1), 1))] + out["verify"] = verify(client, base, username, password, f"{slug}-v", corpus, shots=shots) + out["verify_css"] = verify(client, base, username, password, f"{slug}-vs", styled, + css=STYLED_CSS, shots=shots) return release, out +def dump(value, indent: int = 0) -> str: + """The fixture as JSON, an array that holds a plain value (a row: one element's + computed style, a styled block) on a line of its own.""" + pad = " " * (indent + 1) + if isinstance(value, dict): + items = [f"{pad}{json.dumps(k)}: {dump(v, indent + 1)}" for k, v in sorted(value.items())] + text = "{\n" + ",\n".join(items) + "\n" + " " * indent + "}" if items else "{}" + elif isinstance(value, list) and value and all(isinstance(v, (list, dict)) for v in value): + text = "[\n" + ",\n".join(pad + dump(v, indent + 1) for v in value) + "\n" + " " * indent + "]" + elif isinstance(value, list): + text = "[" + ", ".join(dump(v, indent) for v in value) + "]" + else: + text = json.dumps(value, ensure_ascii=False) + return text + ("\n" if indent == 0 else "") + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--base-url", action="append", required=True) @@ -349,21 +596,25 @@ def main(argv: list[str] | None = None) -> int: args = ap.parse_args(argv) password = os.environ.get("SDC_CI_PASSWORD", "admin") # sandbox default; never in argv - rec = json.loads(OUT.read_text(encoding="utf-8")) if OUT.exists() else {"releases": {}} - rec["about"] = ("Markdown blocks Superset drew in headless Chromium at a 1600 x 1200 " - "viewport, in px; written by tools/record_markdown_fit.py, which says " - "what each number is.") + old = json.loads(OUT.read_text(encoding="utf-8")) if OUT.exists() else {} + rec = {"releases": old.get("releases", {})} + rec["about"] = ("Markdown blocks Superset drew in headless Chromium, in px; written by " + "tools/record_markdown_fit.py, which says what each number is.") rec["corpus"] = CORPUS rec["widths"] = WIDTHS + rec["styled"] = [list(s) for s in STYLED] + rec["styled_css"] = STYLED_CSS + rec["style_probe"] = {"markdown": STYLE_PROBE, "probe": PROBE_CSS, "line": LINE_CSS} for base in args.base_url: release, measured = record(base.rstrip("/"), args.username, password, args.shots) rec["releases"][release] = measured - scrolled = sorted(k for k, v in measured["verify"].items() if v["scrolls"]) - print(release, f"grid {measured['grid_w']} px ({measured['grid_w_filter_bar']} px " - f"beside the filter bar); containers {measured['containers']}; scrolls at", + scrolled = sorted(k for k, v in {**measured["verify"], **measured["verify_css"]}.items() + if v["scrolls"]) + grids = {w: (v["grid_w"], v["grid_w_filter_bar"]) for w, v in measured["viewports"].items()} + print(release, f"grids {grids}; containers {measured['containers']}; scrolls at", scrolled, flush=True) OUT.parent.mkdir(parents=True, exist_ok=True) - OUT.write_text(json.dumps(rec, indent=1, sort_keys=True) + "\n", encoding="utf-8") + OUT.write_text(dump(rec), encoding="utf-8") print(f"wrote {OUT.relative_to(REPO).as_posix()}") return 0