zz-meridian 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -572,6 +572,14 @@ export const RELEASE_MIGRATIONS = [
572
572
  instructions: 'The audit, the presses, the keyboard walk, the vitals and `scripts/shot.ts` now read what a route answered: one that answered an error status or draws the not-found screen (`[data-missing-page]`) was measured as that screen and not as the route, so it fails. In `scripts/verify.config.ts`, which is yours, move every entry of `detailRoutes` that names a record that does not exist (the sample\'s `/requests/req_missing` and `/customers/missing`) into a new `missingRoutes` list; each must be the not-found screen, and one that renders a page fails. A not-found screen of your own carries `data-missing-page` on its root, as `MissingPage` does: inside a streamed shell a missing record has already answered 200.',
573
573
  checks: ['verify'],
574
574
  },
575
+ {
576
+ id: 'self-hosted-fonts',
577
+ since: '0.34.0',
578
+ applies: (root) => matching(root, (rel, src) => /^app\//.test(rel) && SOURCE.test(rel) && /from\s+['"]next\/font\/google['"]/.test(src) && /--font-face-(?:sans|mono)/.test(src)),
579
+ summary: "Meridian's faces ship with the release under `src/styles/fonts/`: a build loads them from there and fetches nothing from Google Fonts.",
580
+ instructions: "Your `app/layout.tsx` is yours, so an update does not touch it, and yours hands `--font-face-sans` and `--font-face-mono` their faces through `next/font/google`, which `next build` fetches from fonts.googleapis.com: with that host unreachable and no build cache, the build fails (`Build error occurred`). The update has delivered the two faces, `src/styles/fonts/Geist-Variable.woff2` and `src/styles/fonts/GeistMono-Variable.woff2`, with their licence. In the layout, replace the `next/font/google` import with `import localFont from 'next/font/local';` and each face's call with `localFont({ src: '../src/styles/fonts/Geist-Variable.woff2', weight: '100 900', variable: '--font-face-sans', display: 'swap' })` and `localFont({ src: '../src/styles/fonts/GeistMono-Variable.woff2', weight: '100 900', variable: '--font-face-mono', display: 'swap' })`, keeping the class names on `<html>` as they are. A layout that gives the tokens faces of its own from Google Fonts is the same case: ship the files and load them with `next/font/local`.",
581
+ checks: GATE_AND_BUILD,
582
+ },
575
583
  ];
576
584
  /** 0.32.0: a verify configuration that lists detail routes and declares no routes meant to be missing. */
577
585
  function detailRoutesWithoutMissing(root) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,19 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [0.34.0] · 2026-10-29
6
+
7
+ ### Changed
8
+
9
+ - **A build fetches nothing: the typefaces ship with the template.** Geist and Geist Mono came from Google Fonts through `next/font/google`, which `next build` fetches at build time, so with fonts.googleapis.com unreachable and no build cache the build failed (`Build error occurred`, exit 1), as the first 0.33.0 release run's update of a created project did (issue #100). The two variable faces are under `src/styles/fonts/` (from `geist@1.8.2`, with their SIL Open Font License), managed, and `app/layout.tsx` loads them with `next/font/local`. Each face is now one whole file, where Google served its Latin glyphs in a subset of their own.
10
+ - **What a team does.** `app/layout.tsx` is yours, so an update does not change it: run `update`, which delivers the faces and reports `migration:self-hosted-fonts` for a layout that still hands `--font-face-sans` and `--font-face-mono` their faces from Google Fonts, and load each with `next/font/local` as the release's layout does.
11
+
12
+ ### Fixed
13
+
14
+ - **A Bar list's axis draws behind its rows.** Its gridlines, positioned before the rows, painted over every label that is not a link, the meta and each figure, and, under reduced motion, over the bars: the words and the bars now stand over them, and the words over the bars, so a linked label's target under a finger still takes a tap on its bar.
15
+ - **A row or a part below zero draws nothing, never the whole track.** A Bar list row below zero set a negative width, which a browser drops, so its bar filled the track; it draws an empty bar, its figure the value it holds and its share of `total` `0.0%`. A Composition bar summed its signed parts into its whole, so a negative part turned every other share negative; the whole is the sum of the parts above zero (`compositionTotal`), and a part below zero draws as a part of zero, its share `0%`, and adds nothing to Other, as a Bar list row below zero adds nothing to its "N others". A share above none too small for one decimal reads `<0.1%`, never `0.0%`. `tests/chart-semantics.test.tsx` now catches a size below zero where React sets it, which jsdom, like a browser, dropped before any check could read it.
16
+ - **A grouped Column chart's group with no data keeps its cut word in its title.** "No data" cut to a 36 to 45px column was held whole nowhere; the group's title holds it, as a plain column's does. The Column chart's README names the grouped form's ring and readout where it named the stacked form's alone.
17
+
5
18
  ## [0.33.0] · 2026-10-28
6
19
 
7
20
  The charts and parts a monitoring page needs (issue #99): bars in the order given, on a held and labelled scale, two values a row, signed rows about zero, straight and step lines with a dot per reading, grouped columns, a hatch for an estimate, a section that opens in place and a dialog wide enough for a table.
package/payload/README.md CHANGED
@@ -158,8 +158,8 @@ brief defines — which is also how you tell a wrong rendering from a wrong mode
158
158
  Meridian takes nothing from you. The package, the template and the skill have no telemetry, no analytics and no
159
159
  account, and the template turns off Next.js's own anonymous telemetry (`next.config.ts`; Meridian's scripts turn it off
160
160
  for every `next` they run). What reaches the network is what you would expect: npm, when you run `npx` and when `update`
161
- reads releases from it; Google Fonts, which `next/font` downloads the typefaces from at build time; and whatever you
162
- configure yourself, such as the assistant's model provider.
161
+ reads releases from it, and whatever you configure yourself, such as the assistant's model provider. The typefaces ship
162
+ with the template (`src/styles/fonts/`), so a build fetches nothing.
163
163
 
