feat: add fixed board scaling

This commit is contained in:
ajnart committed 2026-09-08 12:37:51 +02:00
1 parent 9d857377c1
commit ecc1009e23
36 files changed
+8654 -41

No files matched your search

+5
View File
@@ -62,6 +62,11 @@ Each board has exactly one Mobile and one Base layout. Mobile uses breakpoint `0
layouts can use any other breakpoint, and Homarr selects the highest breakpoint that fits the viewport. Preserve layout
roles and use the canonical layouts returned by save operations, including generated IDs.
Board settings expose `fixedScaling` and `fixedItemSize`. The default fitted mode uses `fixedScaling: false`; setting it
to `true` keeps each `1 × 1` tile at `fixedItemSize` pixels, which must be an integer from `100` through `400`. The
configured size is retained while fixed scaling is disabled. Layout `columnCount` remains the maximum total column count,
including fixed sidebars.
Creating or changing an integration-backed widget requires use access to each newly selected integration.
## Docker targets
+8 -4
View File
@@ -27,9 +27,13 @@ Mobile and Base are protected layouts. New boards use three Mobile columns; Base
creation. Additional layouts use unique breakpoints. Homarr selects the layout with the highest breakpoint that fits
the current viewport.
Tiles use fixed `200 × 200` logical-pixel cells with a `24px` gap. Homarr scales the complete layout to the available
width instead of changing each widget's proportions. **Reset from Base** regenerates a Mobile or custom layout from the
current Base layout.
By default, Homarr fits the complete fixed-proportion tile grid to the available width. Enable **Fixed scaling** in the
board's Layout settings to render every `1 × 1` tile at the exact configured pixel size instead. A fixed dashboard is
centred when it fits and scrolls both horizontally and vertically when it does not.
The column count is the maximum total for that layout, including any fixed sidebars. For example, a Base layout with
five columns and no sidebars remains a centred five-column dashboard on an ultrawide screen. **Reset from Base**
regenerates a Mobile or custom layout from the current Base layout.
### Containers and rails
@@ -59,7 +63,7 @@ click remains available as a shortcut.
Board settings cover:
- **General:** page title, browser title, logo, and favicon.
- **Layout:** responsive layouts, breakpoints, columns, and rails.
- **Layout:** fitted or fixed scaling, tile size, responsive layouts, breakpoints, maximum columns, and rails.
- **Background:** image, attachment, size, and repeat behavior.
- **Appearance:** colors, opacity, icon color, and item radius.
- **Custom CSS:** styles applied only to the current board.
+8
View File
@@ -2151,6 +2151,14 @@
}
]
},
"fixedScaling": {
"type": "boolean"
},
"fixedItemSize": {
"type": "integer",
"minimum": 100,
"maximum": 400
},
"disableStatus": {
"type": "boolean"
}
@@ -1,7 +1,6 @@
.columns {
display: grid;
align-items: start;
gap: 24px;
}
.lane {
@@ -27,3 +26,10 @@
outline-offset: -0.0625rem;
border-radius: var(--mantine-radius-md);
}
:global([data-canvas-scaling="fixed"]) .gutter {
--board-gutter-sticky-top: 0px;
--board-gutter-viewport-height: calc(
var(--board-canvas-fixed-viewport-height) * var(--board-canvas-inverse-scale, 1)
);
}
@@ -18,7 +18,6 @@ import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalGridSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
@@ -33,6 +32,7 @@ import { BoardSelectionToolbar } from "~/components/board/selection/board-select
import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
const BOARD_LANE_GAP = 24;
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
<GridEditorRegistryProvider>
@@ -63,11 +63,11 @@ export const ClientBoard = () => {
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * BOARD_LANE_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
const fixedItemSize = board.fixedScaling ? board.fixedItemSize : undefined;
const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
`${getLogicalGridSize(mainColumnCount)}px`,
@@ -90,10 +90,11 @@ export const ClientBoard = () => {
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
fixedItemSize={fixedItemSize}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gridTemplateColumns }}>
<div className={classes.columns} style={{ gap: BOARD_LANE_GAP, gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
@@ -1,7 +1,31 @@
.scalingSettings,
.gutterSettings {
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
}
.fixedItemSizePreview {
display: flex;
align-items: center;
gap: var(--mantine-spacing-md);
margin: 0;
min-width: 12rem;
}
.fixedItemSizePreviewTile {
display: grid;
flex: 0 0 auto;
place-items: center;
min-width: 1.5rem;
min-height: 1.5rem;
color: var(--mantine-primary-color-contrast);
background: var(--mantine-primary-color-filled);
border: 1px solid var(--mantine-primary-color-filled-hover);
border-radius: var(--mantine-radius-md);
transition:
width 180ms ease-out,
height 180ms ease-out;
}
.gutterPreview {
display: grid;
gap: 6px;
@@ -41,6 +65,7 @@
}
@media (prefers-reduced-motion: reduce) {
.fixedItemSizePreviewTile,
.gutterPreview {
transition: none;
}
@@ -22,6 +22,7 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { BOARD_FIXED_ITEM_SIZE_MAX, BOARD_FIXED_ITEM_SIZE_MIN } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -106,10 +107,55 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
const fixedItemPreviewSize =
Math.min(BOARD_FIXED_ITEM_SIZE_MAX, Math.max(BOARD_FIXED_ITEM_SIZE_MIN, form.values.fixedItemSize)) / 4;
return (
<SectionCard title={tBoard("setting.section.layout.title")}>
<Stack gap="lg">
<Paper className={classes.scalingSettings} p="md" withBorder>
<Stack gap="md">
<Text fw={600}>{tBoard("setting.section.layout.scaling.title")}</Text>
<Switch
label={tBoard("field.fixedScaling.label")}
description={tBoard("field.fixedScaling.description")}
{...form.getInputProps("fixedScaling", { type: "checkbox" })}
/>
<Group align="center" justify="space-between" gap="xl" wrap="wrap">
<NumberInput
label={tBoard("field.fixedItemSize.label")}
description={tBoard("field.fixedItemSize.description")}
min={BOARD_FIXED_ITEM_SIZE_MIN}
max={BOARD_FIXED_ITEM_SIZE_MAX}
step={10}
allowDecimal={false}
suffix=" px"
w={{ base: "100%", sm: 240 }}
{...form.getInputProps("fixedItemSize")}
/>
<Box
component="figure"
className={classes.fixedItemSizePreview}
aria-label={tBoard("field.fixedItemSize.previewLabel", { size: form.values.fixedItemSize })}
>
<Box
className={classes.fixedItemSizePreviewTile}
w={fixedItemPreviewSize}
h={fixedItemPreviewSize}
aria-hidden="true"
>
<Text size="xs" fw={600}>
1 × 1
</Text>
</Box>
<Text size="sm" fw={500}>
{tBoard("field.fixedItemSize.previewValue", { size: form.values.fixedItemSize })}
</Text>
</Box>
</Group>
</Stack>
</Paper>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem">
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
@@ -160,7 +206,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper label={tLayout("field.columnCount.label")}>
<Input.Wrapper
label={tLayout("field.columnCount.label")}
description={tLayout("field.columnCount.description")}
>
<Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs"
@@ -8,6 +8,7 @@ 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 { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
@@ -59,6 +60,8 @@ const PARTIAL_FORM_KEYS = [
"itemRadius",
"customCss",
"disableStatus",
"fixedScaling",
"fixedItemSize",
] as const;
const buildInitialValues = (board: Board): FormValues => ({
@@ -77,6 +80,8 @@ const buildInitialValues = (board: Board): FormValues => ({
itemRadius: board.itemRadius,
customCss: board.customCss ?? "",
disableStatus: board.disableStatus,
fixedScaling: board.fixedScaling ?? false,
fixedItemSize: board.fixedItemSize ?? BOARD_FIXED_ITEM_SIZE_DEFAULT,
layouts: normalizeMobileLayoutGutters(board.layouts),
});
@@ -11,6 +11,7 @@ import {
CopyButton,
Group,
InputWrapper,
NumberInput,
Select,
SimpleGrid,
Skeleton,
@@ -29,6 +30,7 @@ import {
IconCheck,
IconCopy,
IconFileTypeCss,
IconLayoutGrid,
IconPalette,
IconPhoto,
IconRefresh,
@@ -38,7 +40,14 @@ import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import {
BOARD_FIXED_ITEM_SIZE_DEFAULT,
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
@@ -85,6 +94,8 @@ const normalizeFormValues = (
iconColor: values.iconColor ?? "",
itemRadius: values.itemRadius,
disableStatus: values.disableStatus,
fixedScaling: values.fixedScaling ?? false,
fixedItemSize: values.fixedItemSize ?? BOARD_FIXED_ITEM_SIZE_DEFAULT,
};
};
@@ -263,6 +274,9 @@ const BoardSettingsForm = ({
<Tabs.Tab value="css" leftSection={<IconFileTypeCss size={15} />}>
{t("tabs.css")}
</Tabs.Tab>
<Tabs.Tab value="layout" leftSection={<IconLayoutGrid size={15} />}>
{t("tabs.layout")}
</Tabs.Tab>
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
{t("tabs.appearance")}
</Tabs.Tab>
@@ -329,6 +343,26 @@ const BoardSettingsForm = ({
</Stack>
</Tabs.Panel>
<Tabs.Panel value="layout" pt="md">
<Stack gap="md">
<Switch
label={boardT("board.field.fixedScaling.label")}
description={boardT("board.field.fixedScaling.description")}
{...form.getInputProps("fixedScaling", { type: "checkbox" })}
/>
<NumberInput
label={boardT("board.field.fixedItemSize.label")}
description={boardT("board.field.fixedItemSize.description")}
min={BOARD_FIXED_ITEM_SIZE_MIN}
max={BOARD_FIXED_ITEM_SIZE_MAX}
step={10}
allowDecimal={false}
suffix=" px"
{...form.getInputProps("fixedItemSize")}
/>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="appearance" pt="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
@@ -16,6 +16,8 @@ export const assistantBoardSettingKeys = [
"iconColor",
"itemRadius",
"disableStatus",
"fixedScaling",
"fixedItemSize",
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
const comparableValue = (value: unknown) => (value === null ? "" : value);
@@ -54,12 +56,14 @@ const backgroundKeys = new Set<keyof AssistantBoardSettingsChanges>([
"backgroundImageRepeat",
"backgroundImageSize",
]);
const layoutKeys = new Set<keyof AssistantBoardSettingsChanges>(["fixedScaling", "fixedItemSize"]);
export const getAssistantBoardSettingsDefaultTab = (
changes: AssistantBoardSettingsChanges,
): "general" | "appearance" | "background" | "css" => {
): "general" | "layout" | "appearance" | "background" | "css" => {
if (changes.customCss !== undefined) return "css";
const keys = Object.keys(changes) as (keyof AssistantBoardSettingsChanges)[];
if (keys.some((key) => layoutKeys.has(key))) return "layout";
if (keys.some((key) => appearanceKeys.has(key))) return "appearance";
if (keys.some((key) => backgroundKeys.has(key))) return "background";
return "general";
@@ -1,6 +1,8 @@
import { z } from "zod/v4";
import {
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
@@ -48,6 +50,17 @@ export const assistantBoardSettingsChangesSchema = z
iconColor: boardColorSchema.nullable().optional(),
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
disableStatus: z.boolean().optional(),
fixedScaling: z
.boolean()
.optional()
.describe("Keep board tiles at a configured size instead of fitting the complete layout to the viewport."),
fixedItemSize: z
.number()
.int()
.min(BOARD_FIXED_ITEM_SIZE_MIN)
.max(BOARD_FIXED_ITEM_SIZE_MAX)
.optional()
.describe("Visible pixel width and height of a 1 by 1 tile when fixedScaling is enabled."),
})
.describe("Only the board fields the user asked to change.");
@@ -1,4 +1,5 @@
import { createId } from "@homarr/common";
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
import { ContainerSectionMockBuilder } from "./container-section-mock";
@@ -27,6 +28,8 @@ export class BoardMockBuilder {
name: "board",
opacity: 100,
isPublic: true,
fixedScaling: false,
fixedItemSize: BOARD_FIXED_ITEM_SIZE_DEFAULT,
disableStatus: false,
customCss: "",
creatorId: createId(),
@@ -5,13 +5,15 @@ The board uses fixed logical units:
- one grid track has a `212 × 212` logical-pixel footprint;
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
- the canvas is zoomed once with one uniform scale derived from its available width.
- the canvas is zoomed once with one uniform scale.
Grid footprints keep the same logical dimensions at every viewport. Widget cards
use a scale-compensated inset so their painted size and content measurements
match the legacy responsive grid. The visual canvas always fits the available
width, every root lane keeps at least one viewport of height, and edit-mode
previews extend it downward as needed.
match the legacy responsive grid. Responsive scaling derives the zoom from the
available width. Fixed scaling derives it from the configured painted `1 x 1`
card size and keeps the canvas centered while it fits; otherwise the board's
named canvas region scrolls on both axes. Every root lane keeps at least one
viewport of height, and edit-mode previews extend it downward as needed.
The read-only renderer should:
@@ -5,5 +5,7 @@
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 12;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
/** Physical inset between a grid footprint and each edge of its painted tile. */
export const BOARD_GRID_ITEM_INSET = 10;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
@@ -10,6 +10,76 @@
overflow: clip;
}
.viewport[data-canvas-scaling="fixed"] {
--board-canvas-fixed-viewport-height: calc(
100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding, 1rem) -
var(--app-shell-footer-offset, 0px) - var(--app-shell-padding, 1rem)
);
height: var(--board-canvas-fixed-viewport-height);
overflow: auto;
overscroll-behavior: contain;
}
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar {
width: 2px;
height: 2px;
}
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-track,
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-corner {
background: transparent;
}
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-thumb {
border-radius: 999px;
background: transparent;
}
.viewport[data-canvas-scaling="fixed"]:hover::-webkit-scrollbar-thumb,
.viewport[data-canvas-scaling="fixed"]:focus-visible::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
}
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--mantine-color-dimmed) 70%, transparent);
}
@supports (-moz-appearance: none) {
.viewport[data-canvas-scaling="fixed"] {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
}
.viewport[data-canvas-scaling="fixed"]:hover,
.viewport[data-canvas-scaling="fixed"]:focus-visible {
scrollbar-color: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent) transparent;
}
}
.viewport[data-canvas-scaling="fixed"]:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.viewport[data-canvas-scaling="fixed"][data-canvas-centered="true"] .sizer {
margin-inline: auto;
}
.viewport[data-canvas-scaling="fixed"] .sizer {
/* Leave room for edit labels and controls that intentionally sit above first-row tiles. */
margin-block-start: calc(2rem * var(--board-canvas-scale, 1));
}
.viewport[data-canvas-scaling="fixed"] .canvas {
/* Fixed size describes every painted tile, including container sections. */
--board-grid-item-inset: calc(var(--board-grid-card-inset, 10px) * var(--board-canvas-inverse-scale, 1));
}
.viewport[data-canvas-scaling="fixed"][data-board-hydrated="false"] {
overflow: clip;
}
.sizer {
position: relative;
}
@@ -1,15 +1,18 @@
"use client";
import type { PropsWithChildren } from "react";
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./constants";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
const BoardCanvasViewportHeightContext = createContext<number | null>(null);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
export const useBoardCanvasViewportHeight = () => useContext(BoardCanvasViewportHeightContext);
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
@@ -18,6 +21,14 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
return availableWidth / logicalWidth;
};
export const calculateFixedBoardCanvasScale = (fixedItemSize: number) => {
if (!Number.isFinite(fixedItemSize) || fixedItemSize <= 0) return 1;
// The configured size is the painted card, after the two fixed physical insets
// have been removed from its scaled logical grid footprint.
return (fixedItemSize + 2 * BOARD_GRID_ITEM_INSET) / LOGICAL_GRID_PITCH;
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
@@ -28,22 +39,24 @@ interface ScaledBoardCanvasProps {
logicalWidth: number;
initialLogicalHeight: number;
initialAvailableWidth: number;
fixedItemSize?: number;
label: string;
}
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
* fixed logical pixels. Responsive scaling fits the canvas to its viewport;
* fixed scaling contains overflow in a keyboard-scrollable canvas region.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
initialLogicalHeight,
initialAvailableWidth,
fixedItemSize,
label,
children,
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useElementSize<HTMLDivElement>();
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
const [isHydrated, setIsHydrated] = useState(false);
@@ -53,14 +66,17 @@ export const ScaledBoardCanvas = ({
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
const scale = useMemo(
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
[logicalWidth, resolvedAvailableWidth],
);
const isFixedScaling = fixedItemSize !== undefined;
const scale = useMemo(() => {
if (fixedItemSize !== undefined) return calculateFixedBoardCanvasScale(fixedItemSize);
return calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth);
}, [fixedItemSize, 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;
const shouldCenterCanvas = isFixedScaling && visualWidth <= resolvedAvailableWidth;
return (
<Box
@@ -69,24 +85,41 @@ export const ScaledBoardCanvas = ({
className={classes.viewport}
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scaling={isFixedScaling ? "fixed" : "responsive"}
data-canvas-scale={scale}
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
data-canvas-centered={shouldCenterCanvas ? "true" : undefined}
data-canvas-initial-height={initialLogicalHeight}
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
tabIndex={isFixedScaling ? 0 : undefined}
>
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
<Box
className={classes.sizer}
style={
{
"--board-canvas-scale": scale,
width: visualWidth,
height: resolvedLogicalHeight * scale,
} as CSSProperties
}
>
<Box
ref={canvasRef}
className={classes.canvas}
style={{
"--board-grid-card-inset": `${BOARD_GRID_ITEM_INSET}px`,
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
<BoardCanvasScaleContext.Provider value={scale}>
<BoardCanvasViewportHeightContext.Provider value={isFixedScaling ? viewportHeight : null}>
{children}
</BoardCanvasViewportHeightContext.Provider>
</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
@@ -1250,7 +1250,7 @@ const configureEditorPlugins = (defaults: Plugins<DragDropManager>) =>
dropAnimation: null,
keyboardTransition: { duration: 100 },
});
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0, y: 0.16 } });
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0.16, y: 0.16 } });
return plugin;
});
@@ -64,7 +64,7 @@
}
.staticItem[data-type="item"] > .contentMount {
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
inset: calc(var(--board-grid-card-inset, 10px) * var(--board-canvas-inverse-scale, 1));
}
.staticItem:has([data-board-container-label]) {
@@ -10,6 +10,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import {
BOARD_GRID_ITEM_INSET,
getCollapsedDisplayLayout,
getEditableCanvasAttributes,
getGridRowCountForVisualHeight,
@@ -18,7 +19,11 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import {
calculateBoardUiScale,
useBoardCanvasScale,
useBoardCanvasViewportHeight,
} from "~/components/board/layout/scaled-board-canvas";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -50,6 +55,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const canvasViewportHeight = useBoardCanvasViewportHeight();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
@@ -130,7 +136,11 @@ export const SectionGrid = ({
() => innerSections.map((item) => withPlacement(item, placementById.get(item.id))),
[innerSections, placementById],
);
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const minimumViewportRowCount = useMinimumViewportRowCount(
section.kind === "empty",
canvasScale,
canvasViewportHeight,
);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
@@ -150,9 +160,9 @@ export const SectionGrid = ({
// 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.
// card and visually spills past its right/bottom edges.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
const outerCardInset = section.kind === "container" ? (2 * BOARD_GRID_ITEM_INSET) / 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
@@ -342,11 +352,11 @@ const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number, canvasViewportHeight: number | null) => {
const [visualHeight, setVisualHeight] = useState(0);
useEffect(() => {
if (!enabled) return;
if (!enabled || canvasViewportHeight !== null) return;
const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
update();
window.addEventListener("resize", update, { passive: true });
@@ -355,9 +365,10 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
window.removeEventListener("resize", update);
window.visualViewport?.removeEventListener("resize", update);
};
}, [enabled]);
}, [canvasViewportHeight, enabled]);
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
const resolvedVisualHeight = canvasViewportHeight ?? visualHeight;
return enabled ? getGridRowCountForVisualHeight(resolvedVisualHeight, canvasScale) : 0;
};
const containerMinimumSizeCache = new WeakMap<
+10 -4
View File
@@ -42,6 +42,8 @@ import {
getWidgetIntegrationIssueMessage,
getPermissionsWithChildren,
getPermissionsWithParents,
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
normalizeBoardLayoutRoles,
rootSectionOffsets,
widgetDefaultSizes,
@@ -1142,8 +1144,7 @@ export const boardRouter = createTRPCRouter({
.meta({
mcp: {
enabled: true,
description:
"Read the editable visual and behavior settings for one board, including its current custom CSS. Requires modify permission. REQUIRED: id (board ID). Call this before proposing board settings or custom CSS changes",
description: `Read the editable visual and behavior settings for one board, including its current custom CSS and board scaling settings. fixedScaling keeps each 1x1 grid item at fixedItemSize pixels instead of fitting the board to the viewport; fixedItemSize is ${BOARD_FIXED_ITEM_SIZE_MIN}-${BOARD_FIXED_ITEM_SIZE_MAX}. Requires modify permission. REQUIRED: id (board ID). Call this before proposing board settings or custom CSS changes`,
},
})
.input(z.object({ id: z.string() }))
@@ -1170,6 +1171,8 @@ export const boardRouter = createTRPCRouter({
customCss: true,
iconColor: true,
itemRadius: true,
fixedScaling: true,
fixedItemSize: true,
disableStatus: true,
},
where: boardWhere,
@@ -1526,8 +1529,7 @@ export const boardRouter = createTRPCRouter({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Update visual and behavior settings for a board. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, and disableStatus",
description: `Update visual and behavior settings for a board. Set fixedScaling to true to keep each 1x1 grid item at fixedItemSize pixels instead of fitting the board to the viewport; fixedItemSize must be ${BOARD_FIXED_ITEM_SIZE_MIN}-${BOARD_FIXED_ITEM_SIZE_MAX}. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, fixedScaling, fixedItemSize, and disableStatus`,
},
})
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
@@ -1557,6 +1559,10 @@ export const boardRouter = createTRPCRouter({
iconColor: input.iconColor,
itemRadius: input.itemRadius,
// scaling settings
fixedScaling: input.fixedScaling,
fixedItemSize: input.fixedItemSize,
// custom css
customCss: input.customCss,
@@ -29,6 +29,7 @@ import {
serverSettings,
} from "@homarr/db/schema";
import {
BOARD_FIXED_ITEM_SIZE_DEFAULT,
emptySuperJSON,
everyoneGroup,
extractContainerImageName,
@@ -388,6 +389,8 @@ export const onboardRouter = createTRPCRouter({
customCss: null,
iconColor: null,
itemRadius: input.board.itemRadius,
fixedScaling: false,
fixedItemSize: BOARD_FIXED_ITEM_SIZE_DEFAULT,
disableStatus: false,
sections: [],
layouts: pendingLayouts,
@@ -0,0 +1,2 @@
ALTER TABLE `board` ADD `fixed_scaling` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `board` ADD `fixed_item_size` smallint DEFAULT 200 NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -351,6 +351,13 @@
"when": 1788763848931,
"tag": "0049_add_byte_unit_system",
"breakpoints": true
},
{
"idx": 50,
"version": "5",
"when": 1788845117219,
"tag": "0050_add_fixed_board_scaling",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "board" ADD COLUMN "fixed_scaling" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "board" ADD COLUMN "fixed_item_size" smallint DEFAULT 200 NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -113,6 +113,13 @@
"when": 1788763848932,
"tag": "0015_add_byte_unit_system",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1788845119083,
"tag": "0016_add_fixed_board_scaling",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE `board` ADD `fixed_scaling` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `board` ADD `fixed_item_size` integer DEFAULT 200 NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -337,6 +337,13 @@
"when": 1788763848930,
"tag": "0047_add_byte_unit_system",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1788845055278,
"tag": "0048_add_fixed_board_scaling",
"breakpoints": true
}
]
}
+3
View File
@@ -23,6 +23,7 @@ import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
BOARD_FIXED_ITEM_SIZE_DEFAULT,
emptySuperJSON,
} from "@homarr/definitions";
import type {
@@ -301,6 +302,8 @@ export const boards = mysqlTable("board", {
customCss: text(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
fixedScaling: boolean().default(false).notNull(),
fixedItemSize: smallint().default(BOARD_FIXED_ITEM_SIZE_DEFAULT).notNull(),
disableStatus: boolean().default(false).notNull(),
});
+3
View File
@@ -21,6 +21,7 @@ import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
BOARD_FIXED_ITEM_SIZE_DEFAULT,
emptySuperJSON,
} from "@homarr/definitions";
import type {
@@ -299,6 +300,8 @@ export const boards = pgTable("board", {
customCss: text(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
fixedScaling: boolean().default(false).notNull(),
fixedItemSize: smallint().default(BOARD_FIXED_ITEM_SIZE_DEFAULT).notNull(),
disableStatus: boolean().default(false).notNull(),
});
+3
View File
@@ -9,6 +9,7 @@ import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
BOARD_FIXED_ITEM_SIZE_DEFAULT,
emptySuperJSON,
} from "@homarr/definitions";
import type {
@@ -284,6 +285,8 @@ export const boards = sqliteTable("board", {
customCss: text(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
fixedScaling: int({ mode: "boolean" }).default(false).notNull(),
fixedItemSize: int().default(BOARD_FIXED_ITEM_SIZE_DEFAULT).notNull(),
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
});
+4
View File
@@ -10,6 +10,10 @@ export const backgroundImageSizes = createDefinition(["cover", "contain"], {
});
export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" });
export const BOARD_FIXED_ITEM_SIZE_DEFAULT = 200;
export const BOARD_FIXED_ITEM_SIZE_MIN = 100;
export const BOARD_FIXED_ITEM_SIZE_MAX = 400;
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
+16 -1
View File
@@ -5430,6 +5430,16 @@
"label": "Disable app status",
"description": "Disables the status check for all apps on this board"
},
"fixedScaling": {
"label": "Fixed scaling",
"description": "Keep every tile at the selected size. The dashboard is centred when it fits and scrolls when it does not."
},
"fixedItemSize": {
"label": "1 × 1 tile size",
"description": "Set the exact visible width and height used for a 1 × 1 tile when fixed scaling is enabled.",
"previewLabel": "1 by 1 tile preview at {size} by {size} pixels",
"previewValue": "{size} x {size} px"
},
"columnCount": {
"label": "Column count"
},
@@ -5461,6 +5471,9 @@
},
"layout": {
"title": "Layout",
"scaling": {
"title": "Scaling"
},
"responsive": {
"title": "Responsive layouts",
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.",
@@ -5607,7 +5620,8 @@
"layout": {
"field": {
"columnCount": {
"label": "Column count"
"label": "Column count",
"description": "Maximum total columns in this layout, including fixed sidebars."
},
"breakpoint": {
"label": "Breakpoint",
@@ -8032,6 +8046,7 @@
"resetAll": "Reset all changes",
"tabs": {
"css": "Custom CSS",
"layout": "Layout",
"appearance": "Appearance",
"background": "Background",
"general": "General"
+4
View File
@@ -6,6 +6,8 @@ import {
backgroundImageSizes,
boardPermissions,
layoutRoles,
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
widgetKinds,
} from "@homarr/definitions";
@@ -68,6 +70,8 @@ export const boardSavePartialSettingsSchema = z
customCss: z.string().max(16384),
iconColor: hexColorNullableSchema,
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
fixedScaling: z.boolean(),
fixedItemSize: z.number().int().min(BOARD_FIXED_ITEM_SIZE_MIN).max(BOARD_FIXED_ITEM_SIZE_MAX),
disableStatus: z.boolean(),
})
.partial();