From b5dbeae86461aa32d1bf462375a64ba597e0d714 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:21:55 +0200 Subject: [PATCH 01/24] test: fix custom option repair fixture --- .../custom-widgets/src/test/options.spec.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/custom-widgets/src/test/options.spec.ts b/packages/custom-widgets/src/test/options.spec.ts index 078cfc62a..628246b7a 100644 --- a/packages/custom-widgets/src/test/options.spec.ts +++ b/packages/custom-widgets/src/test/options.spec.ts @@ -71,17 +71,16 @@ describe("lean Custom Widget options", () => { }); it("replaces stale values with current defaults", () => { - const options = customWidgetDefinitionSchema.parse({ - $schema: "homarr-custom-widget-v2", - name: "Updated options", - sources: {}, - requests: {}, - options: { - limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 }, - label: { label: "Label", control: "text", default: "Default" }, - }, - template: "{options.limit}", - }).options; + const options = { + limit: customWidgetOptionSchema.parse({ + label: "Limit", + control: "slider", + default: 5, + min: 1, + max: 10, + }), + label: customWidgetOptionSchema.parse({ label: "Label", control: "text", default: "Default" }), + }; const normalized = normalizeCustomWidgetOptions(options, { limit: "5", From b9c89c61a26853c823b3435f07c458bac0c15297 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 13:39:16 +0200 Subject: [PATCH 02/24] test: harden integration icon checks --- packages/definitions/src/test/integration.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/definitions/src/test/integration.spec.ts b/packages/definitions/src/test/integration.spec.ts index bdb5194fd..3372a450d 100644 --- a/packages/definitions/src/test/integration.spec.ts +++ b/packages/definitions/src/test/integration.spec.ts @@ -6,7 +6,7 @@ import { integrationDefs } from "../integration"; describe("Icon url's of integrations should be valid and return 200", () => { objectEntries(integrationDefs).forEach(([integration, { iconUrl }]) => { - it.concurrent(`should return 200 for ${integration}`, async () => { + it(`should return 200 for ${integration}`, { concurrent: true, retry: 2, timeout: 15_000 }, async () => { const res = await fetch(iconUrl); expect(res.status).toBe(200); }); From fc1dbd37f163d9c738a2592de13c453066ee96ea Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:36:49 +0200 Subject: [PATCH 03/24] fix: accept legacy dynamic sections --- packages/validation/src/board.spec.ts | 12 ++++++++++-- packages/validation/src/shared.ts | 6 +++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/validation/src/board.spec.ts b/packages/validation/src/board.spec.ts index c8220f445..8a6e8b37c 100644 --- a/packages/validation/src/board.spec.ts +++ b/packages/validation/src/board.spec.ts @@ -44,8 +44,16 @@ describe("section behavior validation", () => { }); }); - it.each(["category", "dynamic"])("rejects the legacy %s kind", (kind) => { - expect(() => sectionSchema.parse({ id: "legacy", kind, layouts: [] })).toThrow(); + it("normalizes the legacy dynamic kind to a container", () => { + expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({ + id: "legacy", + kind: "container", + layouts: [], + }); + }); + + it("rejects the legacy category kind", () => { + expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow(); }); it.each([ diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts index cd7ec8ac3..6a6a3e92d 100644 --- a/packages/validation/src/shared.ts +++ b/packages/validation/src/shared.ts @@ -93,4 +93,8 @@ const containerSectionSchema = z.object({ ), }); -export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema]); +const legacyDynamicSectionSchema = containerSectionSchema + .extend({ kind: z.literal("dynamic") }) + .transform((section) => ({ ...section, kind: "container" as const })); + +export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema, legacyDynamicSectionSchema]); From 7a97b7187bd623cb9e36e1602ef6ee797113c949 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:43:10 +0200 Subject: [PATCH 04/24] Fix board grid bounds and live growth --- apps/docs/docs/management/boards/index.mdx | 3 +- .../src/components/board/layout/README.md | 4 +- .../src/components/board/layout/constants.ts | 1 - .../src/components/board/layout/geometry.ts | 7 ++ .../layout/scaled-board-canvas.module.css | 4 +- .../board/layout/scaled-board-canvas.tsx | 12 ++- .../board/layout/test/geometry.spec.ts | 16 +++- .../layout/test/scaled-board-canvas.spec.ts | 16 ++-- .../board/sections/empty-section.tsx | 2 +- .../board/sections/grid/grid-editor.css | 4 + .../board/sections/grid/grid-editor.tsx | 22 +++++- .../board/sections/grid/section-grid.tsx | 29 ++++++- e2e/board-grid.spec.ts | 79 ++++++++++++------- 13 files changed, 142 insertions(+), 57 deletions(-) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 3fb9467ef..9c8735c5b 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -38,7 +38,8 @@ Underscores and hyphen-minus are allowed. The column count defines how many fixed-width cells are available in each row. One cell contains a `200 × 200` logical-pixel tile. -Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small. +Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling. +The main canvas stays at least one viewport tall and grows automatically while you drag or resize items near its lower edge. ### Public diff --git a/apps/nextjs/src/components/board/layout/README.md b/apps/nextjs/src/components/board/layout/README.md index 8a12bc180..be60ab237 100644 --- a/apps/nextjs/src/components/board/layout/README.md +++ b/apps/nextjs/src/components/board/layout/README.md @@ -10,7 +10,9 @@ The board uses fixed logical units: Items keep the same logical dimensions at every viewport. The viewport reserves the painted size while the inner canvas remains at its logical dimensions, so a widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay -stable. +stable. The visual canvas always fits the available width, the main root keeps +at least one viewport of height, and edit-mode previews extend it downward as +needed. The read-only renderer should: diff --git a/apps/nextjs/src/components/board/layout/constants.ts b/apps/nextjs/src/components/board/layout/constants.ts index 9c3103b4b..26e3f0765 100644 --- a/apps/nextjs/src/components/board/layout/constants.ts +++ b/apps/nextjs/src/components/board/layout/constants.ts @@ -5,6 +5,5 @@ export const LOGICAL_GRID_CELL_SIZE = 200; export const LOGICAL_GRID_GAP = 24; export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; -export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75; export const COLLAPSED_SECTION_ROW_COUNT = 0.5; diff --git a/apps/nextjs/src/components/board/layout/geometry.ts b/apps/nextjs/src/components/board/layout/geometry.ts index 32b7fced4..449e74d4d 100644 --- a/apps/nextjs/src/components/board/layout/geometry.ts +++ b/apps/nextjs/src/components/board/layout/geometry.ts @@ -21,6 +21,13 @@ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSPropert export const getLayoutRowCount = (placements: readonly GridCoordinates[]) => placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0); +export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => { + if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1; + if (!Number.isFinite(visualScale) || visualScale <= 0) return 1; + + return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH)); +}; + export const normalizeGridPlacement = ( placement: TPlacement, columnCount: number, diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css index 6c98e40a2..447cb638d 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css @@ -2,9 +2,7 @@ width: 100%; max-width: 100%; min-width: 0; - overflow: visible; - overflow-x: auto; - overscroll-behavior-inline: contain; + overflow: clip; } .viewport[data-board-hydrated="false"] { diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx index 3d5de2b72..5844a9bd8 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx @@ -5,11 +5,8 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { Box } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; -import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants"; import classes from "./scaled-board-canvas.module.css"; -export { MIN_ACCESSIBLE_CANVAS_SCALE }; - const BoardCanvasScaleContext = createContext(1); export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext); @@ -18,7 +15,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1; if (availableWidth <= 0 || logicalWidth <= 0) return 1; - return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth); + return availableWidth / logicalWidth; }; export const calculateBoardUiScale = (canvasScale: number) => { @@ -36,9 +33,8 @@ interface ScaledBoardCanvasProps { /** * Scales the complete board as one surface while keeping widget layout in - * fixed logical pixels. The scale floor deliberately allows horizontal - * overflow so browser zoom still magnifies content instead of being cancelled - * by responsive downscaling. + * fixed logical pixels. The complete canvas always fits its viewport so board + * content can never widen the document or require horizontal scrolling. */ export const ScaledBoardCanvas = ({ logicalWidth, @@ -62,6 +58,7 @@ export const ScaledBoardCanvas = ({ [logicalWidth, resolvedAvailableWidth], ); const uiScale = calculateBoardUiScale(scale); + const inverseScale = scale > 0 ? 1 / scale : 1; const visualWidth = logicalWidth * scale; const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5; @@ -84,6 +81,7 @@ export const ScaledBoardCanvas = ({ className={classes.canvas} w={logicalWidth} style={{ + "--board-canvas-inverse-scale": inverseScale, "--board-canvas-ui-scale": uiScale, transform: `scale(${scale})`, }} diff --git a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts index 66f084703..3d46398bb 100644 --- a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; -import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index"; +import { + getGridRowCountForVisualHeight, + getLayoutRowCount, + getLogicalItemStyle, + getLogicalTrackSize, + normalizeGridPlacement, +} from "../index"; describe("fixed dashboard geometry", () => { test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => { @@ -24,4 +30,12 @@ describe("fixed dashboard geometry", () => { expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 }); expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7); }); + + test("derives enough logical rows to fill the visual viewport at any canvas scale", () => { + expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6); + expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200); + + expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11); + expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200); + }); }); diff --git a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts index 83e14ea02..50fae3420 100644 --- a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "vitest"; -import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas"; +import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas"; describe("board canvas scale policy", () => { - test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => { + test("fits the logical canvas exactly", () => { const scale = calculateBoardCanvasScale(840, 1000); expect(scale).toBe(0.84); @@ -14,16 +14,16 @@ describe("board canvas scale policy", () => { expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8); }); - test("stops counter-scaling at 0.75 and lets the canvas overflow", () => { + test("keeps fitting narrow viewports without horizontal overflow", () => { const availableWidth = 600; const logicalWidth = 1000; const scale = calculateBoardCanvasScale(availableWidth, logicalWidth); - expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE); - expect(logicalWidth * scale).toBeGreaterThan(availableWidth); + expect(scale).toBe(0.6); + expect(logicalWidth * scale).toBe(availableWidth); }); - test("keeps content materially magnified at 200% effective zoom", () => { + test("keeps the board fitted at 200% effective zoom", () => { const logicalWidth = 1696; const normalAvailableWidth = 1872; const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth); @@ -31,8 +31,8 @@ describe("board canvas scale policy", () => { const normalPaintedCellSize = 200 * normalScale; const zoomedPaintedCellSize = 200 * zoomedScale * 2; - expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE); - expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25); + expect(zoomedScale).toBeCloseTo(normalScale / 2); + expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize); }); test.each([ diff --git a/apps/nextjs/src/components/board/sections/empty-section.tsx b/apps/nextjs/src/components/board/sections/empty-section.tsx index 5e4ef1d65..ab958e75f 100644 --- a/apps/nextjs/src/components/board/sections/empty-section.tsx +++ b/apps/nextjs/src/components/board/sections/empty-section.tsx @@ -18,7 +18,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai const [isEditMode] = useEditMode(); const t = useScopedI18n("board.landmark"); - if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null; + if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null; return ( .board-grid-content-mount { + overflow: clip; +} + .board-grid-placeholder { z-index: 0; background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent); diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index 192cbf7f9..d4d0782ab 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -10,6 +10,7 @@ import { CollisionPriority } from "@dnd-kit/abstract"; import { pointerIntersection } from "@dnd-kit/collision"; import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom"; import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom"; +import { RestrictToElement } from "@dnd-kit/dom/modifiers"; import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities"; import type { BeforeDragStartEvent, @@ -720,6 +721,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement if (!previewGrid || !interaction) return placements; if (interaction.sourceGridId === sectionId) { + if (interaction.mode === "resize") return previewGrid.placements; const original = controlledById.get(interaction.activeId); if (!original) return previewGrid.placements; return previewGrid.placements.some((placement) => placement.id === interaction.activeId) @@ -734,9 +736,16 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement ); const targetPlacement = interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null; + const growsDuringDrag = + interaction?.mode === "drag" && + (interaction.targetGridId === sectionId || + (interaction.targetGridId === null && interaction.sourceGridId === sectionId)); + const participatesInInteraction = + interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId); + const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements); const renderedRowCount = - section.kind !== "container" && interaction?.mode === "resize" - ? Math.max(rowCount, getLayoutRowCount(renderPlacements)) + section.kind !== "container" && participatesInInteraction + ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0) : rowCount; const { ref: droppableRef, isDropTarget } = useDroppable({ @@ -876,6 +885,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: ], [], ); + const modifiers = useMemo( + () => [ + RestrictToElement.configure({ + element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null, + }), + ], + [], + ); const { ref: draggableRef, handleRef, @@ -886,6 +903,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: type: GRID_ENTRY_TYPE, data: sourceData, sensors, + modifiers, }); const setShellRef = useCallback( (element: HTMLDivElement | null) => { diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx index 3ab7b292b..6b98efc0c 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { Box, LoadingOverlay } from "@mantine/core"; import combineClasses from "clsx"; @@ -13,12 +13,14 @@ import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types import { getCollapsedDisplayLayout, getEditableCanvasAttributes, + getGridRowCountForVisualHeight, getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, getReadonlyCanvasAttributes, normalizeGridPlacement, } from "~/components/board/layout"; +import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import { loadGridEditorAsync } from "./grid-editor-loader"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import type { SectionGridPlacement } from "./use-grid-layout-actions"; @@ -81,6 +83,7 @@ export const SectionGrid = ({ className, }: SectionGridProps) => { const [isEditMode] = useEditMode(); + const canvasScale = useBoardCanvasScale(); const editorRuntimeStatus = useGridEditorRuntimeStatus(); const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); @@ -144,8 +147,12 @@ export const SectionGrid = ({ ); const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id))); const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id))); + const minimumViewportRowCount = useMinimumViewportRowCount( + section.kind === "empty" && railPlacement === "main", + canvasScale, + ); const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements)); - const rowCount = Math.max(contentRowCount, requestedRowCount); + const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount); const logicalWidth = getLogicalTrackSize(columnCount); const logicalHeight = getLogicalTrackSize(rowCount); const canvasAttributes = isEditMode @@ -199,6 +206,24 @@ export const SectionGrid = ({ ); }; +const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => { + const [visualHeight, setVisualHeight] = useState(0); + + useEffect(() => { + if (!enabled) return; + const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight); + update(); + window.addEventListener("resize", update, { passive: true }); + window.visualViewport?.addEventListener("resize", update, { passive: true }); + return () => { + window.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("resize", update); + }; + }, [enabled]); + + return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0; +}; + const getContainerMinimumSize = ( board: ReturnType, layoutId: string, diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 47d734263..512fddf08 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -9,7 +9,6 @@ import { describe, test } from "vitest"; import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, - MIN_ACCESSIBLE_CANVAS_SCALE, } from "../apps/nextjs/src/components/board/layout/constants"; import * as sqliteSchema from "../packages/db/schema/sqlite"; import { createHomarrContainer } from "./shared/create-homarr-container"; @@ -26,7 +25,6 @@ const gridRuntimeMarker = "homarr-dnd-kit-v1"; const dndKitPayloadMarker = "dnd-kit-description"; const logicalCellSize = LOGICAL_GRID_CELL_SIZE; const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; -const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE; describe("Board grid", () => { test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => { @@ -174,21 +172,17 @@ describe("Board grid", () => { expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01); // CSS zoom exercises Chromium's browser-zoom layout path without relying - // on desktop browser chrome. At 200% the scale floor must preserve - // magnification and expose horizontal scrolling instead of shrinking the - // board by the inverse zoom factor. + // on desktop browser chrome. The board must keep fitting without adding + // horizontal scrolling at any effective zoom. await setDocumentZoomAsync(page, 2); - await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale)); - await expect(canvas).toHaveAttribute("data-canvas-overflow", "true"); + const zoomedCanvasScale = await readCanvasScaleAsync(canvas); + expect(zoomedCanvasScale).toBeCloseTo(normalCanvasScale / 2, 2); + await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); await expectFixedLogicalTileAsync(logicalTile); await expectUniformVisualScaleAsync(logicalTile); - const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => { - return Number.parseFloat(getComputedStyle(element).fontSize); - }); - expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25); const zoomedTileBox = await expectBoundingBoxAsync(logicalTile); - expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25); - await expectHorizontalOverflowAsync(canvas); + expect(zoomedTileBox.width).toBeCloseTo(normalZoomTileBox.width, 1); + await expectNoHorizontalOverflowAsync(canvas); await expectDocumentNotHorizontallyScrollableAsync(page); const overflowRailY = (await expectBoundingBoxAsync(rail)).y; await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" })); @@ -337,8 +331,11 @@ describe("Board grid", () => { await expect(itemSelectDialog).toHaveCount(0); const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`); + const editorViewport = page.viewportSize(); + if (!editorViewport) throw new Error("Expected a browser viewport"); + expect((await expectBoundingBoxAsync(mainGrid)).height).toBeGreaterThanOrEqual(editorViewport.height - 1); const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid); - await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem); + await expectGridGrowsAtBoundaryAsync(page, canvas, mainGrid, firstItem); await firstItem.locator("[data-grid-item-content]").evaluate((element) => { const iframe = document.createElement("iframe"); iframe.dataset.e2eDragIframe = "true"; @@ -367,8 +364,8 @@ describe("Board grid", () => { .evaluateAll((elements) => elements.forEach((element) => element.remove())); await page.setViewportSize({ width: 600, height: 1200 }); - await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale)); - await expectHorizontalOverflowAsync(canvas); + await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); + await expectNoHorizontalOverflowAsync(canvas); await expectDocumentNotHorizontallyScrollableAsync(page); for (const direction of ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const) { await expectTargetSizeAsync(firstItem.locator(`:scope > [data-grid-resize-handle="${direction}"]`)); @@ -718,13 +715,12 @@ describe("Board grid", () => { expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale); await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 }); - await expect(containerSection).toHaveAttribute("data-grid-w", "2"); + await expect(containerSection).toHaveAttribute("data-grid-w", "1"); await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1"); expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1); - const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor); const liveContainerBox = await expectBoundingBoxAsync(containerSection); - expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1); - expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1); + expect(liveContainerBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1); + await expect(containerSection.locator(":scope > .board-grid-content-mount")).toHaveCSS("overflow", "clip"); await page.mouse.up(); await expect(containerSection).toHaveAttribute("data-grid-w", "1"); await expect(resizeOutline).toHaveCount(0); @@ -759,15 +755,26 @@ describe("Board grid", () => { await expect(containerSection).toHaveAttribute("data-grid-h", "2"); const southResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="s"]'); - await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale); + const gridHeightBeforeSouthResize = (await expectBoundingBoxAsync(mainGrid)).height; + const southResizeBox = await expectBoundingBoxAsync(southResizeHandle); + const southResizeStart = { + x: southResizeBox.x + southResizeBox.width / 2, + y: southResizeBox.y + southResizeBox.height / 2, + }; + await page.mouse.move(southResizeStart.x, southResizeStart.y); + await page.mouse.down(); + await page.mouse.move(southResizeStart.x, southResizeStart.y + logicalCellPitch * canvasScale, { steps: 8 }); await expect(containerSection).toHaveAttribute("data-grid-h", "3"); await expect(belowItem).toHaveAttribute("data-grid-y", "3"); + await expect + .poll(async () => (await expectBoundingBoxAsync(mainGrid)).height) + .toBeGreaterThan(gridHeightBeforeSouthResize); + await page.mouse.up(); expect( gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)), ).toBe(false); - // Auto-scroll needs real document overflow. Grow the canvas through an - // explicit resize instead of relying on edit mode to add empty rows. + // Grow the canvas further to exercise sustained document auto-scroll. await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6); await expect(containerSection).toHaveAttribute("data-grid-h", "9"); await expect(belowItem).toHaveAttribute("data-grid-y", "9"); @@ -1713,9 +1720,10 @@ const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, gr await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); }; -const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, locator: Locator) => { +const expectGridGrowsAtBoundaryAsync = async (page: Page, canvas: Locator, grid: Locator, locator: Locator) => { const snapshot = await readEditorGridSnapshotAsync(grid); const gridBox = await expectBoundingBoxAsync(grid); + const canvasBox = await expectBoundingBoxAsync(canvas); const itemBox = await expectBoundingBoxAsync(locator); const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 }; @@ -1723,11 +1731,22 @@ const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, await page.mouse.down(); await page.mouse.move(start.x + 8, start.y); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); - await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch, { steps: 12 }); - await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0); - const activeGridBox = await expectBoundingBoxAsync(grid); - expect(activeGridBox.height).toBeCloseTo(gridBox.height, 1); + await expect + .poll(async () => (await expectBoundingBoxAsync(grid)).height) + .toBeGreaterThan(gridBox.height); + await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch / 2, { + steps: 12, + }); + await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(1); + const activeGridBox = await expectBoundingBoxAsync(grid); + expect(activeGridBox.height).toBeGreaterThan(gridBox.height); + + await page.mouse.move(canvasBox.x + canvasBox.width + 1000, start.y, { steps: 12 }); + const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]")); + expect(overlayBox.x + overlayBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 1); + + await page.keyboard.press("Escape"); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); @@ -2036,7 +2055,7 @@ const expectUniformVisualScaleAsync = async (locator: Locator) => { expect(scale.horizontal).toBeCloseTo(scale.vertical, 3); }; -const expectHorizontalOverflowAsync = async (locator: Locator) => { +const expectNoHorizontalOverflowAsync = async (locator: Locator) => { const dimensions = await locator.evaluate((element) => { const htmlElement = element as HTMLElement; return { @@ -2045,7 +2064,7 @@ const expectHorizontalOverflowAsync = async (locator: Locator) => { }; }); - expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth); + expect(dimensions.scrollWidth - dimensions.clientWidth).toBeLessThanOrEqual(1); }; const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => { From 0a8e4fead0ddf5cb9f15f31f5455ca7536cc44ca Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:41:08 +0200 Subject: [PATCH 05/24] fix: stabilize board rails --- apps/docs/docs/management/boards/index.mdx | 4 +- .../boards/(content)/_client.module.css | 40 +++++++++-- .../app/[locale]/boards/(content)/_client.tsx | 66 ++----------------- e2e/board-grid.spec.ts | 8 +++ 4 files changed, 51 insertions(+), 67 deletions(-) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 3fb9467ef..0ea8d256e 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -133,8 +133,8 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f Enabling or widening a rail reflows canvas items into the remaining columns. Disabling a rail moves its contents back into the next available canvas positions. -Rails remain visible while the page scrolls. -Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode. +Rails fill the available window height and remain fixed while the page scrolls. +Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary. #### Breakpoint diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 158ad16ae..d0f0252e6 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -9,14 +9,46 @@ } .gutter { - position: relative; - top: 0; + --board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1)); + --board-gutter-viewport-height: calc( + (100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1) + ); + + position: sticky; + top: var(--board-gutter-sticky-top); align-self: start; + box-sizing: border-box; + height: var(--board-gutter-viewport-height); padding-top: 24px; margin-top: -24px; - max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md)); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; - transform: translateY(var(--board-gutter-sticky-offset, 0px)); +} + +.gutter[data-board-editing="true"] { + outline: 1px dashed var(--mantine-color-default-border); + outline-offset: -1px; + border-radius: var(--mantine-radius-md); +} + +.gutter[data-board-editing="true"]::before { + position: sticky; + top: 0; + z-index: 2; + display: block; + width: fit-content; + height: 24px; + padding-inline: var(--mantine-spacing-xs); + margin-top: -24px; + overflow: hidden; + font-size: var(--mantine-font-size-xs); + line-height: 24px; + color: var(--mantine-color-dimmed); + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + content: attr(aria-label); + background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); + border-radius: var(--mantine-radius-sm); } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 689cb95ee..19bf1d0fa 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; import { Box } from "@mantine/core"; import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; import { useScopedI18n } from "@homarr/translation/client"; import { @@ -23,66 +23,12 @@ import classes from "./_client.module.css"; const APP_SHELL_INLINE_PADDING = 32; -const useFixedBoardGutters = () => { - const [columns, setColumns] = useState(null); - - useEffect(() => { - if (!columns) return; - - let frame = 0; - const update = () => { - frame = 0; - const columnsRect = columns.getBoundingClientRect(); - const headerBottom = document.querySelector("[data-app-shell-header]")?.getBoundingClientRect().bottom ?? 0; - const stickyTop = Math.max(0, headerBottom + 16); - - for (const gutter of columns.querySelectorAll("[data-board-gutter]")) { - const rect = gutter.getBoundingClientRect(); - if (gutter.offsetWidth <= 0) continue; - - const effectiveScale = rect.width / gutter.offsetWidth; - if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue; - - const previousOffset = Number(gutter.dataset.stickyOffset ?? 0); - const uncorrectedTop = rect.top - previousOffset * effectiveScale; - const maximumTop = columnsRect.bottom - rect.height; - const targetTop = Math.min(stickyTop, maximumTop); - const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale; - - gutter.dataset.stickyOffset = String(nextOffset); - gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`); - } - }; - const scheduleUpdate = () => { - if (frame !== 0) return; - frame = window.requestAnimationFrame(update); - }; - - const resizeObserver = new ResizeObserver(scheduleUpdate); - resizeObserver.observe(columns); - document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true }); - window.addEventListener("scroll", scheduleUpdate, { passive: true }); - window.addEventListener("resize", scheduleUpdate, { passive: true }); - scheduleUpdate(); - - return () => { - window.cancelAnimationFrame(frame); - resizeObserver.disconnect(); - document.removeEventListener("scroll", scheduleUpdate, { capture: true }); - window.removeEventListener("scroll", scheduleUpdate); - window.removeEventListener("resize", scheduleUpdate); - }; - }, [columns]); - - return setColumns; -}; - export const ClientBoard = () => { const board = useRequiredBoard(); const t = useScopedI18n("board.landmark"); + const [isEditMode] = useEditMode(); const currentLayoutId = useCurrentLayout(); const initialViewportWidth = useInitialViewportWidth(); - const columnsRef = useFixedBoardGutters(); const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0); if (!currentLayout) throw new Error("Expected the board to contain a layout"); @@ -122,16 +68,13 @@ export const ClientBoard = () => { > -
+
{leftColumnCount > 0 && leftSection && (