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.
- package/dist/migrations.js +8 -0
- package/package.json +1 -1
- package/payload/CHANGELOG.md +13 -0
- package/payload/README.md +2 -2
- package/payload/app/layout.tsx +5 -4
- package/payload/docs/atlas-scenario-coverage.md +2 -2
- package/payload/docs/distribution.md +2 -2
- package/payload/package.json +1 -1
- package/payload/skills/zz-meridian/references/update.md +1 -0
- package/payload/src/components/charts/bar-list/README.md +2 -1
- package/payload/src/components/charts/bar-list/index.tsx +11 -7
- package/payload/src/components/charts/column-chart/README.md +2 -2
- package/payload/src/components/charts/column-chart/grouped.tsx +1 -1
- package/payload/src/components/charts/composition-bar/README.md +3 -2
- package/payload/src/components/charts/composition-bar/index.tsx +14 -6
- package/payload/src/logic/derivations.ts +4 -3
- package/payload/src/styles/fonts/Geist-Variable.woff2 +0 -0
- package/payload/src/styles/fonts/GeistMono-Variable.woff2 +0 -0
- package/payload/src/styles/fonts/LICENSE.txt +92 -0
- package/payload/tests/chart-semantics.test.tsx +14 -1
- package/payload/tests/column-chart-grouped.test.tsx +2 -0
- package/payload/tests/composition-bar.test.tsx +20 -0
- package/payload/tests/derivations.test.ts +2 -0
- package/payload/tests/review-held-scales.test.tsx +50 -0
package/dist/migrations.js
CHANGED
|
@@ -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.
|
|
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",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -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
|
|
162
|
-
|
|
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
|
package/payload/app/layout.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { Metadata, Viewport } from 'next';
|
|
2
|
-
import
|
|
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
|
|
9
|
-
|
|
10
|
-
const
|
|
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 |
|
|
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 `
|
|
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
|
-
|
|
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
|
|----|-------------------------------------------|-----------------|
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "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",
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
12
|
-
* neutral
|
|
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">{(
|
|
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
|
|
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
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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);
|