fix: restore translucent dashboard styling (#6664)

* fix: restore translucent dashboard styling

* fix: restore dashboard widget transparency

* fix: address dashboard review feedback

* fix: apply board opacity to containers

* fix: flatten demo board container

* fix: default board roundness to sm

* fix: restore assistant widget transparency

* Revert "fix: default board roundness to sm"

This reverts commit c235cbdf2b.

* fix: address remaining dashboard review comments

* test: align grid geometry expectations
This commit is contained in:
Thomas authored and GitHub committed 2026-08-22 15:51:43 +02:00
1 parent 2570de25a6
commit 19760e5722
88 files changed
+940 -620

No files matched your search

@@ -130,6 +130,45 @@
color: var(--mantine-color-text);
}
.widgetPanel[data-board-widget] {
background: transparent;
}
.widgetPanel[data-board-widget] .panelHeader,
.widgetPanel[data-board-widget] .composerWrap {
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
}
.widgetPanel[data-board-widget] .composerWrap,
.widgetPanel[data-board-widget] .composer,
.widgetPanel[data-board-widget] .composerQuote,
.widgetPanel[data-board-widget] .pendingQuestionDock {
background: transparent;
}
.widgetPanel[data-board-widget] .suggestion,
.widgetPanel[data-board-widget] .runtimeSelectorTrigger,
.widgetPanel[data-board-widget] .composerContext,
.widgetPanel[data-board-widget] .providerQuotaTrigger {
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
background: transparent;
}
.widgetPanel[data-board-widget] .suggestion:hover:not(:disabled),
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:hover:not(:disabled),
.widgetPanel[data-board-widget] .composerContext:hover:not(:disabled),
.widgetPanel[data-board-widget] .providerQuotaTrigger:hover:not(:disabled) {
background: rgb(from var(--mantine-color-default-hover) r g b / var(--opacity, 1));
}
.widgetPanel[data-board-widget] .suggestion:focus-visible,
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:focus-visible,
.widgetPanel[data-board-widget] .composerContext:focus-visible,
.widgetPanel[data-board-widget] .providerQuotaTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.widgetState {
container-type: inline-size;
overflow: auto;
@@ -157,30 +196,12 @@
border-bottom: 1px solid var(--mantine-color-default-border);
}
.panelIdentity {
min-width: 0;
flex: 1 1 auto;
}
.panelIdentityIcon {
flex: 0 0 auto;
}
.panelIdentityTitle,
.panelThreadTitle {
max-width: 14rem;
}
.panelActions {
min-width: 0;
flex: 0 0 auto;
margin-inline-start: auto;
}
.newConversationButton {
flex: 0 0 auto;
}
.panelAction {
width: 2.625rem;
height: 2.625rem;
@@ -207,21 +228,6 @@
display: none;
}
.newConversationButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.newConversationButton :global(.mantine-Button-section) {
margin-inline: 0;
}
.newConversationButton :global(.mantine-Button-label) {
display: none;
}
.messages {
padding: 0.5rem 0.65rem 1rem;
}
@@ -277,8 +283,7 @@
@container assistant-widget (max-width: 22rem) {
.panelAction,
.historyButton,
.newConversationButton {
.historyButton {
width: 2.25rem;
max-width: 2.25rem;
height: 2.25rem;
@@ -301,10 +306,6 @@
.composerContextLabel {
display: none;
}
.panelThreadTitle {
display: none;
}
}
@container assistant-widget (max-width: 18rem) {
@@ -2368,7 +2368,7 @@ const EmptyThread = () => {
<Box className={classes.empty}>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<ThemeIcon size={52} radius="xl" variant="light">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
@@ -2957,7 +2957,6 @@ const Composer = (props: ComposerProps) => {
) : (
<ComposerPrimitive.Send asChild>
<ActionIcon
color="red"
variant="filled"
size="lg"
aria-label={
@@ -3162,27 +3161,6 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
onDismiss?: () => void;
}
const AssistantPanelIdentity = () => {
const t = useI18n("assistant");
const threadTitle = useAuiState((state) => state.threadListItem.title);
return (
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
<IconRobot size={16} />
</ThemeIcon>
<Box miw={0}>
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
{t("title")}
</Text>
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
{threadTitle ?? t("newConversation")}
</Text>
</Box>
</Group>
);
};
export const AssistantConversationSurface = ({
isRunning,
pendingAction,
@@ -3212,23 +3190,20 @@ export const AssistantConversationSurface = ({
return (
<AssistantDirectiveEntitiesProvider>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<AssistantPanelIdentity />
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
<AutoApprovalControl />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<Button
className={classes.newConversationButton}
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconPlus size={17} />}
aria-label={t("newConversation")}
>
{t("newConversation")}
</Button>
<IconPlus size={17} />
</ActionIcon>
</ThreadListPrimitive.New>
</Tooltip>
{onExpand && (
@@ -16,7 +16,7 @@ const AssistantPromptDetail = () => {
const t = useI18n("assistant");
return (
<Group px="md" py="sm" wrap="nowrap">
<ThemeIcon color="red" variant="light" radius="xl">
<ThemeIcon variant="light" radius="xl">
<IconRobot size={17} />
</ThemeIcon>
<Stack gap={1}>
@@ -137,7 +137,7 @@ const CompactAssistantWidget = ({
<Box className={classes.widgetPanel}>
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="xs" miw={0}>
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto">
<ThemeIcon variant="light" radius="xl" size="lg" flex="0 0 auto">
<IconRobot size={19} />
</ThemeIcon>
<Stack className={classes.compactCopy} gap={1}>
@@ -167,13 +167,7 @@ interface AssistantWidgetStateProps {
children?: ReactNode;
}
const AssistantWidgetState = ({
icon: Icon,
title,
description,
color = "red",
children,
}: AssistantWidgetStateProps) => (
const AssistantWidgetState = ({ icon: Icon, title, description, color, children }: AssistantWidgetStateProps) => (
<Center className={classes.widgetState} h="100%" p="md">
<Stack align="center" gap="sm" ta="center" maw={360}>
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
@@ -57,26 +58,29 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const registeredGridEditors = useRegisteredGridEditors();
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry],
);
const targets = useMemo(
() =>
getMoveTargets(board, currentLayoutId, entry, {
canvas: tLandmark("canvas"),
container: tContainer("action.create"),
leftRail: tLandmark("leftRail"),
rightRail: tLandmark("rightRail"),
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
located: (name, location, index) =>
tMoveResize("target.located", {
name,
location,
index: String(index),
}),
}),
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
const targets = getMoveTargets(
board,
currentLayoutId,
entry,
{
canvas: tLandmark("canvas"),
container: tContainer("action.create"),
leftRail: tLandmark("leftRail"),
rightRail: tLandmark("rightRail"),
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
located: (name, location, index) =>
tMoveResize("target.located", {
name,
location,
index: String(index),
}),
},
registeredGridEditors,
);
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
@@ -252,6 +256,7 @@ export const getMoveTargets = (
layoutId: string,
entry: MovableEntry,
labels: MoveTargetLabels,
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
): MoveTarget[] => {
const minimum = getEntryMinimumSize(board, layoutId, entry);
@@ -260,13 +265,15 @@ export const getMoveTargets = (
const columnCount = getSectionColumnCount(board, layoutId, section.id);
const lane = getRootSectionLane(section.xOffset);
if (columnCount === 0) return [];
const registeredEditor = registeredGridEditors?.get(section.id);
if (lane !== "main" && registeredGridEditors && !registeredEditor) return [];
return minimum.width <= columnCount
? [
{
id: section.id,
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
columnCount,
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
},
]
: [];
@@ -3,8 +3,8 @@
The board uses fixed logical units:
- one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
- the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`);
- the canvas is zoomed once with one uniform scale derived from its available width.
Items keep the same logical dimensions at every viewport. The viewport reserves
@@ -3,7 +3,7 @@
* individual cards never change their internal layout at different viewports.
*/
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 24;
export const LOGICAL_GRID_GAP = 12;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
@@ -23,6 +23,8 @@
* 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));
@@ -5,7 +5,12 @@ import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/bo
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalTrackSize,
getRootSectionForLane,
} from "../index";
describe("board lanes", () => {
test("gutters consume columns from the main canvas", () => {
@@ -126,6 +131,6 @@ describe("board lanes", () => {
},
];
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
});
});
@@ -9,18 +9,18 @@ import {
} from "../index";
describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
expect(getLogicalTrackSize(1)).toBe(200);
expect(getLogicalTrackSize(0.5)).toBe(88);
expect(getLogicalTrackSize(2)).toBe(424);
expect(getLogicalTrackSize(3)).toBe(648);
expect(getLogicalTrackSize(0.5)).toBe(94);
expect(getLogicalTrackSize(2)).toBe(412);
expect(getLogicalTrackSize(3)).toBe(624);
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
position: "absolute",
left: 224,
top: 448,
width: 424,
height: 648,
left: 212,
top: 424,
width: 412,
height: 624,
});
});
@@ -35,7 +35,7 @@ describe("fixed dashboard geometry", () => {
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(12);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
});
});
@@ -46,6 +46,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Card
className={combineClasses(
classes.itemCard,
classes.containerCard,
options.customCssClasses.join(" "),
isVisuallyCollapsed && classes.collapsedContainerCard,
)}
@@ -56,7 +57,7 @@ export const BoardContainerSection = ({ section }: Props) => {
root: {
overflow: "visible",
"--opacity": board.opacity / 100,
"--border-color": options.borderColor || undefined,
"--container-border-color": options.borderColor || undefined,
},
}}
radius={board.itemRadius}
@@ -66,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={isVisuallyCollapsed ? 0 : -24}
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
@@ -103,8 +104,9 @@ export const BoardContainerSection = ({ section }: Props) => {
)}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
<Badge
className={classes.containerLabel}
pos="absolute"
top={-24}
top="calc(var(--mantine-spacing-xs) * -1)"
left={labelLeft}
maw={`calc(100% - ${labelLeft + labelRight}px)`}
size="md"
@@ -113,12 +115,7 @@ export const BoardContainerSection = ({ section }: Props) => {
c="var(--mantine-color-text)"
style={{
zIndex: 9,
overflow: "hidden",
pointerEvents: "none",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: "var(--background-color)",
borderColor: "var(--border-color)",
}}
title={options.title}
data-board-container-label
@@ -128,8 +125,9 @@ export const BoardContainerSection = ({ section }: Props) => {
)}
{options.showOpenAll && !isEditMode && (
<ActionIcon
className={classes.containerAction}
pos="absolute"
top={isVisuallyCollapsed ? "50%" : -24}
top={isVisuallyCollapsed ? "50%" : "calc(var(--mantine-spacing-xs) * -1)"}
right={8}
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
variant={options.collapsible ? "subtle" : "default"}
@@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
import { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry";
import { useRegisteredGridEditors } from "./grid-editor-registry";
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
@@ -54,14 +54,14 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
const GridEditor = resolvedEditorModule?.default ?? null;
return (
<GridEditorRegistryProvider>
<>
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
{isEditMode && Provider && GridEditor && (
<Provider>
<RegisteredGridEditors GridEditor={GridEditor} />
</Provider>
)}
</GridEditorRegistryProvider>
</>
);
};
@@ -23,7 +23,12 @@ describe("dnd grid coordinates", () => {
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
const scale = 0.75;
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
const initialShape = {
left: target.left,
top: target.top,
width: getLogicalTrackSize(2) * scale,
height: 200 * scale,
};
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
expect(
@@ -55,6 +55,7 @@ export interface GridEditorRenderProps {
}
export interface RegisteredGridEditor extends GridEditorRenderProps {
placementMaxRowCount: number | null;
host: HTMLElement;
disabled: boolean;
}
@@ -51,7 +51,7 @@
.board-grid-entry[data-board-item-selected="true"]::after {
position: absolute;
inset: 0;
inset: var(--board-grid-item-inset, 0px);
z-index: 100;
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
@@ -121,6 +121,7 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-placeholder {
z-index: 0;
clip-path: inset(var(--board-grid-item-inset, 0px));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
border: 2px dashed var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
@@ -139,6 +140,7 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-resize-outline {
z-index: 1002;
clip-path: inset(var(--board-grid-item-inset, 0px));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
@@ -153,17 +155,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
}
[data-section-kind="empty"][data-rail-placement="main"] > .board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
box-shadow: none;
}
[data-section-kind="empty"][data-rail-placement="main"]
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
outline-color: var(--mantine-color-red-filled);
}
[data-board-container-collapsed]
> [data-board-container-body]
> [data-section-id]
@@ -240,11 +231,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
top: -0.25rem;
top: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: -0.25rem;
bottom: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
@@ -258,11 +249,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
right: -0.25rem;
right: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
left: -0.25rem;
left: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
}
.board-grid-resize-handle[data-grid-resize-handle="ne"],
@@ -275,42 +266,30 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: -0.625rem;
right: -0.625rem;
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
right: -0.625rem;
bottom: -0.625rem;
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: -0.625rem;
left: -0.625rem;
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: -0.625rem;
left: -0.625rem;
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
cursor: nwse-resize;
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
so it doesn't sit on top of the container menu button anchored in this same corner. */
clip-path: polygon(
evenodd,
0 0,
100% 0,
100% 100%,
0 100%,
0 0,
4px 4px,
4px 40px,
40px 40px,
40px 4px,
4px 4px
);
clip-path: polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, 4px 4px, 4px 40px, 40px 40px, 40px 4px, 4px 4px);
}
@media (prefers-reduced-motion: reduce) {
@@ -46,12 +46,14 @@
.contentMount {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
inset: var(--board-grid-item-inset, 0px);
overflow: visible;
}
.staticItem:has([data-board-container-label]) {
z-index: 2;
}
.editorEntry {
overflow: visible;
cursor: grab;
@@ -130,6 +130,10 @@ export const SectionGrid = ({
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
let placementMaxRowCount = maxRowCount;
if (railPlacement !== "main") {
placementMaxRowCount = minimumViewportRowCount;
}
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
@@ -175,6 +179,7 @@ export const SectionGrid = ({
columnCount,
rowCount,
maxRowCount,
placementMaxRowCount,
placements: displayPlacements,
transactionPlacements: placements,
className: editorClassName,
@@ -190,6 +195,7 @@ export const SectionGrid = ({
entryElementStore,
isInteractionDisabled,
maxRowCount,
placementMaxRowCount,
placements,
rowCount,
section,
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, test } from "vitest";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
describe("grid preview geometry", () => {
@@ -25,8 +27,12 @@ describe("grid preview geometry", () => {
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe(
`${-2 * LOGICAL_GRID_PITCH}px`,
);
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe(
`${2 * LOGICAL_GRID_PITCH}px`,
);
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
@@ -57,10 +63,10 @@ describe("grid preview geometry", () => {
const active = elements.get("active");
expect(active?.getAttribute("data-grid-preview")).toBe("true");
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalTrackSize(3)}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`);
expect(active?.dataset.gridW).toBe("3");
expect(active?.dataset.gridH).toBe("4");
});
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { getLogicalTrackSize } from "~/components/board/layout";
import { createGridResizeOutlineController } from "../grid-resize-outline";
describe("grid resize outline", () => {
@@ -28,8 +30,8 @@ describe("grid resize outline", () => {
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
expect(outline?.style.left).toBe("0px");
expect(outline?.style.top).toBe("0px");
expect(outline?.style.width).toBe("200px");
expect(outline?.style.height).toBe("424px");
expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
expect(outline?.dataset.gridResizeValid).toBe("false");
controller.schedule(null);
@@ -11,6 +11,18 @@
border-color: var(--border-color) !important;
}
.containerCard {
@mixin dark {
--container-default-border-color: var(--mantine-color-dark-4);
}
@mixin light {
--container-default-border-color: var(--mantine-color-gray-3);
}
--border-color: rgb(
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
);
}
.itemCard:global(.iframe-wrapper) {
background-color: transparent !important;
border-color: transparent !important;
@@ -26,8 +38,8 @@
z-index: 9;
overflow: hidden;
color: var(--mantine-color-text);
background-color: var(--background-color);
border-color: var(--border-color);
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.containerToggle :global(.mantine-Button-inner) {
@@ -42,6 +54,24 @@
white-space: nowrap;
}
.containerLabel,
.containerAction {
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.containerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.containerLabel :global(.mantine-Badge-label) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.containerBody {
visibility: visible;
opacity: 1;
@@ -31,7 +31,7 @@ interface BoardSelectionContextValue {
copySelectedItems: () => Promise<void>;
pasteItems: () => Promise<void>;
removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string) => void;
moveSelectedItemsToSection: (sectionId: string, maxRowCount?: number | null) => void;
}
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
@@ -229,7 +229,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
const moveSelectedItemsToSection = useCallback(
(targetSectionId: string) => {
(targetSectionId: string, maxRowCount?: number | null) => {
if (selectedItemIds.size === 0) return;
const idsToMove = new Set(selectedItemIds);
@@ -248,7 +248,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
targetSection.kind === "container"
? (containerLayout?.width ?? 0)
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
const rowCount = containerLayout?.height ?? 9999;
const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
if (columnCount === 0 || rowCount === 0) return previous;
const existingContainers = previous.sections
@@ -25,6 +25,7 @@ import { useI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
import { useBoardSelection } from "./board-selection-context";
const MAX_AVATARS_DISPLAYED = 5;
@@ -40,6 +41,7 @@ export const BoardSelectionToolbar = () => {
moveSelectedItemsToSection,
} = useBoardSelection();
const board = useRequiredBoard();
const registeredGridEditors = useRegisteredGridEditors();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const t = useI18n();
@@ -96,7 +98,15 @@ export const BoardSelectionToolbar = () => {
.at(0);
const placementOptions = [
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
...(mainCanvasSection
? [
{
value: mainCanvasSection.id,
label: t("item.create.destination.mainCanvas"),
maxRowCount: null,
},
]
: []),
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
@@ -105,6 +115,7 @@ export const BoardSelectionToolbar = () => {
{
value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
maxRowCount: registeredGridEditors.get(section.id)?.placementMaxRowCount,
},
];
}),
@@ -113,6 +124,7 @@ export const BoardSelectionToolbar = () => {
.map((section) => ({
value: section.id,
label: section.options.title.trim() || t("section.container.untitled"),
maxRowCount: section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.height,
})),
];
@@ -244,7 +256,11 @@ export const BoardSelectionToolbar = () => {
<Menu.Dropdown>
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
{placementOptions.map((opt) => (
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
<Menu.Item
key={opt.value}
disabled={opt.maxRowCount === undefined}
onClick={() => moveSelectedItemsToSection(opt.value, opt.maxRowCount)}
>
{opt.label}
</Menu.Item>
))}