164
164
  The one way to tell us anything is a [GitHub issue](https://github.com/zhixuan312/zz-meridian/issues/new/choose): a
165
165
  **Bug** (something in Meridian did the wrong thing) or a **Feature request** (something it should do, or do more
@@ -1,13 +1,14 @@
1
1
  import type { Metadata, Viewport } from 'next';
2
- import { Geist, Geist_Mono } from 'next/font/google';
2
+ import localFont from 'next/font/local';
3
3
  import { app } from '@/app.config';
4
4
  import { PREPAINT } from '@/lib/preferences';
5
5
  import { Providers } from '@/components/base/providers';
6
6
  import './globals.css';
7
7
 
8
- /* The faces are self-hosted by next/font and handed to the tokens as --font-face-sans and --font-face-mono. */
9
- const sans = Geist({ subsets: ['latin'], variable: '--font-face-sans', display: 'swap' });
10
- const mono = Geist_Mono({ subsets: ['latin'], weight: ['400', '500'], variable: '--font-face-mono', display: 'swap' });
8
+ /* The faces ship with the template (src/styles/fonts, Geist and Geist Mono from geist@1.8.2, under their licence there),
9
+ so a build fetches nothing; next/font hands them to the tokens as --font-face-sans and --font-face-mono. */
10
+ const sans = localFont({ src: '../src/styles/fonts/Geist-Variable.woff2', weight: '100 900', variable: '--font-face-sans', display: 'swap' });
11
+ const mono = localFont({ src: '../src/styles/fonts/GeistMono-Variable.woff2', weight: '100 900', variable: '--font-face-mono', display: 'swap' });
11
12
 
12
13
  export const metadata: Metadata = {
13
14
  title: { default: app.name, template: `%s · ${app.name}` },
@@ -78,7 +78,7 @@ This is the inventory of the whole Meridian sample: every job the product is mea
78
78
  | S11 | Pattern: Rail | console navigation — `src/components/patterns/rail/index.tsx` | L3 | `src/components/patterns/rail` | compose | Used by the console chrome and holds the appearance menu. It takes the optional navigation and the workspace scopes that let the product supply one workspace at a time. Its preview draws the real rail with one workspace and with several, and no copy of its items. | `src/components/patterns/rail` | `/system/patterns/rail` | all console pages | normal | `tests/workspace-chrome.test.tsx`, `tests/console-chrome.test.tsx`, `tests/atlas-template-contracts.test.tsx`, `scripts/rail.ts` |
79
79
  | shared | Pattern: Shell tools | search pill and alerts — `src/components/patterns/shell-tools/index.tsx` | L3 | `src/components/patterns/shell-tools` | keep | Used by the dashboard layout and the states layout. The alerts panel lists what needs a person across the product and links to where each is handled; the layout hands it alerts worked out from the sample's records, and the card's own preview keeps its fixture. It is the closest thing to A7. | `src/components/patterns/shell-tools` | `/system/patterns/shell-tools` | all console pages | normal, empty | `tests/clock.test.tsx`, `tests/shell-alerts.test.ts` |
80
80
  | S5 | Pattern: Status list | every service, its 90-day history and, when asked, how long it has been operational — `src/components/patterns/status-list/index.tsx` | L3 | `src/components/patterns/status-list` | keep | Used by Now, by Services and by the Now embed. With `run` each row says how long the service has been operational, and that the run is its longest when no run among the days kept is longer; Now and Services ask for it. It is also the nearest composition for release lifecycle (S8) and run verdicts (S7). | `src/components/patterns/status-list` | `/system/patterns/status-list` | Now, Services, Now embed | normal, empty | `tests/status-list.test.tsx`, `tests/plural.test.ts` |
81
- | shared | Chart: Bar list | rank items by one measure — `src/components/charts/bar-list/index.tsx` | L3 | `src/components/charts/bar-list` | keep | Used by Usage, Revenue, One customer and Now. | `src/components/charts/bar-list` | `/system/patterns/bar-list` | Usage, Revenue, One customer, Now | normal, empty | `scripts/audit.ts` |
81
+ | shared | Chart: Bar list | rank items by one measure — `src/components/charts/bar-list/index.tsx` | L3 | `src/components/charts/bar-list` | keep | Used by Usage, Revenue, One customer and Now. | `src/components/charts/bar-list` | `/system/patterns/bar-list` | Usage, Revenue, One customer, Now | normal, empty | `tests/bar-list-links.test.tsx`, `tests/bar-list-forms.test.tsx`, `tests/bar-list-axis-words.test.tsx`, `tests/feel-bar-list.test.tsx`, `scripts/audit.ts` |
82
82
  | shared | Chart: Column chart | compare counts side by side, or read each period as a whole split into its parts — `src/components/charts/column-chart/index.tsx` | L3 | `src/components/charts/column-chart` | keep | Used by Usage for the hours of the day, which are sums over the heatmap's rows, with the busiest hour highlighted. On that chart the card writes a value above each column while a column is at least 36px wide, is one Tab stop whose arrow keys move the cursor the pointer moves, and reads the column pointed at to a screen reader. The card also draws each column as its parts, in one hue or in tones when the parts are outcomes, with a legend, a fold into Other past four parts, a readout and a table of every part and total; marks a last column that is a period still running and says so far; stands two or three identities side by side as a grouped form, with no Total, loaded only on a page that draws one; hatches an estimated column in every form and says estimate; and takes its card's height when asked. Home draws requests and revenue a period with the latest highlighted, Revenue draws its periods as parts in one hue, and Now its verification runs as parts in tones with the day in progress running; no page takes its card's height yet, and the card's preview does. | `src/components/charts/column-chart` | `/system/patterns/column-chart` | Main (`/`), Usage, Revenue, Now | normal, empty, partial | `tests/column-chart.test.tsx`, `tests/column-chart-stacked.test.tsx`, `tests/column-chart-grouped.test.tsx`, `scripts/audit.ts` |
83
83
  | shared | Chart: Composition bar | split one whole into parts — `src/components/charts/composition-bar/index.tsx` | L3 | `src/components/charts/composition-bar` | keep | Used by Usage for how the last 30 complete days were answered, by status class in status colours, and by Home and Revenue for what is sold and who pays. Single use is not a reason to remove a card the Atlas teaches. | `src/components/charts/composition-bar` | `/system/patterns/composition-bar` | Main (`/`), Revenue, Usage | normal | `scripts/audit.ts` |
84
84
  | shared | Chart: Diverging bars | signed rows either side of one zero line — `src/components/charts/diverging-bars/index.tsx` | L3 | `src/components/charts/diverging-bars` | keep | Added for issue #99: a signed change by category on a scale symmetric about zero, held at a domain or widened past it in words, the neutral population with one accent row, linked labels and hatched estimates. No page of the sample draws it yet; the Atlas teaches it, and its preview draws every state from the sample's customers against the company. | `src/components/charts/diverging-bars` | `/system/patterns/diverging-bars` | none: no page draws it yet; the Atlas preview does | normal, empty, partial | `tests/diverging-bars.test.tsx`, `tests/chart-semantics.test.tsx`, `scripts/audit.ts` |
@@ -91,7 +91,7 @@ This is the inventory of the whole Meridian sample: every job the product is mea
91
91
  | A2 | Return to a filtered view without rebuilding it | none | L3 | `src/components/patterns/filter-bar`, `app/(dashboard)/requests` | defer | Nothing was built. The filtered Requests view is already a link, because the filters live in the address, and a bookmark meets the job today. A saved, named view needs per-person storage the sample does not have; it comes back when a record for a person's views exists. | none | none: nothing was built, so there is no entry to specify | none | none | none |
92
92
  | A3 | See tickets by state as columns | none | L4 | `src/components/templates/records` | defer | Nothing was built. The tickets queue (S6) is a table with a state column and a state change. A board needs drag between columns and ordering rules that the sample's mutation model does not define; it comes back if the queue shows that state grouping is wanted and the ordering is defined. | none | none: nothing was built, so there is no entry to specify | none | none | none |
93
93
  | A4 | Compare two things side by side (two releases, two customers) | none | L3 | `src/components/patterns/data-table`, `src/components/ui/key-value` | defer | Nothing was built. Key-value facts and tables show one thing per row; a comparison needs a second selected subject and a diff rule. No scenario in the product asks for it, and Releases already marks the live version beside each candidate (S8); it comes back with a scenario that names two subjects. | none | none: nothing was built, so there is no entry to specify | none | none | none |
94
- | A5 | Read data a basic trend cannot show | none | L3 | `src/components/charts/trend-chart`, `src/components/charts/heatmap` | defer | Nothing was built. The sample already has trend, column, heatmap, composition, bar list and uptime charts. A new chart type needs a question the existing ones cannot answer, and none was found in the inventory; it comes back when one is. | none | none: nothing was built, so there is no entry to specify | none | none | none |
94
+ | A5 | Read data a basic trend cannot show | none | L3 | `src/components/charts/trend-chart`, `src/components/charts/heatmap` | defer | No page was built for it. The sample has trend, column, heatmap, composition, bar list and uptime charts, and Diverging bars (issue #99) answers one question they could not, a signed change by category; it is a card, not a page. A further chart type needs a question none of them answers; it comes back when one is. | none | none: nothing was built, so there is no entry to specify | none | none | none |
95
95
  | A6 | Review and decide on a list of agent proposals | none | L4 | `src/components/patterns/proposal`, `app/embed/proposal` | defer | Nothing was built. The Proposal card and its embed handle one change at a time, with Approve and Dismiss. A queue page needs stored proposals and an owner per proposal, which the sample does not hold; it comes back, records-shaped, if proposals are stored. | none | none: nothing was built, so there is no entry to specify | none | none | none |
96
96
  | A7 | Read what needs attention across the product | the alerts panel — `src/components/patterns/shell-tools/index.tsx` | L3 | `src/components/patterns/shell-tools` | compose | The top bar has an alerts popover with a new-count dot and a link per alert. The alerts are worked out when the page is read from unresolved incidents, customers past due, failing verifications and high-priority tickets nobody owns, and each opens the page where it is handled: customers past due, failing verifications and unowned high-priority tickets open their list filtered to those records (a lone customer past due opens its own page), and an incident opens Services. The panel keeps no record of what a person has read: its dot marks an incident that is still open. Home's lines worth knowing across the company serve the larger job; sending notifications is not invented. A separate page is deferred in favour of S1. | `src/logic/alerts.ts`, `src/views/shell-alerts.tsx` | `/system/patterns/shell-tools` | Main (`/`), top bar | normal, empty | `tests/shell-alerts.test.ts` |
97
97
  | A8 | Finish first-run setup in a known order | none | L3 | `src/components/ui/empty-state`, `src/components/ui/progress` | defer | Nothing was built. The pages have first-run empty states that point to the next action (create a key, invite a member), and a Progress component exists. A checklist needs a sequence the product defines and no such sequence exists; it comes back when the product defines one. | none | none: nothing was built, so there is no entry to specify | none | none | none |
@@ -151,10 +151,10 @@ Two environment variables exist for the updater's own tests and are used only wh
151
151
  reports as `migration:<id>` because it cannot make them. An entry applies when the project's recorded version is older
152
152
  than its `since` and the target is not, and its `applies(root)` returns the paths that still have the old shape, or
153
153
  null. It only reads, and only team-owned files (the ownership rule's managed paths are skipped); a file it cannot read is
154
- absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers`, `no-data-mark` and `table-grid`, `gate` alone), and its
154
+ absent. A project already on the new shape gets no line. Every entry carries `gate` and `build` as its checks (`verify-modes`, `agent-reads-section`, `nav-needs`, `agent-reads-own-group`, `agent-does-here-section`, `marker-measured-on-observe`, `view-page-matched-as-next`, `journey-moved`, `today-in-timezone`, `verify-tiers`, `no-data-mark`, `table-grid`, `direct-query-escape`, `app-description` and `live-stream-shutdown`, `gate` alone; `missing-routes`, `verify`), and its
155
155
  instructions name the section of a reference (`cache.md`, `live.md`, `validation.md`, `agents.md`, `customize.md`), of the Start guide or of `docs/assistant.md` to follow.
156
156
 
157
- Eighteen arrived after 0.5.0 and are resolved as the skill's `references/update.md` says: `share-view-context` and `agent-reads-section` (0.9.0), `assistant-view-tools` (0.10.0), `nav-needs` (0.13.0), `agent-reads-own-group` (0.15.0), `refusal-declared`, `managed-exports-removed`, `view-tool-page-explore`, `agent-does-here-section` and `sample-pages-renamed` (0.16.0), `marker-measured-on-observe` (0.16.2), `view-page-matched-as-next`, `journey-moved` and `today-in-timezone` (0.16.3), `verify-tiers` (0.17.0), `no-data-mark` (0.19.0), `table-grid` (0.26.0) and `four-layers` (0.28.0), which moves thirty-eight modules into `src/logic/`. The ten of 0.5.0 are:
157
+ Twenty-three arrived after 0.5.0 and are resolved as the skill's `references/update.md` says: `share-view-context` and `agent-reads-section` (0.9.0), `assistant-view-tools` (0.10.0), `nav-needs` (0.13.0), `agent-reads-own-group` (0.15.0), `refusal-declared`, `managed-exports-removed`, `view-tool-page-explore`, `agent-does-here-section` and `sample-pages-renamed` (0.16.0), `marker-measured-on-observe` (0.16.2), `view-page-matched-as-next`, `journey-moved` and `today-in-timezone` (0.16.3), `verify-tiers` (0.17.0), `no-data-mark` (0.19.0), `table-grid` (0.26.0), `four-layers` (0.28.0), which moves thirty-eight modules into `src/logic/`, `direct-query-escape` and `app-description` (0.30.0), `live-stream-shutdown` (0.31.1), `missing-routes` (0.32.0) and `self-hosted-fonts` (0.34.0). The ten of 0.5.0 are:
158
158
 
159
159
  | Id | Reported when a team-owned file still has | Completion step |
160
160
  |----|-------------------------------------------|-----------------|
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
@@ -193,6 +193,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
193
193
  | `four-layers` (0.28.0) | a file of the team's importing a module that has left `optional:src/views/` or `optional:src/data/` for `src/logic/` — the migration names the file and, in its instruction, every path the module left — or importing `afterStart` from `src/components/patterns/incident-card` or `summarise` from `src/components/patterns/status-list`, which no longer export them | thirty-eight modules are in `src/logic/` since 0.28.0, thirty-six of them from `optional:src/views/`, and each import is rewritten to the path the module has: `afterStart` is `src/logic/after-start.ts` and `summarise` is `src/logic/summarise.ts`, and those two must move because the cards are Meridian's and the update replaces them, while every path under `optional:src/views/` and `optional:src/data/` is the team's and stays as it is; a created project brings the release's `src/logic/` over with the pages that import it; the standard the layers come from is `data-layers.md`; checked by the gate and a build |
194
194
  | `live-stream-shutdown` (0.31.1) | `optional:src/data/live-stream.ts` that never ends its streams when the server is told to stop | a stopped `next start` closes its port and waits for its open connections, so the old process kept every tab's stream while the new one served the port and no tab heard a hint again until its next poll: bring the block that begins `Every stream still open` over from `live.md`, delete the stream from the open set first in its `close`, and register it after it listens for its abort; checked by the gate |
195
195
  | `missing-routes` (0.32.0) | `scripts/verify.config.ts` that lists `detailRoutes` and no `missingRoutes` | every browser suite now fails a route that answered an error or draws the not-found screen (`[data-missing-page]`), since it measured that screen and not the route: move each detail route that names a record that does not exist into `missingRoutes`, and give a not-found screen of your own `data-missing-page` on its root; checked by verify |
196
+ | `self-hosted-fonts` (0.34.0) | a file under `app/` that imports `next/font/google` and names `--font-face-sans` or `--font-face-mono` | the faces ship under `src/styles/fonts/` and an update delivered them: load each with `next/font/local` (`src: '../src/styles/fonts/Geist-Variable.woff2'` and `'../src/styles/fonts/GeistMono-Variable.woff2'`, `weight: '100 900'`, the same `variable`), so `next build` fetches nothing from Google Fonts and cannot fail when that host is unreachable; checked by the gate and the build |
196
197
 
197
198
  A created project that never changed the template's pages brings the release's version of each affected file over, with
198
199
  the files that arrived with them: the view contexts `optional:src/logic/home-context.ts` and its siblings, the view
@@ -9,7 +9,7 @@ Status: beta
9
9
  1. **Row**: label on the left (`text-sm`, `ink-2`; the highlighted row `ink` at `weight-medium`), an optional meta line, the value on the right (`ink`, tabular, `weight-medium`).
10
10
  2. **Track**: 6px tall, `fill-track`, `radius-full`.
11
11
  3. **Bar**: the value against the scale's top (the largest row, `total`, `max` or the axis's last level), `chart-neutral`; the one highlighted row in `accent`, and its row's title adds `highlightLabel`, what makes it the one ("The busiest endpoint"), so the accent is never a colour left to guess.
12
- 4. **Others**: past `limit`, the remaining rows fold into "N others", summed, in `ink-3`; a single extra row shows as itself, since "1 others" would hide a name to save no space. It is never a link, whatever the rows it holds.
12
+ 4. **Others**: past `limit`, the remaining rows fold into "N others", the sum of those above zero, in `ink-3`; a single extra row shows as itself, since "1 others" would hide a name to save no space. It is never a link, whatever the rows it holds.
13
13
  5. **Linked row**: a row with an `href` names a record that has a page, and its label is the one link to it, named by the label. The bar, the meta line and the figure stay text: they say what the label already opens, so they are never a second way there. At rest the row looks as an unlinked one does.
14
14
  6. **Compare** (under `pair`): 2px under the value bar, the row's `compare` on the same scale, a 1px `chart-neutral-strong` outline with no fill; none for a missing compare. The figure reads `{value} · {compare}`, the compare in `ink-3`, `—` for one with no data.
15
15
  7. **Legend** (under `pair`): above the rows, a filled and an outlined swatch named `pair.label` and `pair.compareLabel`, at `text-xs` in `ink-2`.
@@ -44,6 +44,7 @@ Rows are 14px apart; label to bar 6px. The list takes its container's width.
44
44
  | Hover (a linked row's label) | The label takes `accent-ink` over `dur-hover`, and the bar steps up as on any row. |
45
45
  | Focus (a linked row) | The label's link shows the 2px `accent` ring. |
46
46
  | Empty | Render the card's Empty state. |
47
+ | Below zero | A row below zero draws an empty bar (and an empty outline under `pair`), its figure the value it holds and its share of `total` `0.0%`: a Bar list measures from zero, and signed rows are Diverging bars. |
47
48
 
48
49
  ## Behaviour
49
50
 
@@ -68,12 +68,13 @@ export function BarList({
68
68
  // A held top not above zero is no scale to draw against, so the bars are drawn against one.
69
69
  const cap = held ? (max > 0 ? max : 1) : 0;
70
70
  const sorted = order === 'given' ? items : [...items].sort((a, b) => b.value - a.value);
71
- // One row past the limit is shown as itself: "1 other" hides a name to save no space. A held scale, a pair and an
71
+ // One row past the limit is shown as itself: "1 other" hides a name to save no space. The fold sums the rows above
72
+ // zero: a row below zero measures nothing, and summed signed it would cancel another's length. A held scale, a pair and an
72
73
  // order that is the meaning show every row: a fold would sum what they are read one by one for.
73
74
  const cut = order === 'given' || held || pair ? sorted.length : sorted.length === limit + 1 ? limit + 1 : limit;
74
75
  const shown = sorted.slice(0, cut);
75
76
  const rest = sorted.slice(cut);
76
- if (rest.length) shown.push({ key: '__others', label: `${rest.length} ${plural(rest.length, 'other')}`, value: rest.reduce((a, b) => a + b.value, 0), estimate: rest.some((r) => r.estimate) });
77
+ if (rest.length) shown.push({ key: '__others', label: `${rest.length} ${plural(rest.length, 'other')}`, value: rest.reduce((a, b) => a + Math.max(0, b.value), 0), estimate: rest.some((r) => r.estimate) });
77
78
  const data = shown.flatMap((i) => (pair && i.compare != null ? [i.value, i.compare] : [i.value]));
78
79
  const ticks = !axis ? null : held ? heldTicks(0, cap) : niceTicks(Math.max(total ?? 0, ...data));
79
80
  const top = held ? Math.max(cap, ...data) : ticks ? ticks.at(-1)! : Math.max(1, total ?? 0, ...data);
@@ -91,10 +92,13 @@ export function BarList({
91
92
  const hot = it.key === highlight;
92
93
  // The row says what it is as well as its share (issue #94): "42.1% of 1,204" on its own never named the row, and
93
94
  // a list with no total had no title at all. The label is the row's own word, so it is used when it is text.
94
- const share = total ? `${((it.value / Math.max(1, total)) * 100).toFixed(1)}% of ${format(total)}` : null;
95
+ const share = total ? `${((Math.max(0, it.value) / Math.max(1, total)) * 100).toFixed(1)}% of ${format(total)}` : null;
95
96
  return (
96
97
  <li key={it.key} data-row={it.key} className="group/bar" title={[named(it), format(it.value), share, it.estimate ? 'estimate' : null, hot ? highlightLabel : null].filter(Boolean).join(' · ')}>
97
- <div className="mb-1.5 flex items-baseline gap-3 text-sm">
98
+ {/* Positioned, the words and the bar paint over the axis's gridlines, which are positioned before them; the bar
99
+ without the transform its animation gives it, under reduced motion, too. The words are lifted over the bar,
100
+ so a link's 44px target under a finger, which reaches across the track, still takes the tap. */}
101
+ <div className="relative z-1 mb-1.5 flex items-baseline gap-3 text-sm">
98
102
  {it.href ? (
99
103
  // The row's look at rest, the accent under the pointer and the focus ring on the keyboard. The hit area is
100
104
  // drawn outside the link, so the link does not clip: the label inside it truncates.
@@ -114,11 +118,11 @@ export function BarList({
114
118
  <div
115
119
  data-bar
116
120
  data-estimate={it.estimate || undefined}
117
- className={cn('grow-x h-full rounded-full transition-colors duration-(--dur-hover)', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/bar:bg-chart-neutral-strong')}
118
- style={{ width: `${(it.value / top) * 100}%`, ['--i' as string]: n }}
121
+ className={cn('grow-x relative h-full rounded-full transition-colors duration-(--dur-hover)', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/bar:bg-chart-neutral-strong')}
122
+ style={{ width: `${(Math.max(0, it.value) / top) * 100}%`, ['--i' as string]: n }}
119
123
  />
120
124
  </div>
121
- {pair && it.compare != null ? <div data-compare className="mt-0.5 h-1.5 rounded-full border border-chart-neutral-strong" style={{ width: `${(it.compare / top) * 100}%` }} /> : null}
125
+ {pair && it.compare != null ? <div data-compare className="mt-0.5 h-1.5 rounded-full border border-chart-neutral-strong" style={{ width: `${(Math.max(0, it.compare) / top) * 100}%` }} /> : null}
122
126
  </li>
123
127
  );
124
128
  })}
@@ -75,7 +75,7 @@ The readout is at least 144px wide and never wider than the chart, with `space-3
75
75
  | Arriving | Columns grow from the baseline over `dur-grow`, 12ms apart; values at rest fade in over `dur-enter`. |
76
76
  | Rest | Columns, and their values where a column is 36px or wider. No readout. |
77
77
  | Hover | Plain, neutral columns step up to `chart-neutral-strong` over `dur-hover`. |
78
- | Pointed | By the pointer or the keys. Plain: the column takes the accent, and its value stands above it in `ink-inverse` on `surface-inverse`. Stacked: the column takes a 2px `ink` ring over `dur-hover`, its total steps up to `ink`, and the readout shows. |
78
+ | Pointed | By the pointer or the keys. Plain: the column takes the accent, and its value stands above it in `ink-inverse` on `surface-inverse`. Stacked or grouped: the column takes a 2px `ink` ring over `dur-hover` and the readout shows; stacked, its total steps up to `ink`. |
79
79
  | Highlighted | With no column pointed at, the highlighted column is the accent one (plain) or wears the ring (stacked and grouped). Its title adds `highlightLabel`, and the table's caption names it the way the mark draws it: `Accented: {highlightLabel}` plain, `Ringed: {highlightLabel}` stacked and grouped. |
80
80
  | Focus | The plot wears a 2px `accent` outline, 4px off its box, on `radius-sm`. The ring encloses the plot's box and so all the plot draws: the gridlines, every tick's label whole, the columns, their values at rest and the pointed value. It stands 4px under the line of the zero tick's label and 2px over the line of the x labels, and encloses neither the legend nor the x labels. |
81
81
  | Running | The last column's fill at half strength, inside a 1px dashed `ink` outline, open at the baseline, 1px off the fill; "so far" where it is read. Grouped, the group's bars at half strength inside one outline. |
@@ -90,7 +90,7 @@ The readout is at least 144px wide and never wider than the chart, with `space-3
90
90
  - **Pointer**: entering or tapping a column points at it; leaving the chart lets go.
91
91
  - **Keyboard**: the plot is one Tab stop, and its ring is the plot's own outline. Left and Right move the cursor a column, Home and End go to the first and the last. With no column pointed at, the cursor starts from the last column: a first Left lands on the last but one, a first Right on the last. Escape and blur let go.
92
92
  - **One cursor**: the keys move the cursor the pointer moves. With `dates`, one a column, that cursor is the page's Meridian index, so every chart and tile on the page reads the same day.
93
- - **Readout**: stacked only, and only on the chart being pointed at: a chart that follows another chart's cursor on the page's Meridian rings the day and keeps its panel hidden, as the Trend chart does. It stands after a column in the first half of the plot and before one in the second, measures its own width and stays inside the chart; long labels wrap. Under 640px it pins to the plot's leading edge.
93
+ - **Readout**: stacked and grouped, and only on the chart being pointed at: a chart that follows another chart's cursor on the page's Meridian rings the day and keeps its panel hidden, as the Trend chart does. It stands after a column in the first half of the plot and before one in the second, measures its own width and stays inside the chart; long labels wrap. Under 640px it pins to the plot's leading edge.
94
94
  - **Width**: the chart reads its own width, and every rule about width follows it, not the window, except the readout's pin to the leading edge, which follows a window under 640px.
95
95
 
96
96
  ## Surfaces
@@ -100,7 +100,7 @@ export function GroupedColumns({
100
100
  data-active={pointed ? '' : undefined}
101
101
  data-running={unfinished ? '' : undefined}
102
102
  data-no-data={b.none ? '' : undefined}
103
- title={b.key === highlight ? highlightLabel : undefined}
103
+ title={[b.none && column >= REST && full.length * 7 > column ? full : null, b.key === highlight ? highlightLabel : null].filter(Boolean).join(' · ') || undefined}
104
104
  className="relative h-full min-w-0 flex-1"
105
105
  onPointerEnter={() => set(i)}
106
106
  onPointerDown={() => set(i)}
@@ -14,7 +14,7 @@ Status: beta
14
14
  | Variant | Colours |
15
15
  |---|---|
16
16
  | Status | Parts that carry a judgement take status colours (`warning`, `critical`); neutral classes take `neutral` and, beside it, `neutral-ink` (both at least 3:1 on the surface, unlike the `chart-neutral` track), never the accent; the legend names each in words |
17
- | Categories | Parts with no judgement take slots 1, 2, 3 in order. The palette has six slots: past six parts, the first five keep theirs and the rest are drawn as one, "Other", their sum, in `neutral` |
17
+ | Categories | Parts with no judgement take slots 1, 2, 3 in order. The palette has six slots: past six parts, the first five keep theirs and the rest are drawn as one, "Other", their sum above zero, in `neutral` |
18
18
  | Stacked | `stacked`: the parts are the ones a stacked Trend or Column chart on the same page draws, so a part with no colour takes `stackPart(place, parts)`, the one-hue stack by its place, and its swatch sits on an `ink-3` hairline as the stacked charts' do. A part is one colour wherever the page draws it |
19
19
 
20
20
  ## Sizes
@@ -28,6 +28,7 @@ The bar takes its container's width; the legend wraps.
28
28
  | Arriving | The bar reveals left to right over `dur-grow`. |
29
29
  | Hover | A part's native title gives its name and count. |
30
30
  | Empty | A whole of zero renders the card's Empty state. |
31
+ | Below zero | A part below zero is no share of the whole: the whole is the sum of the parts above zero, and the part draws as a part of zero does, at the 3px sliver, its share `0%` and its count the value it holds; folded into Other, it adds nothing to Other. |
31
32
 
32
33
  ## Behaviour
33
34
 
@@ -44,7 +45,7 @@ The parts and shares are shared as structured context by the view; the legend te
44
45
 
45
46
  ## Accessibility
46
47
 
47
- A labelled `figure`; the legend is the accessible content. Shares under 10% carry one decimal so small parts are not rounded to zero.
48
+ A labelled `figure`; the legend is the accessible content. Shares under 10% carry one decimal so small parts are not rounded to zero; a part above none too small for that decimal reads `<0.1%`, and a part of none `0%`.
48
49
 
49
50
  ## Content
50
51
 
@@ -8,16 +8,24 @@ const fill = (c: Part['color'], i: number, stack: number | null) =>
8
8
  c === 'positive' || c === 'warning' || c === 'critical' ? `var(--${c})` : c === 'neutral-ink' ? 'var(--ink-2)' : c === undefined && stack !== null ? stackPart(i, stack) : SERIES_VAR(c ?? ((i + 1) as SeriesSlot));
9
9
 
10
10
  /**
11
- * The parts drawn: as given up to the palette's six, and past it the first five and "Other", the sum of the rest, in the
12
- * neutral. A seventh slot names no colour (`--series-7`), and its part drew transparent: its share gone from the bar and
13
- * its swatch blank in the key.
11
+ * The parts drawn: as given up to the palette's six, and past it the first five and "Other", the sum of the rest above
12
+ * zero, in the neutral: a part below zero is no share of the whole, folded or not. A seventh slot names no colour
13
+ * (`--series-7`), and its part drew transparent: its share gone from the bar and its swatch blank in the key.
14
14
  */
15
15
  function drawn(parts: Part[]): Part[] {
16
16
  if (parts.length <= SERIES_SLOTS) return parts;
17
17
  const rest = parts.slice(SERIES_SLOTS - 1);
18
- return [...parts.slice(0, SERIES_SLOTS - 1), { label: 'Other', value: rest.reduce((n, p) => n + p.value, 0), color: 'neutral' }];
18
+ return [...parts.slice(0, SERIES_SLOTS - 1), { label: 'Other', value: rest.reduce((n, p) => n + Math.max(0, p.value), 0), color: 'neutral' }];
19
19
  }
20
20
 
21
+ /** A part's share of the whole: to one decimal under 10%, so a small part is not rounded to zero, and a part above none
22
+ * too small for that decimal reads `<0.1%`; a part of none reads `0%`. */
23
+ const share = (value: number, total: number) => {
24
+ const pct = (Math.max(0, value) / total) * 100;
25
+ if (pct > 0 && pct < 0.05) return '<0.1%';
26
+ return `${pct.toFixed(pct > 0 && Number(pct.toFixed(1)) < 10 ? 1 : 0)}%`;
27
+ };
28
+
21
29
  /** One whole split into its parts: a single bar with a 2px gap between segments, and a legend that carries the numbers. */
22
30
  export function CompositionBar({
23
31
  parts: given,
@@ -44,7 +52,7 @@ export function CompositionBar({
44
52
  <figure aria-label={label} className={cn('@container min-w-0', className)}>
45
53
  <div className="reveal-x flex h-2.5 gap-0.5 overflow-hidden rounded-full">
46
54
  {parts.map((p, i) => (
47
- <div key={p.label} className="h-full first:rounded-l-full last:rounded-r-full" style={{ width: `${(p.value / total) * 100}%`, minWidth: 3, background: fill(p.color, i, stack) }} title={`${p.label}: ${format(p.value)}`} />
55
+ <div key={p.label} className="h-full first:rounded-l-full last:rounded-r-full" style={{ width: `${(Math.max(0, p.value) / total) * 100}%`, minWidth: 3, background: fill(p.color, i, stack) }} title={`${p.label}: ${format(p.value)}`} />
48
56
  ))}
49
57
  </div>
50
58
  <figcaption className={legend ? 'mt-4 grid grid-cols-2 gap-x-6 gap-y-3 @min-[36rem]:grid-cols-4' : 'sr-only'}>
@@ -56,7 +64,7 @@ export function CompositionBar({
56
64
  {p.label}
57
65
  </div>
58
66
  <div className="mt-1 flex items-baseline gap-1.5">
59
- <span className="t-num text-sm font-medium">{((p.value / total) * 100).toFixed(p.value / total < 0.1 ? 1 : 0)}%</span>
67
+ <span className="t-num text-sm font-medium">{share(p.value, total)}</span>
60
68
  <span className="t-num t-caption">{format(p.value)}</span>
61
69
  </div>
62
70
  </div>
@@ -65,9 +65,10 @@ export function columnFold(
65
65
  }
66
66
 
67
67
  /**
68
- * A composition bar's own denominator: every part's `value`, and never zero, so an empty bar still divides by one and
69
- * draws nothing rather than a part of no whole.
68
+ * A composition bar's own denominator: every part's `value` above zero, and never zero, so an empty bar still divides by
69
+ * one and draws nothing rather than a part of no whole. A part below zero is no share of a whole and adds nothing, so
70
+ * signed parts never turn the shares of the rest negative.
70
71
  */
71
72
  export function compositionTotal(parts: readonly { value: number }[]): number {
72
- return parts.reduce((total, part) => total + part.value, 0) || 1;
73
+ return parts.reduce((total, part) => total + Math.max(0, part.value), 0) || 1;
73
74
  }
@@ -0,0 +1,92 @@
1
+ Copyright (c) 2023 Vercel, in collaboration with basement.studio
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font creation
14
+ efforts of academic and linguistic communities, and to provide a free and
15
+ open framework in which fonts may be shared and improved in partnership
16
+ with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply
25
+ to any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software components as
36
+ distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to, deleting,
39
+ or substituting -- in part or in whole -- any of the components of the
40
+ Original Version, by changing formats or by porting the Font Software to a
41
+ new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION AND CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
49
+ redistribute, and sell modified and unmodified copies of the Font
50
+ Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components,
53
+ in Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the corresponding
64
+ Copyright Holder. This restriction only applies to the primary font name as
65
+ presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created
77
+ using the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -106,6 +106,17 @@ async function draw(el: ReactElement) {
106
106
  /** Everything a reader or a screen reader is given: the text, every attribute that names something, every style. */
107
107
  const said = (c: HTMLElement) => [c.textContent ?? '', ...[...c.querySelectorAll('*')].flatMap((n) => ['title', 'aria-label', 'aria-valuetext', 'style', 'd', 'x', 'y', 'width', 'height', 'cx', 'cy', 'points', 'transform'].map((a) => n.getAttribute(a) ?? ''))].join('\n');
108
108
 
109
+ /**
110
+ * Every size below zero React sets on a mark. jsdom drops a negative width or height set through `style`, as a browser
111
+ * does (which then draws the mark at its auto size), so no attribute keeps it: it is caught where React sets it.
112
+ */
113
+ const below: string[] = [];
114
+ const styles = Object.getPrototypeOf(document.createElement('div').style);
115
+ for (const prop of ['width', 'height', 'minWidth', 'minHeight']) {
116
+ const d = Object.getOwnPropertyDescriptor(styles, prop)!;
117
+ Object.defineProperty(styles, prop, { ...d, set(v: unknown) { if (/^\s*-\d/.test(String(v))) below.push(`${prop}: ${v}`); d.set!.call(this, v); } });
118
+ }
119
+
109
120
  /** A cell that says nothing but zero, in any unit: `0`, `$0`, `0.0%`, `0 pts`. */
110
121
  const ZERO = /^[^\d]*0(?:\.0+)?(?:\s*[a-z%]+)?$/i;
111
122
  /** The levels a chart names on its axis: the Column chart's tick spans, the Trend chart's axis text. */
@@ -143,11 +154,13 @@ describe('every chart and figure, drawn with every shape a product’s data take
143
154
  describe(name, () => {
144
155
  for (const [shape, values] of Object.entries(SHAPES)) {
145
156
  it(`${shape}: no number that is not one, and no mark sized by one`, async () => {
157
+ below.length = 0;
146
158
  const c = await draw(drawn(values));
147
159
  const out = said(c);
148
160
  expect(out, 'a NaN, Infinity or undefined reached the page').not.toMatch(/\bNaN\b|Infinity|\bundefined\b/);
149
- // A size below zero draws nothing where a mark should be, or flips it.
161
+ // A size below zero draws nothing where a mark should be, flips it, or is dropped and draws it at its auto size.
150
162
  expect(out, 'a mark is sized below zero').not.toMatch(/(?:^|[;\s"])(?:height|width):\s*-\d/);
163
+ expect(below, 'a mark is sized below zero').toEqual([]);
151
164
  });
152
165
  }
153
166
  });
@@ -118,6 +118,8 @@ describe('estimates', () => {
118
118
  const c = await draw(<ColumnChart label="Counts" columns={plain} data-preview-index={0} />, 2);
119
119
  expect(column(c, 'a').querySelector('[data-estimate]')).not.toBeNull();
120
120
  expect(column(c, 'b').querySelector('[data-estimate]')).toBeNull();
121
+ // The readout: the pointed value says it is an estimate where it stands.
122
+ expect(column(c, 'a').textContent).toMatch(/ · estimate/);
121
123
  expect(c.querySelector('[aria-live="polite"]')?.textContent).toMatch(/\bestimate\b/);
122
124
  expect(body(c)[0][0]).toMatch(/\bestimate\b/);
123
125
  expect(c.textContent?.match(/Hatched: estimate/g)).toHaveLength(1);
@@ -24,6 +24,26 @@ describe('a composition bar', () => {
24
24
  expect(container.textContent).toMatch(/Other\s*36%/);
25
25
  });
26
26
 
27
+ // The review of fed2f39: the whole is the sum of the parts above zero, but Other still summed the folded parts signed,
28
+ // so a folded part below zero cancelled the sixth part's share out of the bar and the key.
29
+ it('folds only the parts above zero into Other, and reads a part below zero as a share of 0%', () => {
30
+ const parts = [10, 10, 10, 10, 10, 10, -10].map((value, i) => ({ label: `P${i}`, value }));
31
+ const { container } = render(<CompositionBar label="Mix" parts={parts} />);
32
+ const widths = [...container.querySelectorAll('figure > div:first-child > div')].map((d) => (d as HTMLElement).style.width);
33
+ expect(widths.at(-1)).toBe(`${(10 / 60) * 100}%`);
34
+ expect(container.textContent).toMatch(/Other\s*17%/);
35
+ const signed = render(<CompositionBar label="Signed" parts={[{ label: 'A', value: 12 }, { label: 'B', value: -8 }]} />);
36
+ expect(signed.container.textContent).toMatch(/B\s*0%-8/);
37
+ });
38
+
39
+ it('reads a part above zero as a share above none, and a share that rounds to 10% as 10%', () => {
40
+ const tiny = render(<CompositionBar label="Tiny" parts={[{ label: 'A', value: 99999 }, { label: 'B', value: 4 }, { label: 'C', value: 0 }]} />);
41
+ expect(tiny.container.textContent).toMatch(/B\s*<0\.1%4/);
42
+ expect(tiny.container.textContent).toMatch(/C\s*0%0/);
43
+ const edge = render(<CompositionBar label="Edge" parts={[{ label: 'A', value: 9003 }, { label: 'B', value: 997 }]} />);
44
+ expect(edge.container.textContent).toMatch(/B\s*10%997/);
45
+ });
46
+
27
47
  it('draws its parts in the categorical slots by default', () => {
28
48
  const { container } = render(<CompositionBar label="Plan mix" parts={PLANS} />);
29
49
  expect(fills(container)).toEqual(([1, 2, 3] as const).map((n) => SERIES_VAR(n)));
@@ -26,6 +26,8 @@ describe('the derivations', () => {
26
26
  // The composition's own denominator is the sum of its parts, and never zero.
27
27
  expect(logic.compositionTotal([{ value: 2 }, { value: 3 }])).toBe(5);
28
28
  expect(logic.compositionTotal([])).toBe(1);
29
+ // A part below zero is no share of a whole: it adds nothing, so signed parts never turn the shares of the rest negative.
30
+ expect(logic.compositionTotal([{ value: 12 }, { value: -8 }, { value: 20 }])).toBe(32);
29
31
  });
30
32
 
31
33
  it('leaves no card computing a figure it can be handed', () => {
@@ -28,6 +28,47 @@ describe('the forms of issue #99 on their edge paths', () => {
28
28
  expect(container.querySelector('[data-widened]')).toBeNull();
29
29
  });
30
30
 
31
+ it("a Bar list's words and bars are positioned, so its axis's gridlines, positioned before them, paint under them", () => {
32
+ const { container } = render(<BarList label="Regions" axis items={[{ key: 'a', label: 'A long region label', value: 40 }, { key: 'b', label: 'B', value: 10 }]} />);
33
+ expect(container.querySelector('[data-gridline]')).not.toBeNull();
34
+ for (const row of container.querySelectorAll('li[data-row]')) {
35
+ expect(row.firstElementChild?.classList.contains('relative')).toBe(true);
36
+ expect(row.querySelector('[data-bar]')?.classList.contains('relative')).toBe(true);
37
+ }
38
+ });
39
+
40
+ it("a linked Bar list row's words stand over its bar, so a finger's target reaching the track still opens the row", () => {
41
+ const { container } = render(<BarList label="Pipeline" axis items={[{ key: 'a', label: 'A', value: 40, href: '/customers/a' }, { key: 'b', label: 'B', value: 10 }]} />);
42
+ // The bar is positioned to paint over the gridlines; the words, positioned and lifted, paint over the bar, and the
43
+ // link's 44px target under a finger with them.
44
+ for (const row of container.querySelectorAll('li[data-row]')) expect(row.firstElementChild?.classList.contains('z-1')).toBe(true);
45
+ });
46
+
47
+ it('a Bar list row below zero draws an empty bar and outline, never a width a browser drops for the whole track', () => {
48
+ const below: string[] = [];
49
+ const styles = Object.getPrototypeOf(document.createElement('div').style);
50
+ const d = Object.getOwnPropertyDescriptor(styles, 'width')!;
51
+ Object.defineProperty(styles, 'width', { ...d, set(v: unknown) { below.push(String(v)); d.set!.call(this, v); } });
52
+ try {
53
+ const { container } = render(<BarList label="Change" pair={{ label: 'Now', compareLabel: 'Before' }} items={[{ key: 'a', label: 'A', value: 4, compare: -2 }, { key: 'b', label: 'B', value: -3, compare: 1 }]} />);
54
+ expect(below.filter((w) => w.startsWith('-'))).toEqual([]);
55
+ expect((container.querySelector('li[data-row="b"] [data-bar]') as HTMLElement).style.width).toBe('0%');
56
+ expect(container.querySelector('li[data-row="b"] [data-figure]')?.textContent).toMatch(/^-3/);
57
+ // Its length is zero, and so is the share its title reads.
58
+ const total = render(<BarList label="Change" total={10} items={[{ key: 'a', label: 'A', value: 4 }, { key: 'b', label: 'B', value: -1 }]} />);
59
+ expect(total.container.querySelector('li[data-row="b"]')?.getAttribute('title')).toBe('B · -1 · 0.0% of 10');
60
+ } finally {
61
+ Object.defineProperty(styles, 'width', d);
62
+ }
63
+ });
64
+
65
+ it("a Bar list's folded rows sum only the rows above zero, as a Composition bar's Other does", () => {
66
+ const items = [10, 9, 8, 7, 6, 5, 4, -3].map((value, i) => ({ key: `r${i}`, label: `R${i}`, value }));
67
+ const { container } = render(<BarList label="Change" total={60} items={items} />);
68
+ const others = container.querySelector('li[data-row="__others"]')!;
69
+ expect(others.getAttribute('title')).toBe(`2 others · 4 · ${((4 / 60) * 100).toFixed(1)}% of 60`);
70
+ });
71
+
31
72
  it('a Bar list held at a max not above zero draws against one', () => {
32
73
  const { container } = render(<BarList label="Rates" max={0} items={[{ key: 'a', label: 'A', value: 0.25 }]} />);
33
74
  expect((container.querySelector('[data-bar]') as HTMLElement).style.width).toBe('25%');
@@ -50,6 +91,15 @@ describe('the forms of issue #99 on their edge paths', () => {
50
91
  expect([...d3.querySelectorAll('[data-value]')].map((v) => v.textContent)).toEqual(['60', 'So far']);
51
92
  });
52
93
 
94
+ it('a grouped group with no data whose words are cut at rest keeps them whole in its title, as a plain column does', () => {
95
+ const parts = [{ key: 'in', label: 'In' }, { key: 'out', label: 'Out' }];
96
+ const columns = Array.from({ length: 11 }, (_, i) => ({ key: `d${i}`, label: `D${i}`, values: i === 4 ? [null, null] : [10 + i, 5 + i] }));
97
+ const { container } = render(<GroupedColumns grouped label="G" parts={parts} columns={columns} />);
98
+ const none = container.querySelector('[data-column="d4"]')!;
99
+ expect(none.querySelector('[data-value]')?.textContent).toBe('No data');
100
+ expect(none.getAttribute('title')).toBe('No data');
101
+ });
102
+
53
103
  it('a Trend chart draws no dot on its running point: nothing solid reaches a period still in progress', () => {
54
104
  const { container } = render(<TrendChart label="R" points running dates={dates} series={[{ key: 'r', label: 'R', values: [10, 20, 15, 5], kind: 'line' }]} />);
55
105
  expect(container.querySelectorAll('[data-point]')).toHaveLength(3);