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

@@ -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";