diff --git a/Cargo.lock b/Cargo.lock
index 578dc8b..c101ad9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5130,7 +5130,7 @@ dependencies = [
[[package]]
name = "widgetsack"
-version = "0.0.46"
+version = "0.0.47"
dependencies = [
"fontdb",
"futures-util",
diff --git a/client/e2e/groupHug.spec.ts b/client/e2e/groupHug.spec.ts
index d27ec9b..44cd0d1 100644
--- a/client/e2e/groupHug.spec.ts
+++ b/client/e2e/groupHug.spec.ts
@@ -48,3 +48,48 @@ test('a content-basis group floored at max-content sizes to its FULL content (ra
expect(m.rateBottom).toBeLessThanOrEqual(m.grpBottom + 1);
expect(m.rateH).toBeGreaterThan(5);
});
+
+// Cross-axis (horizontal) overflow regression: a content-basis GROUP in a COL hugs along the MAIN
+// axis (height) but must NOT be floored on the CROSS axis (width) — flooring width at max-content too
+// forces the slot WIDER than its 170px container when the content (two un-wrapping rate texts) is
+// wider, the live "Network widget overflows horizontally in studio" report. flowStyle now floors the
+// main axis only; this asserts the group stays within its container width.
+const crossHtml = (floorWidth: boolean) => `
+
+
+
+
+ ▲ 1234.5 MB/s
+ ▼ 6789.0 MB/s
+
+
+
+
`;
+
+test('a content-basis group in a col does NOT overflow its container horizontally (cross axis unfloored)', async ({
+ page
+}) => {
+ await page.setContent(crossHtml(false));
+ const w = await page.evaluate(() => {
+ const c = document.getElementById('container');
+ const g = document.getElementById('grp');
+ if (!c || !g) throw new Error('missing test nodes');
+ return { containerW: c.getBoundingClientRect().width, grpW: g.getBoundingClientRect().width };
+ });
+ // Main-axis-only flooring: the group width tracks the 170px container — no horizontal overflow.
+ expect(w.grpW).toBeLessThanOrEqual(w.containerW + 1);
+ expect(w.grpW).toBeGreaterThan(160);
+
+ // Sanity: the OLD both-axes behaviour (min-width:max-content) DID overflow, proving this guards a
+ // real regression and isn't trivially satisfiable.
+ await page.setContent(crossHtml(true));
+ const overflow = await page.evaluate(() => {
+ const c = document.getElementById('container');
+ const g = document.getElementById('grp');
+ if (!c || !g) throw new Error('missing test nodes');
+ return g.getBoundingClientRect().width > c.getBoundingClientRect().width + 1;
+ });
+ expect(overflow).toBe(true);
+});
diff --git a/client/src/lib/core/flowStyle.test.ts b/client/src/lib/core/flowStyle.test.ts
index d2db7d6..8e639bc 100644
--- a/client/src/lib/core/flowStyle.test.ts
+++ b/client/src/lib/core/flowStyle.test.ts
@@ -114,34 +114,49 @@ describe('itemStyle (sizing)', () => {
expect(itemStyle({ ...leaf(prim('A', 40, 20)) }, 'row').flexBasis).toBe('40px');
});
- it("basis 'content' on a FILL meter (gauge/gpu/…) → authored box as basis, floored at min-content", () => {
- // Keeps the stored box as the default extent (can't collapse to 0), but floors BOTH axes at
+ it("basis 'content' on a FILL meter (gauge/gpu/…) → authored box as basis, MAIN axis floored at min-content", () => {
+ // Keeps the stored box as the default extent (can't collapse to 0), and floors the MAIN axis at
// min-content so a content-bearing meter (e.g. GPU VRAM) is never squeezed below its content and
- // clipped by the slot's overflow:hidden — the "hug clips" fix.
+ // clipped by the slot's overflow:hidden — the "hug clips" fix. The CROSS axis is NOT floored
+ // (the parent's align-items:stretch bounds it to the container) — flooring it too overflows the
+ // container horizontally.
const s = itemStyle({ ...leaf(prim('A', 40, 20), 'content') }, 'row');
expect(s).toMatchObject({
flexGrow: 0,
flexShrink: 0,
flexBasis: '40px',
- minWidth: 'min-content',
- minHeight: 'min-content'
+ minWidth: 'min-content' // main axis (row → width)
});
- // col → main axis is height, so the basis tracks the stored height.
- expect(itemStyle({ ...leaf(prim('A', 40, 20), 'content') }, 'col').flexBasis).toBe('20px');
+ expect('minHeight' in s).toBe(false); // cross axis stays unbounded → stretches, no overflow
+ // col → main axis is height, so the basis (and the floor) tracks height instead.
+ const sc = itemStyle({ ...leaf(prim('A', 40, 20), 'content') }, 'col');
+ expect(sc.flexBasis).toBe('20px');
+ expect(sc.minHeight).toBe('min-content');
+ expect('minWidth' in sc).toBe(false);
});
- it("basis 'content' on a GROUP (dropped template) → group size as basis, floored at MAX-content", () => {
+ it("basis 'content' on a GROUP (dropped template) → group size as basis, MAIN axis floored at MAX-content", () => {
// The Network widget is a GROUP; hugging it must fit its FULL content (a collapsing sub-row
// falls out of min-content), so groups floor at max-content — not min-content like fill meters.
+ // Only the MAIN axis is floored: in a col that's height, so the cross axis (width) stays bounded
+ // by the container — flooring width too made the Network group overflow horizontally.
const g = group('g', { w: 80, h: 120 }, container('c', 'col', []));
const s = itemStyle({ ...leaf(g, 'content') }, 'col');
expect(s).toMatchObject({
flexGrow: 0,
flexShrink: 0,
flexBasis: '120px', // group size on the main (col) axis
- minWidth: 'max-content',
- minHeight: 'max-content'
+ minHeight: 'max-content' // main axis (col → height)
});
+ expect('minWidth' in s).toBe(false); // cross axis (width) not floored → no horizontal overflow
+ // row parent → main axis is width, so the floor lands on minWidth instead.
+ const sr = itemStyle({ ...leaf(g, 'content') }, 'row');
+ expect(sr.minWidth).toBe('max-content');
+ expect('minHeight' in sr).toBe(false);
+ // grid parent → no single main axis (2D cell), so BOTH axes keep the content floor.
+ const sg = itemStyle({ ...leaf(g, 'content') }, 'grid');
+ expect(sg.minWidth).toBe('max-content');
+ expect(sg.minHeight).toBe('max-content');
});
it("basis 'content' on an INTRINSIC text meter (clock/text) → content-fit (auto basis, shrinkable)", () => {
diff --git a/client/src/lib/core/flowStyle.ts b/client/src/lib/core/flowStyle.ts
index b4bee21..d9328c1 100644
--- a/client/src/lib/core/flowStyle.ts
+++ b/client/src/lib/core/flowStyle.ts
@@ -165,10 +165,17 @@ export function itemStyle(node: LayoutNode, parentKind: Container['kind']): Styl
} else if (basis === 'content' && isLeaf(node)) {
// 'content' on a FILL meter (gauge / sparkline / cpu / GPU panel / …, no intrinsic size) OR a
// GROUP (a dropped template / nested layout): keep its authored box as the DEFAULT extent
- // (flex-basis = the meter rect / the group size), but FLOOR both axes at the content extent so
- // it's never squeezed below its own content and clipped by the slot's overflow:hidden — the
- // GPU VRAM / Network "hug clips" reports. The cross axis (stretch) and a shrinking fr column
- // otherwise have no such floor.
+ // (flex-basis = the meter rect / the group size), but FLOOR the MAIN axis at the content extent
+ // so it's never squeezed below its own content and clipped by the slot's overflow:hidden — the
+ // GPU VRAM / Network "hug clips" reports. A shrinking fr column otherwise has no such floor.
+ //
+ // In a FLEX parent (row/col) floor the MAIN axis ONLY. The cross axis is governed by the parent's
+ // align-items:stretch and already fills the container; pinning the cross axis to a content floor
+ // too (min/max-content) forces the slot WIDER than its container when the content is wider than
+ // the box — the Network widget's "overflows horizontally in studio" report. Main-axis-only
+ // flooring fits the content along the hug direction while letting the cross axis stay bounded by
+ // its container. A GRID parent has no single main axis (the item fills a 2D cell), and there's no
+ // such overflow report there, so it keeps the original 2D floor (both axes).
//
// The floor differs by kind. A GROUP floors at MAX-content — its full natural content: a
// collapsing sub-row (e.g. the Network rate-text row, whose fr text leaves can shrink to 0)
@@ -183,8 +190,13 @@ export function itemStyle(node: LayoutNode, parentKind: Container['kind']): Styl
const box = isGroup(node.unit) ? node.unit.size : node.unit.rect;
s.flexBasis = `${parentKind === 'row' ? box.w : box.h}px`;
const floor = isGroup(node.unit) ? 'max-content' : 'min-content';
- s.minWidth = floor;
- s.minHeight = floor;
+ if (parentKind === 'row') s.minWidth = floor;
+ else if (parentKind === 'col') s.minHeight = floor;
+ else {
+ // grid: no single main axis — floor both so a hugged cell fits its content in 2D.
+ s.minWidth = floor;
+ s.minHeight = floor;
+ }
} else {
// 'auto' / unset: a LEAF takes its STORED main extent (primitive rect / group size) as the
// flex-basis, so a fill-meter (width/height:100%, no intrinsic size) keeps the authored box the
diff --git a/widgetsack/Cargo.toml b/widgetsack/Cargo.toml
index 740c2f5..6d4060b 100644
--- a/widgetsack/Cargo.toml
+++ b/widgetsack/Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "widgetsack"
# Keep in lockstep with tauri.conf.json's version (the bundle/updater source of truth).
-version = "0.0.46"
+version = "0.0.47"
description = "widgetsack desktop widget platform"
authors = ["you"]
license = "MIT OR Apache-2.0"
diff --git a/widgetsack/tauri.conf.json b/widgetsack/tauri.conf.json
index 4e07ae3..62bd766 100644
--- a/widgetsack/tauri.conf.json
+++ b/widgetsack/tauri.conf.json
@@ -50,7 +50,7 @@
"createUpdaterArtifacts": false
},
"productName": "widgetsack",
- "version": "0.0.46",
+ "version": "0.0.47",
"identifier": "io.github.gyng",
"plugins": {},
"app": {