feat: refine board settings and compact previews

This commit is contained in:
ajnart committed 2026-09-08 16:17:01 +02:00
1 parent ecc1009e23
commit 6db134cf15
27 files changed
+1203 -550

No files matched your search

+23 -8
View File
@@ -28,7 +28,7 @@ creation. Additional layouts use unique breakpoints. Homarr selects the layout w
the current viewport.
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
board's Layout settings to render each top-level `1 × 1` tile at the configured CSS-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
@@ -60,14 +60,29 @@ click remains available as a shortcut.
## Board settings
Board settings cover:
Settings are organized into tabs, with one **Save changes** action for the whole board. Switching tabs retains your
draft; **Discard** restores the saved settings.
- **General:** page title, browser title, logo, and favicon.
- **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.
- **Behaviour:** app status and widget context-menu behavior.
- **Layout:** tile sizing, responsive layouts, breakpoints, maximum columns, and rails.
- **Appearance:** colors, opacity, icon color, item radius, and background image settings beside a live layout preview.
- **General:** page title, browser title, logo, favicon, and app status.
- **Advanced:** custom CSS applied only to this board.
- **Access control:** permissions, visibility, renaming, and deletion (requires full access).
### Try a layout before saving
Choose **Fixed scaling** or **Fit to screen** under **Tile sizing**. Fixed sizing accepts **100–400 px**; its square
sample is shown at half size with the configured dimensions underneath. The size is retained when you switch modes.
Select a layout to edit its columns, breakpoint, and side rails beside the compact **Board preview**. It uses the
familiar Boards thumbnail, with the board's actual grid proportions and rails aligned beside the main canvas.
The indicated screen width represents the selected layout. The miniature stays contained, centres fixed grids that
fit, and scrolls when needed. Changes to sizing and appearance appear immediately, before saving.
In fixed mode, browser `Cmd`/`Ctrl` + `+` or `-` zoom works naturally: a 200 × 200 CSS-pixel tile keeps those dimensions
while its physical screen size changes with zoom. Homarr does not shrink the tile to cancel the browser's zoom.
Your live board can still select a different saved layout when its viewport crosses a breakpoint. Nested Containers
fit their inner grids into their own frames; the configured tile size describes the top-level board grid.
See [Styling](/docs/advanced/styling) for board and global CSS.
@@ -17,10 +17,11 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalGridSize,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BOARD_LANE_GAP, getBoardCanvasGeometry } from "~/components/board/layout/canvas-geometry";
import { getBoardScaling } from "~/components/board/layout/scaling";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
@@ -32,7 +33,6 @@ 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>
@@ -60,21 +60,10 @@ export const ClientBoard = () => {
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize);
const logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * BOARD_LANE_GAP;
const { width: logicalWidth, gridTemplateColumns } = getBoardCanvasGeometry(currentLayout);
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`,
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
]
.filter((value) => value !== null)
.join(" ");
const content = (
<BoardSelectionGridProvider>
@@ -90,7 +79,7 @@ export const ClientBoard = () => {
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
fixedItemSize={fixedItemSize}
scaling={getBoardScaling(board)}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
@@ -0,0 +1,51 @@
import { Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { LayoutPreview } from "./_layout-preview";
import type { FormValues } from "./_settings-form";
import classes from "./_settings-form.module.css";
export const AppearancePlayground = ({
board,
form,
apps,
selectedLayoutId,
}: {
board: Board;
form: UseFormReturnType<FormValues>;
apps: RouterOutputs["app"]["byIds"];
selectedLayoutId: string | null;
}) => {
const layout =
form.values.layouts.find((candidate) => candidate.id === selectedLayoutId) ??
form.values.layouts.find((candidate) => candidate.role === "base");
const sourceLayout =
board.layouts.find((candidate) => candidate.id === layout?.id) ??
board.layouts.find((candidate) => candidate.role === "base");
return (
<div className={classes.appearance}>
<Stack gap="lg">
<ColorSettingsContent form={form} />
<BackgroundSettingsContent form={form} />
</Stack>
{layout && sourceLayout && (
<div className={classes.appearancePreview}>
<LayoutPreview
board={board}
layout={layout}
layouts={form.values.layouts}
sourceLayout={sourceLayout}
apps={apps}
settings={form.values}
/>
</div>
)}
</div>
);
};
@@ -1,7 +1,6 @@
"use client";
import {
Anchor,
Button,
Collapse,
ColorInput,
@@ -39,7 +38,6 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const { branding } = useSettings();
@@ -50,6 +48,11 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Stack gap="xs">
<BoardColorInput
label={tBoard("field.primaryColor.label")}
eyeDropperButtonProps={{
"aria-label": tBoard("setting.section.appearance.pickColor", {
label: tBoard("field.primaryColor.label"),
}),
}}
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
@@ -57,10 +60,22 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
<BoardColorInput
label={tBoard("field.secondaryColor.label")}
eyeDropperButtonProps={{
"aria-label": tBoard("setting.section.appearance.pickColor", {
label: tBoard("field.secondaryColor.label"),
}),
}}
{...form.getInputProps("secondaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
<Button type="button" variant="subtle" size="compact-sm" onClick={toggle} aria-expanded={showPreview}>
{showPreview
? tBoard("setting.section.appearance.hideShades")
: tBoard("setting.section.appearance.showShades")}
</Button>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
@@ -73,6 +88,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={tBoard("field.opacity.label")}>
<Slider
thumbLabel={tBoard("field.opacity.label")}
my={6}
min={0}
max={100}
@@ -86,6 +102,11 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Group align="end">
<ColorInput
label={tBoard("field.iconColor.label")}
eyeDropperButtonProps={{
"aria-label": tBoard("setting.section.appearance.pickColor", {
label: tBoard("field.iconColor.label"),
}),
}}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
@@ -1,7 +1,7 @@
"use client";
import { startTransition } from "react";
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { ActionIcon, Autocomplete, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
@@ -24,6 +24,7 @@ interface Props {
export const BackgroundSettingsContent = ({ form }: Props) => {
const tBoard = useI18n("board");
const tMedia = useI18n("media");
const { data: session } = useSession();
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
@@ -50,14 +51,15 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<Autocomplete
flex={1}
leftSectionPointerEvents="all"
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<Center h="100%">
<ActionIcon type="button" variant="subtle" aria-label={tMedia("field.preview")}>
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
@@ -113,7 +115,15 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<ActionIcon
type="button"
aria-label={tMedia("action.upload.label")}
onClick={onClick}
loading={loading}
mt={24}
size={36}
variant="default"
>
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
@@ -1,70 +1,6 @@
.canvas {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-body) 82%, var(--mantine-primary-color-light));
padding: var(--mantine-spacing-xs);
min-height: 7rem;
max-height: min(18rem, 38dvh);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.section {
border-radius: var(--mantine-radius-sm);
background: color-mix(in srgb, var(--mantine-color-default) 78%, transparent);
padding: 0.35rem;
}
.grid {
display: grid;
gap: 3px;
grid-auto-rows: clamp(1.35rem, 2.2vw, 1.75rem);
min-height: 2.5rem;
}
.tile {
container-type: inline-size;
min-width: 0;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 24%, var(--mantine-color-default-border));
border-radius: calc(var(--mantine-radius-sm) * 0.75);
background: color-mix(in srgb, var(--mantine-primary-color-light) 70%, var(--mantine-color-body));
padding-inline: 0.25rem;
}
.tileContent {
justify-content: center;
.preview {
width: 100%;
height: 100%;
overflow: hidden;
}
.tileLabel {
display: none;
max-width: 32rem;
min-width: 0;
max-height: 2.1em;
overflow: hidden;
overflow-wrap: anywhere;
line-height: 1.05;
}
@container (min-width: 9rem) {
.tileContent {
justify-content: flex-start;
}
.tileLabel {
display: block;
}
}
.containerTile {
border-style: dashed;
background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light));
}
.appIcon {
width: 0.85rem;
height: 0.85rem;
margin-inline: auto;
}
@@ -1,17 +1,17 @@
"use client";
import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import { useMemo } from "react";
import { Badge, ColorSwatch, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form";
import classes from "./_layout-preview.module.css";
interface Props {
@@ -20,107 +20,55 @@ interface Props {
layouts: BoardPreviewLayout[];
sourceLayout: BoardPreviewLayout;
apps: RouterOutputs["app"]["byIds"];
settings: FormValues;
}
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps, settings }: Props) => {
const t = useI18n("board.setting.section.layout.preview");
const tBoard = useI18n("board");
const tSection = useI18n("section");
const t = useI18n();
const elements = projectBoardLayout(board, sourceLayout, layout);
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
const largestRepresentativeWidth = Math.max(
...layouts.map((candidate) => getRepresentativeLayoutWidth(candidate, layouts)),
);
const previewWidth = `${(representativeWidth / largestRepresentativeWidth) * 100}%`;
const appsById = new Map(apps.map((app) => [app.id, app]));
const rootSections = board.sections
.filter((section) => section.kind === "empty")
.toSorted((sectionA, sectionB) => sectionA.xOffset - sectionB.xOffset);
const preview = useMemo(() => {
const appsById = new Map(apps.map((app) => [app.id, app]));
return {
layouts,
sections: board.sections,
items: board.items.map((item) => {
let iconUrl: string | undefined;
if (item.kind === "app" && typeof item.options.appId === "string")
iconUrl = appsById.get(item.options.appId)?.iconUrl;
return { ...item, iconUrl };
}),
};
}, [board, layouts, apps]);
return (
<Stack gap={6} align="center" w="100%">
<Group gap="xs" justify="center">
<Badge variant="light" color={layout.role === "mobile" ? "teal" : layout.role === "base" ? "blue" : "gray"}>
{representativeWidth}px
</Badge>
<Text size="xs">
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
<Stack gap="xs" className={classes.preview} data-testid="board-layout-preview">
<Group justify="space-between" gap="xs">
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Badge variant="light" size="sm">
{Math.max(320, getRepresentativeLayoutWidth(layout, layouts))} px
</Badge>
</Group>
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
{elements.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="xl">
{tBoard("setting.section.layout.preview.empty")}
</Text>
) : (
<Stack gap="xs">
{rootSections.map((section) => {
const sectionElements = elements.filter((element) => element.sectionId === section.id);
const columnCount = getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
if (sectionElements.length === 0 || columnCount === 0) return null;
return (
<Stack key={section.id} gap={4} className={classes.section}>
<Box
className={classes.grid}
style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
>
{sectionElements.map((element) => {
const item =
element.type === "item" ? board.items.find((candidate) => candidate.id === element.id) : null;
const appId =
item?.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null;
const app = appId ? appsById.get(appId) : undefined;
const container =
element.type === "section"
? board.sections.find((candidate) => candidate.id === element.id)
: undefined;
const containerLabel =
container?.kind === "container" && container.options.title
? container.options.title
: tSection("container.untitled");
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
return (
<Tooltip key={`${element.type}-${element.id}`} label={label} openDelay={350}>
<Box
role="img"
aria-label={label}
className={`${classes.tile} ${element.type === "section" ? classes.containerTile : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${element.width}`,
gridRow: `${element.yOffset + 1} / span ${element.height}`,
}}
>
<Group gap={4} wrap="nowrap" className={classes.tileContent}>
<ThemeIcon size="sm" variant="light" radius="sm" style={{ flexShrink: 0 }}>
{app ? (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={false}
alt=""
className={classes.appIcon}
/>
) : (
<WidgetIcon size={13} stroke={1.7} />
)}
</ThemeIcon>
<Text component="span" size="xs" fw={500} className={classes.tileLabel}>
{label}
</Text>
</Group>
</Box>
</Tooltip>
);
})}
</Box>
</Stack>
);
})}
</Stack>
)}
</Box>
<BoardLayoutThumbnail preview={preview} label={t("canvasLabel")} canvas={{ layout, sourceLayout, settings }} />
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{settings.fixedScaling
? t("dimensions", { size: normalizeFixedItemSize(settings.fixedItemSize), columns: layout.columnCount })
: t("responsiveDimensions", { columns: layout.columnCount })}
</Text>
<Group gap={6}>
<Tooltip label={tBoard("field.primaryColor.label")}>
<ColorSwatch color={settings.primaryColor} size={16} aria-hidden />
</Tooltip>
<Tooltip label={tBoard("field.secondaryColor.label")}>
<ColorSwatch color={settings.secondaryColor} size={16} aria-hidden />
</Tooltip>
</Group>
</Group>
<Text size="xs" c="dimmed">
{t("description")}
</Text>
</Stack>
);
};
@@ -1,29 +1,53 @@
.scalingSettings,
.gutterSettings {
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
}
.scalingSettings {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.6fr);
gap: var(--mantine-spacing-xl);
padding: var(--mantine-spacing-lg);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
}
.scalingControls {
align-self: center;
min-width: 0;
}
.fixedItemSizePreview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
margin: 0;
min-width: 12rem;
min-width: 0;
min-height: 200px;
}
.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;
}
@media (max-width: 48em) {
.scalingSettings {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 62em) {
.layoutPreview {
order: -1;
}
}
.gutterPreview {
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Badge,
@@ -12,6 +12,8 @@ import {
Input,
NumberInput,
Paper,
Select,
Alert,
Slider,
Stack,
Switch,
@@ -21,8 +23,8 @@ import {
import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
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";
@@ -31,6 +33,7 @@ import { InlineConfirmButton } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { Board } from "../../_types";
import { LayoutPreview } from "./_layout-preview";
import { ScalingSettings } from "./_scaling";
import type { FormValues } from "./_settings-form";
import classes from "./_layout.module.css";
@@ -39,11 +42,22 @@ interface Props {
form: UseFormReturnType<FormValues>;
isSaving: boolean;
saveSettingsAsync: () => Promise<FormValues | null>;
selectedLayoutId: string | null;
setSelectedLayoutId: (value: string | null) => void;
apps: RouterOutputs["app"]["byIds"];
}
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
export const LayoutSettingsContent = ({
board,
form,
isSaving,
saveSettingsAsync,
selectedLayoutId,
setSelectedLayoutId,
apps,
}: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
const tCommon = useI18n("common");
@@ -59,18 +73,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
});
},
});
const appIds = useMemo(
() =>
Array.from(
new Set(
board.items.flatMap((item) =>
item.kind === "app" && typeof item.options.appId === "string" ? [item.options.appId] : [],
),
),
),
[board.items],
);
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
const openLayoutEditorAsync = async (layout: FormValues["layouts"][number]) => {
setEditingLayoutId(layout.id);
@@ -107,54 +109,13 @@ 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;
const activeLayoutId =
displayedLayouts.find(({ layout }) => layout.id === selectedLayoutId)?.layout.id ?? baseLayout?.id;
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>
<ScalingSettings form={form} />
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem">
@@ -179,144 +140,169 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
form.insertListItem("layouts", newLayout, insertionIndex);
setSelectedLayoutId(newLayout.id);
}}
>
{tBoard("setting.section.layout.responsive.action.add")}
</Button>
</Group>
{displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
<Select
label={tBoard("setting.section.layout.preview.layout")}
value={activeLayoutId}
onChange={setSelectedLayoutId}
allowDeselect={false}
error={form.errors.layouts}
data={displayedLayouts.map(({ layout, index }) => ({
value: layout.id,
label: `${layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)} · ${layout.columnCount} ${tBoard("setting.section.layout.preview.columns")}${Object.keys(form.errors).some((key) => key.startsWith(`layouts.${index}.`)) ? " ⚠" : ""}`,
}))}
/>
return (
<Fieldset
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge>
</Group>
}
bg="transparent"
>
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
<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")}
description={tLayout("field.columnCount.description")}
>
<Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs"
min={1}
max={24}
step={1}
marks={[1, 6, 12, 18, 24].map((value) => ({ value, label: String(value) }))}
styles={{
markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
}}
value={layout.columnCount}
onChange={(columnCount) => {
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
}}
{Object.entries(form.errors)
.filter(([key]) => key.startsWith("layouts."))
.map(([key, error]) => (
<Alert key={key} color="red">
{error}
</Alert>
))}
{displayedLayouts
.filter(({ layout }) => layout.id === activeLayoutId)
.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
return (
<Fieldset
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge>
</Group>
}
bg="transparent"
>
<Grid gap="xl" align="flex-start">
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper
label={tLayout("field.columnCount.label")}
description={tLayout("field.columnCount.description")}
>
<Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs"
mb="lg"
min={1}
max={24}
step={1}
marks={[1, 6, 12, 18, 24].map((value) => ({ value, label: String(value) }))}
styles={{
markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
}}
value={layout.columnCount}
onChange={(columnCount) => {
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
}}
/>
</Input.Wrapper>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={tLayout("field.breakpoint.label")}
description={
layout.role === "mobile"
? tBoard("setting.section.layout.mobile.breakpointDescription")
: tLayout("field.breakpoint.description")
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
min={layout.role === "mobile" ? 0 : 1}
max={32767}
allowDecimal={false}
/>
</Input.Wrapper>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={tLayout("field.breakpoint.label")}
description={
layout.role === "mobile"
? tBoard("setting.section.layout.mobile.breakpointDescription")
: tLayout("field.breakpoint.description")
}
disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }}
min={layout.role === "mobile" ? 0 : 1}
max={32767}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
{sourceLayout && (
<LayoutPreview
board={board}
layout={layout}
layouts={form.values.layouts}
sourceLayout={sourceLayout}
apps={apps}
/>
)}
</Grid.Col>
{layout.role !== "mobile" && (
<Grid.Col span={{ base: 12, lg: 4 }}>
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
/>
{layout.role !== "mobile" && (
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) =>
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)
}
/>
)}
</Stack>
</Grid.Col>
)}
</Grid>
<Grid.Col span={{ base: 12, md: 8 }} className={classes.layoutPreview}>
{sourceLayout && (
<LayoutPreview
board={board}
layout={layout}
layouts={form.values.layouts}
sourceLayout={sourceLayout}
apps={apps}
settings={form.values}
/>
)}
</Grid.Col>
</Grid>
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
type="button"
variant="subtle"
color="red"
disabled={isSaving}
onClick={() => {
form.setFieldValue(
"layouts",
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
);
}}
>
{tCommon("action.remove")}
</Button>
)}
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
type="button"
variant="subtle"
color="red"
disabled={isSaving}
onClick={() => {
form.setFieldValue(
"layouts",
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
);
}}
>
{tCommon("action.remove")}
</Button>
)}
</Group>
<Button
type="button"
variant="default"
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
loading={editingLayoutId === layout.id}
disabled={isSaving || !form.isValid()}
onClick={() => void openLayoutEditorAsync(layout)}
>
{form.isDirty() || !persistedLayout
? tBoard("setting.section.layout.edit.saveAndEdit")
: tBoard("setting.section.layout.edit.action")}
</Button>
</Group>
<Button
type="button"
variant="default"
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
loading={editingLayoutId === layout.id}
disabled={isSaving || !form.isValid()}
onClick={() => void openLayoutEditorAsync(layout)}
>
{form.isDirty() || !persistedLayout
? tBoard("setting.section.layout.edit.saveAndEdit")
: tBoard("setting.section.layout.edit.action")}
</Button>
</Group>
</Fieldset>
);
})}
</Fieldset>
);
})}
</Stack>
</SectionCard>
);
@@ -0,0 +1,103 @@
import { Box, Group, Input, NumberInput, SegmentedControl, Slider, Stack, Text } from "@mantine/core";
import { BOARD_FIXED_ITEM_SIZE_MAX, BOARD_FIXED_ITEM_SIZE_MIN } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import {
calculateBoardUiScale,
calculateFixedBoardCanvasScale,
normalizeFixedItemSize,
} from "~/components/board/layout/scaling";
import appearanceClasses from "~/components/board/layout/canvas-appearance.module.css";
import type { FormValues } from "./_settings-form";
import classes from "./_layout.module.css";
export const ScalingSettings = ({ form }: { form: UseFormReturnType<FormValues> }) => {
const t = useI18n("board");
const size = normalizeFixedItemSize(form.values.fixedItemSize);
const canvasScale = calculateFixedBoardCanvasScale(size);
const sampleUiScale = (canvasScale * calculateBoardUiScale(canvasScale)) / 2;
return (
<Box className={classes.scalingSettings}>
<Stack gap="md" className={classes.scalingControls}>
<Input.Wrapper label={t("setting.section.layout.scaling.title")}>
<SegmentedControl
fullWidth
mt={6}
aria-label={t("setting.section.layout.scaling.title")}
value={form.values.fixedScaling ? "fixed" : "responsive"}
onChange={(mode) => form.setFieldValue("fixedScaling", mode === "fixed")}
data={[
{ value: "fixed", label: t("field.fixedScaling.label") },
{ value: "responsive", label: t("setting.section.layout.scaling.fit") },
]}
/>
</Input.Wrapper>
<Text size="sm" c="dimmed">
{form.values.fixedScaling
? t("field.fixedScaling.description")
: t("setting.section.layout.scaling.fitDescription")}
</Text>
<Group align="end" wrap="nowrap" gap="lg">
<NumberInput
label={t("field.fixedItemSize.label")}
min={BOARD_FIXED_ITEM_SIZE_MIN}
max={BOARD_FIXED_ITEM_SIZE_MAX}
step={10}
allowDecimal={false}
disabled={!form.values.fixedScaling}
suffix=" px"
w={150}
{...form.getInputProps("fixedItemSize")}
onBlur={() => form.setFieldValue("fixedItemSize", normalizeFixedItemSize(form.getValues().fixedItemSize))}
/>
<Box flex={1} pb={8}>
<Slider
thumbLabel={t("field.fixedItemSize.label")}
min={BOARD_FIXED_ITEM_SIZE_MIN}
max={BOARD_FIXED_ITEM_SIZE_MAX}
step={10}
disabled={!form.values.fixedScaling}
value={size}
onChange={(value) => form.setFieldValue("fixedItemSize", value)}
label={(value) => `${value} px`}
/>
</Box>
</Group>
<Text size="xs" c="dimmed">
{t("setting.section.layout.scaling.sizeDescription")}
</Text>
</Stack>
<Box
component="figure"
className={classes.fixedItemSizePreview}
aria-label={t("field.fixedItemSize.previewLabel", { size })}
>
<Box
className={`${classes.fixedItemSizePreviewTile} ${appearanceClasses.appearance}`}
style={{
width: size / 2,
height: size / 2,
borderRadius: `var(--mantine-radius-${form.values.itemRadius})`,
"--board-canvas-ui-scale": sampleUiScale,
}}
aria-hidden="true"
>
<Text size="sm" fw={600}>
1 × 1
</Text>
</Box>
<Box component="figcaption" ta="center">
<Text size="sm" fw={600} ff="monospace">
{t("field.fixedItemSize.previewValue", { size })}
</Text>
<Text size="xs" c="dimmed">
{t("setting.section.layout.scaling.halfSize")}
</Text>
</Box>
</Box>
</Box>
);
};
@@ -0,0 +1,43 @@
.settings {
--mantine-color-dimmed: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.tabs {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.tabs > button {
flex-shrink: 0;
}
.panel {
padding-top: var(--mantine-spacing-lg);
padding-bottom: 5rem;
min-width: 0;
}
.appearance {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: var(--mantine-spacing-lg);
}
.appearancePreview {
position: sticky;
top: calc(var(--app-shell-header-offset, 0px) + var(--mantine-spacing-md));
min-width: 0;
}
@media (max-width: 62em) {
.appearance {
grid-template-columns: minmax(0, 1fr);
}
.appearancePreview {
position: static;
grid-row: 1;
}
}
@@ -1,10 +1,12 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Button, Stack, Tabs, VisuallyHidden } from "@mantine/core";
import { IconAdjustments, IconCode, IconLayoutGrid, IconPalette, IconUsers } from "@tabler/icons-react";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useUpdateBoard } from "@homarr/boards/updater";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
@@ -21,8 +23,7 @@ import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { AppearancePlayground } from "./_appearance-playground";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
@@ -30,6 +31,7 @@ import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
import classes from "./_settings-form.module.css";
const boardSettingsFormSchema = boardSavePartialSettingsSchema
.extend({
@@ -99,6 +101,21 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
const [activeTab, setActiveTab] = useState<string | null>("layout");
const [selectedLayoutId, setSelectedLayoutId] = useState<string | null>(null);
const appIds = useMemo(
() =>
Array.from(
new Set(
board.items.flatMap((item) => {
if (item.kind === "app" && typeof item.options.appId === "string") return [item.options.appId];
return [];
}),
),
),
[board.items],
);
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
const form = useZodForm(boardSettingsFormSchema, {
initialValues: buildInitialValues(board),
@@ -125,7 +142,22 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
};
}, [updateBoard]);
const revealValidationErrors = (errors: typeof form.errors) => {
const firstField = Object.keys(errors)[0];
if (firstField) setActiveTab(getSettingsTabForField(firstField));
const layoutField = Object.keys(errors).find((field) => /^layouts\.\d+\./.test(field));
if (layoutField) {
const layout = form.values.layouts[Number(layoutField.split(".")[1])];
if (layout) setSelectedLayoutId(layout.id);
}
};
const saveSettingsAsync = async (values: FormValues): Promise<FormValues | null> => {
const validation = form.validate();
if (validation.hasErrors) {
revealValidationErrors(validation.errors);
return null;
}
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
@@ -170,6 +202,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
};
const handleDiscard = () => {
form.clearErrors();
form.setValues(initialValuesRef.current);
form.resetDirty();
updateBoard((previous) => ({
@@ -182,45 +215,101 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values))}>
<Stack gap="xl">
<GeneralSettingsContent board={board} form={form} />
<Tabs
value={activeTab}
onChange={setActiveTab}
keepMounted={false}
classNames={{ root: classes.settings, list: classes.tabs, panel: classes.panel }}
>
<VisuallyHidden>
<h2>{tSection("navigation")}</h2>
</VisuallyHidden>
<Tabs.List aria-label={tSection("navigation")}>
<Tabs.Tab value="layout" leftSection={<IconLayoutGrid size={16} />}>
{tSection("layout.title")}
</Tabs.Tab>
<Tabs.Tab value="appearance" leftSection={<IconPalette size={16} />}>
{tSection("appearance.title")}
</Tabs.Tab>
<Tabs.Tab value="general" leftSection={<IconAdjustments size={16} />}>
{tSection("general.title")}
</Tabs.Tab>
<Tabs.Tab value="advanced" leftSection={<IconCode size={16} />}>
{tSection("advanced")}
</Tabs.Tab>
{hasFullAccess && (
<Tabs.Tab value="access" leftSection={<IconUsers size={16} />}>
{tSection("access.title")}
</Tabs.Tab>
)}
</Tabs.List>
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values), revealValidationErrors)}>
{Object.keys(form.errors).length > 0 && (
<Alert color="red" mt="md">
{tSection("validationError")}
</Alert>
)}
<Tabs.Panel value="layout">
<LayoutSettingsContent
board={board}
form={form}
isSaving={isPending}
saveSettingsAsync={() => saveSettingsAsync(form.values)}
selectedLayoutId={selectedLayoutId}
setSelectedLayoutId={setSelectedLayoutId}
apps={apps}
/>
<BackgroundSettingsContent form={form} />
<ColorSettingsContent form={form} />
</Tabs.Panel>
<Tabs.Panel value="appearance">
<AppearancePlayground board={board} form={form} apps={apps} selectedLayoutId={selectedLayoutId} />
</Tabs.Panel>
<Tabs.Panel value="general">
<Stack gap="lg">
<GeneralSettingsContent board={board} form={form} />
<BehaviorSettingsContent form={form} />
</Stack>
</Tabs.Panel>
<Tabs.Panel value="advanced">
<CustomCssSettingsContent form={form} />
<BehaviorSettingsContent form={form} />
</Tabs.Panel>
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("action.discard")}
</Button>
<Button loading={isPending} type="submit">
{t("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</form>
{hasFullAccess && (
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
<Tabs.Panel value="access">
<Stack gap="lg">
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
<DangerZoneSettingsContent hideVisibility={hideVisibility} />
</Stack>
</Tabs.Panel>
)}
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
</Stack>
</Tabs>
);
};
const getSettingsTabForField = (field: string) => {
if (field.startsWith("layouts") || field === "fixedScaling" || field === "fixedItemSize") return "layout";
if (field === "customCss") return "advanced";
if (
field.startsWith("background") ||
["primaryColor", "secondaryColor", "opacity", "iconColor", "itemRadius"].includes(field)
)
return "appearance";
return "general";
};
// Previously part of the general settings section; applied on the unified save
// https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
@@ -51,6 +51,7 @@ import {
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
import {
getAssistantBoardSettingsResult,
@@ -359,6 +360,9 @@ const BoardSettingsForm = ({
allowDecimal={false}
suffix=" px"
{...form.getInputProps("fixedItemSize")}
onBlur={() =>
form.setFieldValue("fixedItemSize", normalizeFixedItemSize(form.getValues().fixedItemSize))
}
/>
</Stack>
</Tabs.Panel>
@@ -0,0 +1,210 @@
"use client";
import { useMemo } from "react";
import { Box } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData, BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import type { GridAlgorithmItem } from "@homarr/common";
import { getRootSectionLane } from "@homarr/definitions";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { getBoardCanvasGeometry } from "./layout/canvas-geometry";
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./layout/constants";
import { calculateBoardUiScale, getBoardCanvasMetrics, getBoardScaling } from "./layout/scaling";
import { useCanvasViewportSize } from "./layout/use-canvas-viewport-size";
import appearanceClasses from "./layout/canvas-appearance.module.css";
import scrollbarClasses from "./layout/canvas-scrollbar.module.css";
import classes from "./board-layout-thumbnail.module.css";
export interface BoardLayoutThumbnailCanvasProps {
preview: BoardPreviewData;
label: string;
className?: string;
canvas: {
layout: BoardPreviewLayout;
sourceLayout: BoardPreviewLayout;
settings: {
fixedScaling: boolean;
fixedItemSize: number;
opacity: number;
itemRadius: string;
iconColor: string | null;
backgroundImageUrl: string | null;
backgroundImageSize: string;
backgroundImageRepeat: string;
backgroundImageAttachment: string;
};
};
}
/** The Boards thumbnail with exact canvas geometry for the settings draft. */
export const BoardLayoutThumbnailCanvas = ({ preview, label, canvas, className }: BoardLayoutThumbnailCanvasProps) => {
const { layout, sourceLayout, settings } = canvas;
const { ref: viewportRef, width, height } = useCanvasViewportSize();
const screenWidth = Math.max(320, getRepresentativeLayoutWidth(layout, preview.layouts));
const miniatureScale = width / screenWidth;
const geometry = getBoardCanvasGeometry(layout);
const metrics = getBoardCanvasMetrics(getBoardScaling(settings), screenWidth - 32, geometry.width);
const visualUiScale = metrics.scale * calculateBoardUiScale(metrics.scale);
const elements = useMemo(() => projectBoardLayout(preview, sourceLayout, layout), [preview, sourceLayout, layout]);
const roots = preview.sections.filter((section) => section.kind === "empty");
const rootIds = new Set(roots.map((section) => section.id));
const rootElements = elements.filter((element) => rootIds.has(element.sectionId));
const rowCount = Math.max(2, ...rootElements.map((element) => element.yOffset + element.height));
const pitch = LOGICAL_GRID_PITCH * metrics.scale;
const canvasOffset = Math.max(0, (screenWidth - 32 - metrics.width) / 2);
const contentWidth = Math.max(screenWidth, metrics.width + 32);
let canvasTop = 16;
if (settings.fixedScaling) canvasTop += 32 * metrics.scale;
const contentHeight = rowCount * pitch + canvasTop + 16;
const itemsById = new Map(preview.items.map((item) => [item.id, item]));
const stationaryBackground = settings.fixedScaling || settings.backgroundImageAttachment === "fixed";
const backgroundStyle = {
backgroundImage: settings.backgroundImageUrl ? `url(${JSON.stringify(settings.backgroundImageUrl)})` : undefined,
backgroundSize: settings.backgroundImageSize,
backgroundRepeat: settings.backgroundImageRepeat,
backgroundPosition: "center center",
};
const renderIcon = (id: string) => {
const item = itemsById.get(id);
const WidgetIcon = item?.kind ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
if (item?.iconUrl) {
return (
<MaskedOrNormalImage
imageUrl={item.iconUrl}
hasColor={Boolean(settings.iconColor)}
color={settings.iconColor || undefined}
alt=""
className={classes.scaledItemIcon}
/>
);
}
return <WidgetIcon className={classes.scaledItemIcon} stroke={1.7} />;
};
// Keep the familiar nested-container representation without mounting live widgets.
const renderContents = (element: GridAlgorithmItem) => {
if (element.type === "item") return renderIcon(element.id);
const nestedItems = elements.filter((candidate) => candidate.sectionId === element.id);
const columns = Math.max(element.width, ...nestedItems.map((item) => item.xOffset + item.width));
const rows = Math.max(element.height, ...nestedItems.map((item) => item.yOffset + item.height));
return (
<span
className={classes.containerContents}
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}}
>
{nestedItems.map((item) => (
<span
key={item.id}
className={classes.tile}
style={{
gridColumn: `${item.xOffset + 1} / span ${item.width}`,
gridRow: `${item.yOffset + 1} / span ${item.height}`,
}}
>
{renderIcon(item.id)}
</span>
))}
</span>
);
};
return (
<Box className={[classes.canvas, classes.exactCanvas, className].filter(Boolean).join(" ")}>
{stationaryBackground && (
<Box
aria-hidden
className={classes.scaledSurface}
style={{
...backgroundStyle,
width: screenWidth,
height: height / (miniatureScale || 1),
transform: `scale(${miniatureScale})`,
pointerEvents: "none",
}}
/>
)}
<Box
component="section"
ref={viewportRef}
aria-label={label}
tabIndex={0}
className={`${classes.thumbnailViewport} ${scrollbarClasses.scrollbar}`}
data-testid="layout-preview-viewport"
data-preview-scale={miniatureScale}
data-item-size={metrics.itemSize}
>
<Box
style={{
width: contentWidth * miniatureScale,
height: contentHeight * miniatureScale,
minHeight: "100%",
overflow: "clip",
position: "relative",
}}
>
<Box
aria-hidden
className={`${classes.scaledSurface} ${appearanceClasses.appearance}`}
style={{
width: contentWidth,
height: Math.max(contentHeight, (screenWidth * 7) / 16),
transform: `scale(${miniatureScale})`,
"--board-canvas-ui-scale": visualUiScale,
"--preview-opacity": settings.opacity / 100,
"--preview-radius": `var(--mantine-radius-${settings.itemRadius})`,
color: settings.iconColor || undefined,
...(!stationaryBackground && backgroundStyle),
}}
>
{geometry.lanes.map((lane) => {
const root = roots.find((section) => getRootSectionLane(section.xOffset) === lane.lane);
const laneElements = rootElements.filter((element) => element.sectionId === root?.id);
return (
<Box
key={lane.lane}
data-preview-lane={lane.lane}
className={classes.scaledLane}
style={{
left: 16 + canvasOffset + lane.offset * metrics.scale,
top: canvasTop,
width: lane.width * metrics.scale,
height: rowCount * pitch,
}}
>
{laneElements.map((element) => {
let inset = BOARD_GRID_ITEM_INSET;
if (element.type === "section" && !settings.fixedScaling) inset = 5 * visualUiScale;
return (
<Box
key={`${element.type}-${element.id}`}
className={`${classes.tile} ${classes.scaledTile}`}
data-preview-tile
data-type={element.type}
style={{
left: element.xOffset * pitch + inset,
top: element.yOffset * pitch + inset,
width: Math.max(0, element.width * pitch - 2 * inset),
height: Math.max(0, element.height * pitch - 2 * inset),
}}
>
{renderContents(element)}
</Box>
);
})}
</Box>
);
})}
</Box>
</Box>
</Box>
</Box>
);
};
@@ -5,11 +5,7 @@
overflow: hidden;
color: var(--mantine-color-dimmed);
background:
linear-gradient(
to top,
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
transparent 55%
),
linear-gradient(to top, color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent), transparent 55%),
var(--mantine-color-body);
}
@@ -84,3 +80,60 @@
gap: rem(1px);
overflow: hidden;
}
.exactCanvas {
position: relative;
max-height: min(18rem, 38dvh);
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
}
.thumbnailViewport {
position: absolute;
inset: 0;
overflow: auto;
overscroll-behavior: contain;
}
.thumbnailViewport:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.scaledSurface {
position: absolute;
inset: 0 auto auto 0;
transform-origin: top left;
}
.scaledLane,
.scaledTile {
position: absolute;
}
.scaledSurface .tile {
border-radius: var(--preview-radius);
background: rgb(
from light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)) r g b / var(--preview-opacity, 1)
);
border-color: rgb(
from light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) r g b / var(--preview-opacity, 1)
);
}
.scaledTile[data-type="section"] {
border-style: dashed;
}
.scaledItemIcon {
position: absolute;
top: 50%;
left: 50%;
width: calc(32px * var(--board-canvas-ui-scale, 1));
height: calc(32px * var(--board-canvas-ui-scale, 1));
max-width: 55%;
max-height: 55%;
object-fit: contain;
transform: translate(-50%, -50%);
}
@@ -6,6 +6,8 @@ import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { BoardLayoutThumbnailCanvas } from "./board-layout-thumbnail-canvas";
import type { BoardLayoutThumbnailCanvasProps } from "./board-layout-thumbnail-canvas";
import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps {
@@ -13,6 +15,7 @@ interface BoardLayoutThumbnailProps {
label: string;
previewRowLimit?: number;
className?: string;
canvas?: BoardLayoutThumbnailCanvasProps["canvas"];
}
const compactBoardLayoutThumbnailRows = 12;
@@ -23,7 +26,11 @@ export const BoardLayoutThumbnail = ({
label,
previewRowLimit = compactBoardLayoutThumbnailRows,
className,
canvas,
}: BoardLayoutThumbnailProps) => {
if (preview && canvas) {
return <BoardLayoutThumbnailCanvas preview={preview} label={label} canvas={canvas} className={className} />;
}
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
@@ -15,6 +15,24 @@ 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.
## Sizing policy and previews
`scaling.ts` owns the explicit `BoardScaling` union and pure canvas metrics.
`getBoardScaling` is the adapter from the persisted boolean/size settings. Both
the live renderer and settings thumbnail consume the same metrics; neither
should add viewport fitting outside this module. Fixed sizing uses CSS pixels
and never compensates for browser zoom or device-pixel ratio.
`canvas-geometry.ts` owns lane offsets, widths, and gaps for both renderers.
The settings thumbnail applies a separate miniature transform after calculating
board geometry at a representative layout width. That display transform must never feed
back into the board sizing policy.
To retire responsive sizing, migrate the persisted default/settings, remove
the responsive policy branch and its UI option, then remove the responsive
viewport styles. Placement geometry and the fixed renderer do not need a
second conversion. Nested Container fitting is a separate layout concern.
The read-only renderer should:
1. convert persisted `xOffset/yOffset/width/height` values to `x/y/w/h`;
@@ -0,0 +1,39 @@
.appearance {
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
* zoom and enlarges everything together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
/* Keep a subtle boundary inset without widening the visual gap too much. */
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
* above, just never ported over to radius. */
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
}
@@ -0,0 +1,22 @@
import { boardLanes, getBoardLaneColumnCount } from "@homarr/definitions";
import { getLogicalGridSize } from "./geometry";
export const BOARD_LANE_GAP = 24;
/** Shared horizontal geometry for the live board and its settings playground. */
export const getBoardCanvasGeometry = (layout: Parameters<typeof getBoardLaneColumnCount>[0]) => {
let width = 0;
const lanes = boardLanes.flatMap((lane) => {
const columnCount = getBoardLaneColumnCount(layout, lane);
if (columnCount === 0) return [];
if (width > 0) width += BOARD_LANE_GAP;
const offset = width;
const laneWidth = getLogicalGridSize(columnCount);
width += laneWidth;
return [{ lane, columnCount, width: laneWidth, offset }];
});
return { lanes, width, gridTemplateColumns: lanes.map((lane) => `${lane.width}px`).join(" ") };
};
@@ -0,0 +1,35 @@
.scrollbar::-webkit-scrollbar {
width: 2px;
height: 2px;
}
.scrollbar::-webkit-scrollbar-track,
.scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
.scrollbar::-webkit-scrollbar-thumb {
border-radius: 999px;
background: transparent;
}
.scrollbar:hover::-webkit-scrollbar-thumb,
.scrollbar:focus-visible::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
}
.scrollbar::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--mantine-color-dimmed) 70%, transparent);
}
@supports (-moz-appearance: none) {
.scrollbar {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
}
.scrollbar:hover,
.scrollbar:focus-visible {
scrollbar-color: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent) transparent;
}
}
@@ -11,9 +11,10 @@
}
.viewport[data-canvas-scaling="fixed"] {
--board-canvas-shell-offset: calc(var(--app-shell-header-offset, 0px) + var(--app-shell-footer-offset, 0px));
--board-canvas-shell-padding: calc(2 * var(--app-shell-padding, 1rem));
--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)
100dvh - var(--board-canvas-shell-offset) - var(--board-canvas-shell-padding)
);
height: var(--board-canvas-fixed-viewport-height);
@@ -21,42 +22,6 @@
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;
@@ -87,42 +52,4 @@
.canvas {
position: absolute;
inset: 0 auto auto 0;
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
* zoom and enlarges everything together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
/* Keep a subtle boundary inset without widening the visual gap too much. */
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
* above, just never ported over to radius. */
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
}
@@ -1,12 +1,17 @@
"use client";
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./constants";
import { BOARD_GRID_ITEM_INSET } from "./constants";
import type { BoardScaling } from "./scaling";
import { calculateBoardUiScale, getBoardCanvasMetrics } from "./scaling";
import { useCanvasViewportSize } from "./use-canvas-viewport-size";
import classes from "./scaled-board-canvas.module.css";
import appearanceClasses from "./canvas-appearance.module.css";
import scrollbarClasses from "./canvas-scrollbar.module.css";
const BoardCanvasScaleContext = createContext(1);
const BoardCanvasViewportHeightContext = createContext<number | null>(null);
@@ -14,32 +19,11 @@ 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;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
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;
return canvasScale < 1 ? 1 / canvasScale : 1;
};
interface ScaledBoardCanvasProps {
logicalWidth: number;
initialLogicalHeight: number;
initialAvailableWidth: number;
fixedItemSize?: number;
scaling: BoardScaling;
label: string;
}
@@ -52,11 +36,11 @@ export const ScaledBoardCanvas = ({
logicalWidth,
initialLogicalHeight,
initialAvailableWidth,
fixedItemSize,
scaling,
label,
children,
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useElementSize<HTMLDivElement>();
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useCanvasViewportSize();
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
const [isHydrated, setIsHydrated] = useState(false);
@@ -66,29 +50,27 @@ export const ScaledBoardCanvas = ({
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
const isFixedScaling = fixedItemSize !== undefined;
const scale = useMemo(() => {
if (fixedItemSize !== undefined) return calculateFixedBoardCanvasScale(fixedItemSize);
return calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth);
}, [fixedItemSize, logicalWidth, resolvedAvailableWidth]);
const isFixedScaling = scaling.mode === "fixed";
const {
scale,
width: visualWidth,
centered,
overflows,
} = getBoardCanvasMetrics(scaling, resolvedAvailableWidth, logicalWidth);
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
component="section"
ref={viewportRef}
className={classes.viewport}
className={`${classes.viewport} ${isFixedScaling ? scrollbarClasses.scrollbar : ""}`}
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scaling={isFixedScaling ? "fixed" : "responsive"}
data-canvas-scaling={scaling.mode}
data-canvas-scale={scale}
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
data-canvas-centered={shouldCenterCanvas ? "true" : undefined}
data-canvas-overflow={overflows ? "true" : "false"}
data-canvas-centered={centered ? "true" : undefined}
data-canvas-initial-height={initialLogicalHeight}
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
@@ -106,7 +88,7 @@ export const ScaledBoardCanvas = ({
>
<Box
ref={canvasRef}
className={classes.canvas}
className={`${classes.canvas} ${appearanceClasses.appearance}`}
style={{
"--board-grid-card-inset": `${BOARD_GRID_ITEM_INSET}px`,
"--board-canvas-inverse-scale": inverseScale,
@@ -0,0 +1,55 @@
import {
BOARD_FIXED_ITEM_SIZE_DEFAULT,
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
} from "@homarr/definitions";
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./constants";
export type BoardScaling = { mode: "fixed"; itemSize: number } | { mode: "responsive" };
export const normalizeFixedItemSize = (value: number | string) => {
if (value === "" || !Number.isFinite(Number(value))) return BOARD_FIXED_ITEM_SIZE_DEFAULT;
return Math.min(BOARD_FIXED_ITEM_SIZE_MAX, Math.max(BOARD_FIXED_ITEM_SIZE_MIN, Math.round(Number(value))));
};
/** The only adapter from persisted settings to the canvas sizing policy. */
export const getBoardScaling = (settings: { fixedScaling: boolean; fixedItemSize: number }): BoardScaling => {
if (settings.fixedScaling) return { mode: "fixed", itemSize: normalizeFixedItemSize(settings.fixedItemSize) };
return { mode: "responsive" };
};
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return availableWidth / logicalWidth;
};
export const calculateFixedBoardCanvasScale = (itemSize: number) =>
(normalizeFixedItemSize(itemSize) + 2 * BOARD_GRID_ITEM_INSET) / LOGICAL_GRID_PITCH;
/** Fixed sizing never reads viewport width or devicePixelRatio: browser zoom stays native. */
export const getBoardCanvasMetrics = (scaling: BoardScaling, availableWidth: number, logicalWidth: number) => {
let scale: number;
if (scaling.mode === "fixed") scale = calculateFixedBoardCanvasScale(scaling.itemSize);
else scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
const width = logicalWidth * scale;
return {
scale,
width,
itemSize: Math.max(0, LOGICAL_GRID_PITCH * scale - 2 * BOARD_GRID_ITEM_INSET),
centered: scaling.mode === "fixed" && width <= availableWidth,
overflows: availableWidth > 0 && width - availableWidth > 0.5,
};
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
if (canvasScale < 1) return 1 / canvasScale;
return 1;
};
@@ -1,8 +1,65 @@
import { describe, expect, test } from "vitest";
import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
import { getBoardCanvasGeometry } from "../canvas-geometry";
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "../constants";
import {
calculateBoardCanvasScale,
calculateBoardUiScale,
calculateFixedBoardCanvasScale,
getBoardCanvasMetrics,
getBoardScaling,
normalizeFixedItemSize,
} from "../scaling";
describe("board canvas scale policy", () => {
test.each([100, 150, 200, 400])("renders an exact %s CSS-pixel fixed tile", (itemSize) => {
const scale = calculateFixedBoardCanvasScale(itemSize);
expect(LOGICAL_GRID_PITCH * scale - 2 * BOARD_GRID_ITEM_INSET).toBeCloseTo(itemSize);
});
test.each([Number.NaN, Number.POSITIVE_INFINITY, ""])(
"uses the default fixed size for unavailable input %s",
(input) => {
expect(normalizeFixedItemSize(input)).toBe(BOARD_FIXED_ITEM_SIZE_DEFAULT);
},
);
test("clamps and rounds the configured fixed size", () => {
expect(normalizeFixedItemSize(20)).toBe(100);
expect(normalizeFixedItemSize(700)).toBe(400);
expect(normalizeFixedItemSize(200.7)).toBe(201);
});
test.each([0.75, 1, 1.25, 1.5, 2])("preserves fixed CSS dimensions at browser zoom %s", (zoom) => {
const scaling = getBoardScaling({ fixedScaling: true, fixedItemSize: 200 });
const metrics = getBoardCanvasMetrics(scaling, 1440 / zoom, 5 * LOGICAL_GRID_PITCH);
expect(metrics.itemSize).toBeCloseTo(200);
expect(metrics.width).toBeCloseTo(1100);
});
test("centers fixed boards that fit and scrolls without changing tile size when narrow", () => {
const scaling = getBoardScaling({ fixedScaling: true, fixedItemSize: 200 });
expect(getBoardCanvasMetrics(scaling, 2560, 1060)).toMatchObject({ centered: true, overflows: false });
expect(getBoardCanvasMetrics(scaling, 600, 1060)).toMatchObject({ centered: false, overflows: true });
});
test("keeps the sizing mode adapter reversible", () => {
expect(getBoardScaling({ fixedScaling: false, fixedItemSize: 200 })).toEqual({ mode: "responsive" });
expect(getBoardCanvasMetrics({ mode: "responsive" }, 600, 1060).width).toBeCloseTo(600);
});
test("aligns left, main, and right lanes using the same canvas geometry", () => {
const geometry = getBoardCanvasGeometry({ columnCount: 5, leftGutterColumnCount: 1, rightGutterColumnCount: 1 });
expect(geometry.lanes.map(({ lane, offset, width }) => ({ lane, offset, width }))).toEqual([
{ lane: "left", offset: 0, width: 212 },
{ lane: "main", offset: 236, width: 636 },
{ lane: "right", offset: 896, width: 212 },
]);
expect(geometry.width).toBe(1108);
});
test("fits the logical canvas exactly", () => {
const scale = calculateBoardCanvasScale(840, 1000);
@@ -0,0 +1,16 @@
"use client";
import { useCallback, useState } from "react";
/** Canvas fitting uses the scrollport, not the border box including native scrollbars. */
export const useCanvasViewportSize = () => {
const [size, setSize] = useState({ width: 0, height: 0 });
const ref = useCallback((node: HTMLElement | null) => {
if (!node) return;
const observer = new ResizeObserver(() => setSize({ width: node.clientWidth, height: node.clientHeight }));
observer.observe(node);
return () => observer.disconnect();
}, []);
return { ref, ...size };
};
@@ -19,11 +19,8 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import {
calculateBoardUiScale,
useBoardCanvasScale,
useBoardCanvasViewportHeight,
} from "~/components/board/layout/scaled-board-canvas";
import { useBoardCanvasScale, useBoardCanvasViewportHeight } from "~/components/board/layout/scaled-board-canvas";
import { calculateBoardUiScale } from "~/components/board/layout/scaling";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
+20 -4
View File
@@ -5432,13 +5432,13 @@
},
"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."
"description": "Keep board tiles 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"
"previewValue": "{size} × {size} px"
},
"columnCount": {
"label": "Column count"
@@ -5465,6 +5465,9 @@
"setting": {
"title": "Settings for {boardName} board",
"section": {
"navigation": "Board settings",
"advanced": "Advanced",
"validationError": "Check the highlighted settings before saving. Layouts with errors are marked in the layout selector.",
"general": {
"title": "General",
"unrecognizedLink": "The provided link is not recognized and won't preview, it might still work."
@@ -5472,7 +5475,11 @@
"layout": {
"title": "Layout",
"scaling": {
"title": "Scaling"
"title": "Tile sizing",
"fit": "Fit to screen",
"fitDescription": "Resize the whole board to fill the available width. Tile size changes with the window.",
"sizeDescription": "The size applies in fixed scaling and is kept when you switch modes.",
"halfSize": "Tile sample · 50% size"
},
"responsive": {
"title": "Responsive layouts",
@@ -5493,6 +5500,12 @@
"breakpointDescription": "Mobile is always used from 0px until the next layout breakpoint."
},
"preview": {
"title": "Board preview",
"description": "A miniature of this layout at the indicated screen width. Changes appear here before you save.",
"layout": "Layout to configure",
"canvasLabel": "Scrollable board layout preview",
"dimensions": "1 × 1: {size} × {size} px · {columns} columns",
"responsiveDimensions": "Fit to screen · {columns} columns",
"columns": "columns",
"empty": "This layout is empty",
"dynamicSection": "Dynamic section",
@@ -5520,7 +5533,10 @@
"title": "Background"
},
"appearance": {
"title": "Appearance"
"title": "Appearance",
"showShades": "Show color shades",
"hideShades": "Hide color shades",
"pickColor": "Pick {label} from the screen"
},
"customCss": {
"title": "Custom css"