diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84e0f69f9..0deb592c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,7 @@
- `405` responses list the allowed methods, a `415` to `PATCH` names the accepted type, and access requests answer `201`
- An anonymous `HEAD` on a document the requester may only append to is answered `200` with its entity tag instead of `403`: the front Varnish fetched a HEAD as a GET on a cache miss, so the platform decided it as a read. `HEAD` now bypasses the cache and is decided as a HEAD, the way a conditional write by an agent without `acl:Read` needs
- A chart drawn as a Table shows every variable its query selects, or every property its graph's resources carry, instead of only its category and series
+- A chart drawn as a Table is as tall as its rows instead of 400px, and a bar chart grows with its bars so that every bar keeps its label
### Security
- A document's types, which authorizations match on, come from its own graph only, so a write to one document cannot change another's access
diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css
index d35995bfb..84a218e93 100644
--- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css
+++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css
@@ -117,9 +117,12 @@ div[typeof="https://w3id.org/atomgraph/linkeddatahub#XHTML"] .ldh-edit-form .ldh
div[typeof="https://w3id.org/atomgraph/linkeddatahub#XHTML"] .ldh-edit-form .ldh-prop-form, div[typeof="https://w3id.org/atomgraph/linkeddatahub#XHTML"] .ldh-edit-form .ldh-prop-group, div[typeof="https://w3id.org/atomgraph/linkeddatahub#XHTML"] .ldh-edit-form .ldh-prop-row, div[typeof="https://w3id.org/atomgraph/linkeddatahub#XHTML"] .ldh-edit-form .ldh-prop-row > .value, div[typeof="https://w3id.org/atomgraph/linkeddatahub#XHTML"] .ldh-edit-form .val-main { display: block; padding-left: 0; padding-right: 0; }
div[typeof="https://w3id.org/atomgraph/linkeddatahub#XHTML"] form fieldset { border: none; }
object { display: block; margin: auto; max-height: 400px; overflow: hidden; width: 100%; }
-.chart-canvas { width: 100%; height: 400px; }
+/* the 400px a chart is drawn at, held while it loads; the drawing sets the height, so a bar chart of many rows is taller */
+.chart-canvas { width: 100%; min-height: 400px; }
/* an empty result set replaces the plot with the block empty state, which would otherwise sit stranded above 400px of nothing; its own padding supplies the inset the canvas would add */
-.chart-canvas:has(> .ldh-block-blank) { height: auto; padding: 0; }
+.chart-canvas:has(> .ldh-block-blank) { min-height: 0; padding: 0; }
+/* a table is as tall as its rows */
+.chart-canvas:has(.google-visualization-table) { min-height: 0; }
/* Google Charts Table specific (the ac:Table chart type renders as HTML, skinned here rather than via draw options) */
.chart-canvas .google-visualization-table-table { font-family: var(--font-sans); font-size: var(--fs-sm); color: var(--fg-1); }
.chart-canvas .google-visualization-table-table th { background: var(--surface-1); color: var(--fg-muted); font-weight: var(--fw-semibold); border-color: var(--border-default); }
diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl
index 02178b545..da6451971 100644
--- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl
+++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl
@@ -206,7 +206,9 @@ exclude-result-prefixes="#all"
-
+
+
diff --git a/tests/ui/specs/document/blocks/chart/size.spec.mjs b/tests/ui/specs/document/blocks/chart/size.spec.mjs
new file mode 100644
index 000000000..2d25e8624
--- /dev/null
+++ b/tests/ui/specs/document/blocks/chart/size.spec.mjs
@@ -0,0 +1,97 @@
+// A chart is as tall as what it draws.
+//
+// Every chart was drawn 400px tall, by the canvas's CSS and again by the height ldh:RenderChart handed Google Charts,
+// whatever the chart type. A Table of three rows stretched them to fill 400px and cut a long one off. A bar chart
+// draws its categories down the side, and once they no longer fit in 400px Google Charts skips every other label, so
+// a bar chart of 25 items named 13 of them.
+//
+// Each test seeds a document of its own: the shared fixtures chart three kinds, which fit either way.
+import { test, expect } from '../../../../lib/console.mjs';
+import { goto } from '../../../../lib/settle.mjs';
+import { fixtures, ldh, itemNumbers, itemTitle } from '../../../../lib/fixtures.mjs';
+import { endUserBase } from '../../../../lib/stack.mjs';
+import { canvas, chartBlock, drawing } from '../../../../lib/chart.mjs';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const AC = 'https://w3id.org/atomgraph/client#';
+
+// One row per kind: three
+const kindsQuery = `PREFIX sioc:
+PREFIX dct:
+
+SELECT ?kind (COUNT(?item) AS ?items)
+WHERE
+ { GRAPH ?g
+ { ?item sioc:has_container <${fixtures.container}> ;
+ dct:description ?kind
+ }
+ }
+GROUP BY ?kind
+ORDER BY ?kind`;
+
+// One row per item, named by its number alone, so that no label is long enough to be cut short
+const itemsQuery = `PREFIX sioc:
+PREFIX dct:
+
+SELECT ?number (STRLEN(?title) AS ?chars)
+WHERE
+ { GRAPH ?g
+ { ?item sioc:has_container <${fixtures.container}> ;
+ dct:title ?title
+ }
+ BIND (STRAFTER(?title, "item ") AS ?number)
+ }
+ORDER BY ?number`;
+
+const queryFile = text => {
+ const file = join(mkdtempSync(join(tmpdir(), 'ui-chart-size-')), 'q.rq');
+ writeFileSync(file, text);
+ return file;
+};
+
+async function seedChart(slug, query, type, category, series) {
+ const doc = `${endUserBase}${slug}/`;
+ await ldh(['delete', doc], { allowFailure: true });
+ await ldh(['create', 'item', '--container', endUserBase, '--title', `Chart size ${slug}`, '--slug', slug]);
+ await ldh(['add', 'select', '--title', 'Query', '--uri', `${doc}#query`, '--query-file', queryFile(query), doc]);
+ await ldh(['add', 'result-set-chart', '--title', 'Chart', '--uri', `${doc}#chart`, '--query', `${doc}#query`,
+ '--chart-type', type, '--category-var-name', category, '--series-var-name', series, doc]);
+ await ldh(['add', 'object-block', '--title', 'Chart block', '--uri', `${doc}#block`, '--value', `${doc}#chart`, doc]);
+ return { doc, chart: `${doc}#chart` };
+}
+
+// the documents are the owner's, unpublished, so only the owner can read them
+test.describe('the size of a chart', { tag: '@owner' }, () => {
+ test('a table is as tall as its rows', async ({ page }) => {
+ const { doc, chart } = await seedChart('ui-chart-size-table', kindsQuery, `${AC}Table`, 'kind', 'items');
+ try {
+ await goto(page, doc);
+ const table = canvas(chartBlock(page, chart)).locator('.google-visualization-table-table');
+ await expect(table.locator('tr')).toHaveCount(4, { timeout: 30_000 });
+
+ // a header and three rows, drawn at the height of a header and three rows - not stretched to 400px
+ const rows = await table.locator('tr').evaluateAll(trs => trs.map(tr => tr.getBoundingClientRect().height));
+ expect(Math.max(...rows), 'a row was stretched').toBeLessThan(80);
+ expect((await canvas(chartBlock(page, chart)).boundingBox()).height).toBeLessThan(400);
+ } finally {
+ await ldh(['delete', doc], { allowFailure: true });
+ }
+ });
+
+ test('a bar chart names every bar', async ({ page }) => {
+ const { doc, chart } = await seedChart('ui-chart-size-bars', itemsQuery, `${AC}BarChart`, 'number', 'chars');
+ try {
+ await goto(page, doc);
+ const block = chartBlock(page, chart);
+ await expect(drawing(block)).toBeVisible({ timeout: 30_000 });
+
+ const numbers = itemNumbers().map(n => itemTitle(n).split(' ').pop());
+ const labels = await canvas(block).locator('svg text').allTextContents();
+ expect(numbers.filter(number => !labels.includes(number)), 'bars without a label').toEqual([]);
+ } finally {
+ await ldh(['delete', doc], { allowFailure: true });
+ }
+ });
+});