Compare commits

..
Author SHA1 Message Date
ajnart cc015463f3 fix(board): disable displaced item movement animations 2026-09-09 14:37:33 +02:00
ajnart 4df618e418 fix(board): retain swap previews until layout commit renders 2026-09-09 11:59:10 +02:00
ajnart dba4c27ea5 fix(board): make container editor centering order independent 2026-09-09 10:38:26 +02:00
ajnart 6f2bcb8054 fix(board): restore container grid geometry 2026-09-09 09:21:13 +02:00
Thomas 9d857377c1 fix: refine release v2 interactions (#6805) 2026-09-08 11:36:19 +02:00
Thomas 923905c529 fix: keep bookmark density consistent (#6804)
* fix: keep bookmark density consistent

* fix: prioritize bookmark titles in compact rows

* fix: preserve compact bookmark text fallback
2026-09-08 10:39:41 +02:00
18 changed files with 77 additions and 69 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

@@ -65,7 +65,7 @@ We have called this the **"ultimate Homarr update"**. Not because work stops, bu
We threw out the old drag-and-drop system and rebuilt it on [`dnd-kit`](https://dndkit.com/). Moves and eight-direction resizes are transactional: Homarr previews the result, checks collisions and only saves a valid layout. Bad moves roll back instead of wrecking the board. In short: less fighting with your own dashboard.
![A Calendar tile repositioned between widgets on the rebuilt Homarr 2.0 board grid](./img/drag-and-drop-still.webp)
![A Calendar tile moving across the rebuilt Homarr 2.0 board grid](./img/drag-and-drop.gif)
### Goodbye Groups and sections, bonjour Containers
@@ -36,6 +36,11 @@ current Base layout.
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
border color, and CSS classes.
Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item
will remain when released.
Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their
previewed positions without a movement animation and stay there when you release the pointer.
A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page
scrolls and store their contents separately for each layout.
@@ -7,6 +7,7 @@ import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { useUpdateBoard } from "@homarr/boards/updater";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
@@ -107,7 +108,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const initialValuesRef = useRef(buildInitialValues(board));
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
useEffect(() => {
return () => {
@@ -8,6 +8,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -80,7 +81,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
initialValues,
});
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -27,6 +27,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -125,7 +126,7 @@ export const UserGeneralSettingsForm = ({
initialValues,
});
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
const weekDays = useMemo(() => dayjs.weekdays(false), []);
@@ -1,9 +1,11 @@
.board-grid-editor {
position: absolute;
inset: 0 auto auto 0;
inset: 0 0 auto;
margin: 0 auto;
overflow: visible;
border-radius: var(--mantine-radius-md);
pointer-events: none;
zoom: var(--board-grid-content-scale, 1);
}
.board-grid-entry {
@@ -20,14 +22,7 @@
width: var(--board-grid-preview-width) !important;
height: var(--board-grid-preview-height) !important;
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
will-change: translate;
}
.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) {
transition:
translate 100ms ease,
width 100ms ease,
height 100ms ease;
transition: none;
}
.board-grid-entry[data-dnd-active="true"],
@@ -109,8 +104,8 @@
zoom: var(--board-grid-overlay-content-zoom, 1);
}
body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"],
body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"],
/* Displaced entries must snap with the preview too; animating their offsets can trail a new swap target. */
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-placeholder {
transition: none;
}
@@ -415,7 +415,11 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
sectionId: grid.id,
placements: grid.placements,
}));
commitSectionGrids(snapshots);
const hasChanges = commitSectionGrids(snapshots);
if (!hasChanges) {
clearInteraction();
return;
}
const placement = state.grids
.flatMap((grid) => grid.placements)
@@ -429,7 +433,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
)} ${placement.h}`,
);
}
clearInteraction();
// Cache subscribers receive the committed board asynchronously. Keep the preview until
// the board-change layout effect clears it, so displaced items never flash at their old positions.
activeRef.current = null;
},
[announce, clearInteraction, commitSectionGrids, currentLayoutId, t],
);
@@ -46,7 +46,6 @@
.editorGrid {
position: absolute;
inset: 0 auto auto 0;
}
.editorPortalHost {
@@ -63,6 +62,11 @@
overflow: visible;
}
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
.staticItem[data-type="section"] > .contentMount {
inset: 0;
}
.staticItem[data-type="item"] > .contentMount {
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
}
@@ -143,40 +143,15 @@ export const SectionGrid = ({
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
// A container's own visible card is inset from its allocated board cell by the board's
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
// any widget, so it gets that same gap). A widget's content just fills whatever size its
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
// the same column/row counts used to allocate the *outer*, uninset cell - so without
// subtracting that gap back out here, a container's inner grid renders larger than its own
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
// A collapsible container's toggle bar (see the `containerToggle` Button in
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
// not like the grid's own un-compensated logical-pixel math, so it needs the same
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
// all, the header bar covers the first row of the container's content instead of sitting
// above it. Only the vertical axis is affected - the toggle spans the full width already.
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset);
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size.
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
// corrected to compensate so icon/text sizing inside the container isn't affected.
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
@@ -184,16 +159,9 @@ export const SectionGrid = ({
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
// be left with a small amount of unfilled slack when width/height need different ratios to
// exactly fit - centered below so it's even on both sides rather than left-aligned.
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
// zero or negative - combinedUiScale divides by this value, and this value is also used as
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
// nested content uniformly scaled within the remaining height so labels and icons retain their
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
@@ -302,6 +270,7 @@ export const SectionGrid = ({
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
"--board-grid-content-scale": containerContentScale,
} as CSSProperties
}
data-section-id={section.id}
@@ -33,8 +33,8 @@ export const useGridLayoutActions = () => {
placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const),
),
);
let hasChanges = false;
updateBoard((previous) => {
let hasChanges = false;
const items = previous.items.map((item) => {
const layouts = item.layouts.map((layout) => {
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
@@ -97,6 +97,7 @@ export const useGridLayoutActions = () => {
return hasChanges ? { ...previous, items, sections } : previous;
});
return hasChanges;
},
[updateBoard],
);
@@ -21,6 +21,9 @@
--border-color: rgb(
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
);
/* Keep the border visual without letting it shrink the square-cell grid's content box. */
border-width: 0;
box-shadow: inset 0 0 0 calc(1px * var(--board-canvas-inverse-scale, 1)) var(--border-color);
}
.itemCard:global(.iframe-wrapper) {
@@ -1,9 +1,14 @@
interface UnsavedChangesGuardOptions {
isDirty(): boolean;
confirmNavigation(href: string): void;
guardBeforeUnload?: boolean;
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
export function registerUnsavedChangesGuard({
isDirty,
confirmNavigation,
guardBeforeUnload = true,
}: UnsavedChangesGuardOptions) {
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
@@ -17,6 +22,7 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
const current = new URL(window.location.href);
if (destination.origin !== current.origin) return;
if (destination.pathname === current.pathname && destination.search === current.search) {
currentHref = `${destination.pathname}${destination.search}${destination.hash}`;
return;
}
@@ -48,11 +54,11 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
if (guardBeforeUnload) window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
if (guardBeforeUnload) window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}
@@ -7,7 +7,11 @@ import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) {
interface UnsavedChangesGuardOptions {
guardBeforeUnload?: boolean;
}
export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChangesGuardOptions = {}) {
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
const tCommon = useI18n("common");
const router = useRouter();
@@ -39,7 +43,8 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
registerUnsavedChangesGuard({
isDirty: () => dirtyRef.current,
confirmNavigation: (href) => confirmNavigationRef.current(href),
guardBeforeUnload: options.guardBeforeUnload,
}),
[],
[options.guardBeforeUnload],
);
}
+2 -2
View File
@@ -89,7 +89,7 @@ describe("Onboarding", () => {
try {
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
const start = page.getByRole("button", { name: "Get started" });
await start.focus();
await start.press("Enter");
@@ -356,7 +356,7 @@ describe("Onboarding", () => {
);
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
await page.getByRole("button", { name: "Get started" }).click();
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
expect(await page.getByRole("heading", { name: "Create your administrator" }).count()).toBe(0);
@@ -12,13 +12,13 @@ import {
Text,
ThemeIcon,
} from "@mantine/core";
import { IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
import { IconBulb, IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { selectGridCols, SelectableCard } from "@homarr/ui";
import { FloatingTip, selectGridCols, SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -106,6 +106,17 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
return (
<Stack gap="md">
<FloatingTip
opened={multiSelectAvailable}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{t("tips.multiSelectApps")}
</FloatingTip>
{/* Top Search Input */}
<Stack gap={6}>
<Input
+2 -2
View File
@@ -27,11 +27,11 @@
"review": "Review"
},
"welcome": {
"title": "Make home feel organized.",
"title": "Welcome home.",
"description": "Welcome to Homarr v{version}. We’ll guide you through the essentials and help you build your first board.",
"start": "Get started",
"restore": "Restore backup",
"restoreTitle": "Restore this Homarr",
"restoreTitle": "Restore from backup",
"restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.",
"errorTitle": "Setup could not start",
"errorDescription": "Your setup choices are still here. Try again, or reload the page if this continues."