From aa179f1ec0d5ffbe41d8439a33f6c47fdcb807ac Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 28 Aug 2026 17:11:51 +0200 Subject: [PATCH 1/8] fix: decouple responsive layout roles from breakpoints * fix: rework responsive layout breakpoints * fix: keep widget edit overlays visible * fix: inset widget edit action * fix: use valid widget inset token --- apps/docs/docs/management/api/index.mdx | 2 +- apps/docs/docs/management/boards/index.mdx | 18 ++-- .../docs/widgets/speedtest-tracker/index.mdx | 2 +- .../boards/[name]/settings/_layout.tsx | 19 ++-- .../board/items/item-content.module.css | 5 +- .../items/widget-hover-overlay.module.css | 6 +- .../layout/scaled-board-canvas.module.css | 3 +- .../board/sections/grid/grid-editor.css | 2 +- packages/api/src/router/board.ts | 3 - packages/db/migrations/seed.ts | 4 +- packages/definitions/src/board.ts | 23 +++-- packages/translation/src/lang/en.json | 8 +- packages/validation/src/board.ts | 4 - .../src/speedtest-tracker/component.tsx | 98 ++----------------- .../widgets/src/speedtest-tracker/index.ts | 1 - 15 files changed, 56 insertions(+), 142 deletions(-) diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 67fa8119f..3165867d4 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -113,7 +113,7 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Set desktop and mobile home boards for the current user - Configure global board defaults such as public home boards and status behavior -Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. +Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 3fa95d2d4..5c3a20c9f 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -133,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom. The complete canvas always fits the available width and never adds horizontal board scrolling. - **Mobile** starts at `0px` and uses three columns on new boards. -- **Base** is the widest layout and starts at `768px` on new boards. +- **Base** is the main layout and starts at `768px` on new boards. Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed -rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between -their breakpoints. +rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any +unused breakpoint above Mobile. Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in -Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a -free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails. +Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an +available breakpoint, and you can then change the breakpoint, columns, and rails. Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile -layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain -custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions. +layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest +becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them. +Duplicate breakpoints are normalized without discarding saved positions. Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept. @@ -182,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200` #### Breakpoint The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at -`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint. +`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the +eligible layout with the highest breakpoint, regardless of its role. When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent fallback to select a layout. diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx index 2912946e5..95cf296cb 100644 --- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx +++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx @@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance, including the latest result, historical averages, and a live chart of the last 12 hours. -Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected. +The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected. When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown. diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index fa72af452..bf8cfc00f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -40,6 +40,8 @@ interface Props { saveSettingsAsync: () => Promise; } +const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const; + export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { const tBoard = useI18n("board"); const tLayout = useI18n("layout"); @@ -101,6 +103,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); + const displayedLayouts = form.values.layouts + .map((layout, index) => ({ layout, index })) + .toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]); return ( @@ -134,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync - {form.values.layouts.map((layout, index) => { + {displayedLayouts.map(({ layout, index }) => { const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); - const customBreakpoints = form.values.layouts - .filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id) - .map((candidate) => candidate.breakpoint); - const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768; return (
breakpoint + 1)) - : 1 - } - max={layout.role === "custom" ? baseBreakpoint - 1 : 32767} + min={layout.role === "mobile" ? 0 : 1} + max={32767} /> diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css index c00ac34d4..3e30cd129 100644 --- a/apps/nextjs/src/components/board/items/item-content.module.css +++ b/apps/nextjs/src/components/board/items/item-content.module.css @@ -1,7 +1,7 @@ .settingsButton { position: absolute; - top: -34px; - right: 14px; + top: calc(var(--mantine-spacing-sm) / 2); + right: calc(var(--mantine-spacing-sm) / 2); z-index: 201; opacity: 0; pointer-events: none; @@ -52,7 +52,6 @@ @media (max-width: 768px) { .settingsButton { - right: 4px; opacity: 1; pointer-events: auto; } diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css index 25a8708f9..5d8e95f59 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css @@ -49,12 +49,14 @@ } .widgetIcon { + width: var(--mantine-font-size-sm); + height: var(--mantine-font-size-sm); flex-shrink: 0; color: var(--mantine-color-dimmed); } .name { - font-size: 11px; + font-size: var(--mantine-font-size-xs); font-weight: 600; line-height: 1.2; white-space: nowrap; @@ -94,7 +96,7 @@ padding: 2px 6px; border-radius: var(--mantine-radius-md); background-color: var(--chip-background); - font-size: 10px; + font-size: var(--mantine-font-size-xxs); line-height: 1.2; white-space: nowrap; overflow: hidden; diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css index a072d8c74..49f71bf7b 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css @@ -2,7 +2,8 @@ width: 100%; max-width: 100%; min-width: 0; - overflow: clip; + overflow-x: clip; + overflow-y: visible; } .viewport[data-board-hydrated="false"] { diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 53079451f..0de7580b3 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -13,7 +13,7 @@ .board-grid-entry:has([data-board-widget-header]):hover, .board-grid-entry:has([data-board-widget-header]):focus-within { - z-index: 10; + z-index: var(--homarr-z-index-widget-focus); } .board-grid-entry[data-grid-preview="true"] { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index d2568b5f6..93526dc72 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -2776,14 +2776,11 @@ export const boardLayoutsNeedRepair = ( ) => { const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile"); const baseLayouts = boardLayouts.filter((layout) => layout.role === "base"); - const baseLayout = baseLayouts.at(0); return ( mobileLayouts.length !== 1 || baseLayouts.length !== 1 || mobileLayouts.at(0)?.breakpoint !== 0 || - !baseLayout || - boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) || new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length ); }; diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 3e4cdebf2..e06769cda 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str } } - const mobileLayout = normalizedLayouts[0]; - const baseLayout = normalizedLayouts.at(-1); + const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile"); + const baseLayout = normalizedLayouts.find((layout) => layout.role === "base"); if (mobileLayout && baseLayout) { await insertMissingProjectedPositionsAsync( db, diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts index 3bd5ffdd9..625248a95 100644 --- a/packages/definitions/src/board.ts +++ b/packages/definitions/src/board.ts @@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = < const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base"); const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined; const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined; - const orderedLayouts = - mobileLayout && baseLayout - ? [ - mobileLayout, - ...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts), - baseLayout, - ] - : sourceLayouts.toSorted(compareBoardLayouts); + const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined; + let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts); + if (hasProtectedLayouts) { + orderedLayouts = [ + mobileLayout, + ...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts), + ]; + } let previousBreakpoint = -1; return orderedLayouts.map((layout, index) => { @@ -49,7 +49,12 @@ export const normalizeBoardLayoutRoles = < index === 0 ? 0 : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint)); - const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom"; + let role: LayoutRole = layout.role ?? "custom"; + if (!hasProtectedLayouts) { + if (index === 0) role = "mobile"; + else if (index === orderedLayouts.length - 1) role = "base"; + else role = "custom"; + } previousBreakpoint = breakpoint; return { ...layout, breakpoint, role }; }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 4dc3b1783..5ef1cee93 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4672,12 +4672,6 @@ "noSectionsEnabled": "Enable at least one section in widget options.", "error": { "internalServerError": "Failed to fetch speedtest data" - }, - "range": { - "twelveHours": "12h", - "oneDay": "24h", - "all": "All", - "lastThirty": "Last 30" } }, "uptimeKuma": { @@ -5424,7 +5418,7 @@ "title": "Layout", "responsive": { "title": "Responsive layouts", - "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.", + "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.", "action": { "add": "Add layout" } diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 97120bbba..2d6c982dd 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -116,10 +116,6 @@ export const responsiveBoardLayoutsSchema = z ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" }); } - if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) { - ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" }); - } - if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) { ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" }); } diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx index 8ccae86e4..efb2b4376 100644 --- a/packages/widgets/src/speedtest-tracker/component.tsx +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; -import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useMemo } from "react"; +import { Box, Group, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; @@ -21,14 +21,10 @@ export default function SpeedtestTrackerWidget({ options, integrationIds, width, - height, - displayMode = "compact", }: WidgetComponentProps<"speedtestTracker">) { const t = useI18n("widget.speedtestTracker"); const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds }); const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData; - const [advancedRange, setAdvancedRange] = useState("24"); - const isAdvanced = displayMode === "advanced"; const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]); const combined = useMemo( @@ -36,98 +32,24 @@ export default function SpeedtestTrackerWidget({ [successfulDashboards], ); - const rangeHours = isAdvanced ? Number(advancedRange) : 12; - const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0; + const cutoff = Date.now() - 12 * 60 * 60 * 1000; const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff); - const showLatestResult = isAdvanced || options.showLatestResult; - const showStats = isAdvanced || options.showStats; - const showRecentResults = isAdvanced || options.showRecentResults; - const showPingGraph = isAdvanced || options.showPingGraph; + const showLatestResult = options.showLatestResult; + const showStats = options.showStats; + const showRecentResults = options.showRecentResults; + const showPingGraph = options.showPingGraph; const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null); const hasChart = showRecentResults && recentFiltered.length > 0; - const noSectionsEnabled = - !isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults; - const advancedSectionWidth = width >= 720 ? width / 2 : width; + const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults; const latest = showLatestResult && combined.latestResult && ( - + ); const averages = showStats && combined.stats && ( - + ); - if (displayMode === "advanced") { - const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1; - const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32); - const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth; - - return ( - - - - - - - - {successfulDashboards.length === 0 ? ( - - ) : ( - - {successfulDashboards.map(({ integrationId, integrationName, dashboard }) => { - const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff); - const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null; - const sourceHasChart = sourceRecent.length > 0; - - return ( - - - - {integrationName} - - {sourceHasStats && ( - = 720 ? 2 : 1} spacing="md"> - {dashboard.latestResult && ( - - )} - {dashboard.stats && } - - )} - {sourceHasChart && ( - - - - )} - {!sourceHasStats && !sourceHasChart && } - - - ); - })} - - )} - - - - ); - } - return ( diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts index 81ddd8f3f..b61f5488d 100644 --- a/packages/widgets/src/speedtest-tracker/index.ts +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -5,7 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { icon: IconSpeedboat, - supportsAdvancedFocus: true, refetchInterval: null, createOptions() { return optionsBuilder.from( From 7855278ed19fdfa2a7feab55df6291d22ce61751 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Sat, 29 Aug 2026 09:26:15 +1000 Subject: [PATCH 2/8] fix(board): give AppShell root a viewport-height floor (#6736) * fix(board): give AppShell root a viewport-height floor when a background is set The board canvas sizes itself to its content, and AppShell runs in 'static' mode so
doesn't force a 100dvh minimum either. That means a board's background image only covers as much height as the content needs - collapsing a container (or any board with short content) leaves flat page background showing below it instead of the background continuing to the bottom of the viewport, looking like a big empty void. Root-caused via a local repro: the grid/reflow system was already shrinking the collapsed container's own space correctly (verified the live-measured canvas height matched exactly, not the stale debug attribute) - the void was purely a background-sizing issue on the AppShell root, unrelated to the collapse/layout logic itself. * docs(boards): document background minimum-height behavior --- apps/docs/docs/management/boards/index.mdx | 2 ++ apps/nextjs/src/components/layout/shell.tsx | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 5c3a20c9f..89f061f1e 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -273,6 +273,8 @@ Expanding it smoothly restores those bounds and moves following items out of the ### Background +When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed). + #### Background image URL Homarr can display a background image behind apps and widgets. diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index 558ba0c69..bf86ca341 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -27,6 +27,12 @@ export const ClientShell = ({ return ( doesn't force a 100dvh minimum - see AppShell.css). Without this, a board + // background image only covers as much height as the content needs, so collapsing a + // container short enough leaves flat page background showing below it instead of the + // background continuing to the bottom of the viewport. + mih={backgroundProps.bg ? "100dvh" : undefined} header={hasHeader ? { height: headerHeight } : undefined} navbar={ hasNavigation From 10ba298233f2daf08089b2c84aac5aeec6aee16f Mon Sep 17 00:00:00 2001 From: Bergasha Date: Sat, 29 Aug 2026 11:02:10 +1000 Subject: [PATCH 3/8] fix(board): beszel & calendar scale fix (#6737) * fix(calendar): add cell spacing and scale day content with widget size * fix(beszel): stop chart panels from leaving blank grid columns and size charts to fit the widget * fix(widgets): address CodeRabbit review on calendar/beszel scaling PR - beszel: account for the SimpleGrid row gap and the Stack gap between the controls row and the grid in the available-height budget, so a two-row layout no longer overflows the widget. - calendar: memoize the day-cell scale lookup instead of recomputing it on every render. - calendar: don't render the notification indicator row when none of a day's events have an indicatorColor, which was shifting the date number up with no visible dot to show for it. Skipped one finding: the claim that .day's absolute inset:0 covers monthCell's padding. It doesn't - the containing block for an absolutely positioned element is the padding edge of its positioned ancestor per the CSS spec, so the padding correctly insets .day. This was also visually verified in the browser while building the fix. * fix(beszel): don't treat availableHeight=0 as omitted in computeChartHeight A falsy check on availableHeight also caught the legitimate value 0 (widget shorter than its own padding+controls), falling back to the full 180px height instead of clamping to MIN_CHART_HEIGHT - the exact overflow this height budget was meant to prevent. --- .../src/beszel-system-stats/component.tsx | 9 ++++ .../widgets/src/beszel/_shared/stats-view.tsx | 45 +++++++++++++++---- .../widgets/src/calendar/calender-day.tsx | 31 ++++++++++--- packages/widgets/src/calendar/component.tsx | 1 + 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 0aca4348f..2a209e85e 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -23,11 +23,16 @@ import { getUsableWidgetQueryData } from "../common/query-state"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; +const CONTROLS_ROW_HEIGHT = 36; +const CONTROLS_ROW_GAP = 16; +const STACK_PADDING = 24; + export default function BeszelSystemStatsWidget({ options, integrationIds, isEditMode, width, + height, boardId, itemId, setOptions, @@ -256,6 +261,10 @@ export default function BeszelSystemStatsWidget({ systemId={selectedSystem?.systemId ?? ""} timePeriod={options.timePeriod as BeszelTimePeriod} columns={width > 600 ? 2 : 1} + availableHeight={Math.max( + 0, + height - STACK_PADDING - (isEditMode ? 0 : CONTROLS_ROW_HEIGHT + CONTROLS_ROW_GAP), + )} visibility={{ cpu: options.showCpu, memory: options.showMemory, diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx index 66a24f38f..ef67f40ee 100644 --- a/packages/widgets/src/beszel/_shared/stats-view.tsx +++ b/packages/widgets/src/beszel/_shared/stats-view.tsx @@ -62,9 +62,20 @@ interface BeszelStatsViewProps { timePeriod: BeszelTimePeriod; visibility: BeszelStatsVisibility; columns: 1 | 2; + availableHeight?: number; onSwitchToHistorical?: () => void; } +const MIN_CHART_HEIGHT = 100; +const PANEL_ROW_OVERHEAD = 28; +const GRID_ROW_GAP = 16; + +const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => { + if (availableHeight === undefined || rowCount <= 0) return CHART_HEIGHT; + const perRow = (availableHeight - (rowCount - 1) * GRID_ROW_GAP) / rowCount - PANEL_ROW_OVERHEAD; + return Math.max(MIN_CHART_HEIGHT, Math.min(CHART_HEIGHT, Math.floor(perRow))); +}; + const whenVisible = (visible: boolean, data: T | undefined) => (visible ? data : undefined); export function BeszelStatsView({ @@ -73,6 +84,7 @@ export function BeszelStatsView({ timePeriod, visibility, columns, + availableHeight, onSwitchToHistorical, }: BeszelStatsViewProps) { const t = useI18n("widget.beszelSystemStats"); @@ -248,14 +260,29 @@ export function BeszelStatsView({ ); } + const renderedPanelCount = + (visibility.cpu && cpuData.length > 0 ? 1 : 0) + + (visibility.memory && memoryData.length > 0 ? 1 : 0) + + (visibility.disk && diskData.length > 0 ? 1 : 0) + + (visibility.diskIO && diskIOData.length > 0 ? 1 : 0) + + (visibility.network && networkData.length > 0 ? 1 : 0) + + (showDocker && containerSeries.length > 0 + ? (visibility.dockerCpu && dockerCpuData.length > 0 ? 1 : 0) + + (visibility.dockerMemory && dockerMemoryData.length > 0 ? 1 : 0) + + (visibility.dockerNetwork && dockerNetworkData.length > 0 ? 1 : 0) + : 0); + const effectiveColumns = Math.min(columns, Math.max(1, renderedPanelCount)) as 1 | 2; + const rowCount = Math.ceil(renderedPanelCount / effectiveColumns); + const chartHeight = computeChartHeight(availableHeight, rowCount); + return ( - + {visibility.cpu && cpuData.length > 0 && ( + dayScaleTiers.find((tier) => minAxisSize >= tier.minAxisSize) ?? fallbackDayScale; + export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => { const board = useRequiredBoard(); const mantineTheme = useMantineTheme(); const actualItemRadius = mantineTheme.radius[board.itemRadius]; const minAxisSize = Math.min(rootWidth, rootHeight); - const shouldScaleDown = minAxisSize < 350; const shouldShowIndicators = rootHeight >= 256; - const indicatorSize = shouldScaleDown ? 3 : 4; + const { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]); const cell = ( - + {date.getDate()} - + ); @@ -70,19 +86,20 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C interface NotificationIndicatorProps { events: CalendarEvent[]; size: number; + marginTop: number; visible: boolean; } -const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => { +const NotificationIndicator = ({ events, size, marginTop, visible }: NotificationIndicatorProps) => { const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter( (color): color is string => Boolean(color), ); - if (!visible) return null; + if (!visible || notificationEvents.length === 0) return null; return ( Date: Sat, 29 Aug 2026 06:55:31 +0200 Subject: [PATCH 4/8] fix(widgets): use readable onboarding sizes (#6739) --- .../docs/widgets/media-request-list/index.mdx | 5 ++ .../widgets/media-request-stats/index.mdx | 5 ++ packages/definitions/src/widget.ts | 8 ++- packages/translation/src/lang/en.json | 10 +++- .../src/media-requests/empty-state.tsx | 49 +++++++++++++++++++ .../src/media-requests/list/component.tsx | 14 +++++- .../src/media-requests/stats/component.tsx | 14 ++++-- 7 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 packages/widgets/src/media-requests/empty-state.tsx diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 2ed31ea88..9a4599a3d 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem"; import { mediaRequestListWidget } from "."; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { overseerrIntegration } from "@site/docs/integrations/overseerr"; +import { seerrIntegration } from "@site/docs/integrations/seerr"; @@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png"; { integration: overseerrIntegration, }, + { + integration: seerrIntegration, + }, ]} /> @@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png"; - Approve / decline a request by clicking on the thumbs up or down icon. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. +- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error. - A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations. diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx index 7376bb0e4..7a06bf365 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem"; import { mediaRequestStatsWidget } from "."; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { overseerrIntegration } from "@site/docs/integrations/overseerr"; +import { seerrIntegration } from "@site/docs/integrations/seerr"; @@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval { integration: overseerrIntegration, }, + { + integration: seerrIntegration, + }, ]} /> @@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png"; ### Interactions - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. +- When no requests exist yet, the widget offers media search and explains when statistics will appear. - A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible. diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 5279df2e0..f8df10772 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -63,13 +63,19 @@ export type WidgetKind = (typeof widgetKinds)[number]; export const widgetDefaultSizes: Partial> = { airQuality: { width: 2, height: 1 }, + calendar: { width: 2, height: 2 }, countdown: { width: 2, height: 1 }, + downloads: { width: 4, height: 2 }, + indexerManager: { width: 2, height: 3 }, timer: { width: 2, height: 1 }, uptimeKuma: { width: 2, height: 3 }, audioStats: { width: 2, height: 2 }, paperlessNgx: { width: 2, height: 2 }, patchmon: { width: 2, height: 2 }, - mediaMissing: { width: 4, height: 3 }, + mediaServer: { width: 3, height: 2 }, + "mediaRequests-requestList": { width: 3, height: 2 }, + "mediaRequests-requestStats": { width: 2, height: 2 }, + mediaMissing: { width: 2, height: 2 }, bazarr: { width: 2, height: 2 }, assistant: { width: 10, height: 4 }, }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5ef1cee93..04dabe9f7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4227,7 +4227,11 @@ }, "mediaRequests-requestList": { "name": "Media Requests List", - "description": "See a list of all media requests from your Overseerr or Jellyseerr instance", + "description": "See a list of all media requests from your Seerr, Overseerr, or Jellyseerr instance", + "empty": { + "title": "No media requests yet", + "description": "Search for a movie or show to start a request." + }, "option": { "linksTargetNewTab": { "label": "Open links in new tab" @@ -4267,6 +4271,10 @@ "mediaRequests-requestStats": { "name": "Media Requests Stats", "description": "Statistics about your media requests", + "empty": { + "title": "No request activity yet", + "description": "Stats will appear after the first media request." + }, "titles": { "stats": { "main": "Media Stats", diff --git a/packages/widgets/src/media-requests/empty-state.tsx b/packages/widgets/src/media-requests/empty-state.tsx new file mode 100644 index 000000000..20752a18b --- /dev/null +++ b/packages/widgets/src/media-requests/empty-state.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button, Stack, Text, ThemeIcon } from "@mantine/core"; +import { IconMovieOff, IconSearch } from "@tabler/icons-react"; + +import { openMediaRequestSearch } from "@homarr/spotlight"; +import { useI18n } from "@homarr/translation/client"; + +interface MediaRequestsEmptyStateProps { + title: string; + description: string; + integrationIds: string[]; + isEditMode: boolean; +} + +export const MediaRequestsEmptyState = ({ + title, + description, + integrationIds, + isEditMode, +}: MediaRequestsEmptyStateProps) => { + const tSearch = useI18n("search.mode.media"); + + return ( + + + + + + + {title} + + + {description} + + + {!isEditMode ? ( + + ) : null} + + ); +}; diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 617b332be..59d2e14be 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -20,7 +20,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import type { WidgetComponentProps } from "../../definition"; -import { NoIntegrationDataError } from "../../errors/no-data-integration"; +import { MediaRequestsEmptyState } from "../empty-state"; import classes from "./component.module.css"; import searchClasses from "../search-button.module.css"; @@ -30,6 +30,7 @@ export default function MediaServerWidget({ options, width, }: WidgetComponentProps<"mediaRequests-requestList">) { + const t = useI18n("widget.mediaRequests-requestList"); const interactIntegrationIds = new Set( useIntegrationsWithInteractAccess() .filter(({ id }) => integrationIds.includes(id)) @@ -48,7 +49,16 @@ export default function MediaServerWidget({ if (isInitialWidgetQueryPending(mediaRequestQuery)) return ; if (!mediaRequestData) return ; const { requests: mediaRequests, failedIntegrations } = mediaRequestData; - if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError(); + if (mediaRequests.length === 0 && failedIntegrations.length === 0) { + return ( + + ); + } const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1; return ( diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 92aaac4c6..074014264 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -28,7 +28,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import type { WidgetComponentProps } from "../../definition"; -import { NoIntegrationDataError } from "../../errors/no-data-integration"; +import { MediaRequestsEmptyState } from "../empty-state"; import classes from "./component.module.css"; import searchClasses from "../search-button.module.css"; @@ -54,8 +54,16 @@ export default function MediaServerWidget({ requestStats.users.length === 0 && requestStats.stats.length === 0 && requestStats.failedIntegrations.length === 0 - ) - throw new NoIntegrationDataError(); + ) { + return ( + + ); + } const data = [ { From e58c2296643dfc78c80d28d1e8afff7737a64fbd Mon Sep 17 00:00:00 2001 From: Bergasha Date: Sat, 29 Aug 2026 14:56:02 +1000 Subject: [PATCH 5/8] feat(header): add opt-in auto-hide-on-scroll for the header (#6738) * feat(header): add opt-in auto-hide-on-scroll for the header New per-user header preference (default off): when enabled, the header slides away while scrolling down and reappears on scroll-up or near the top of the page. Disabled while in board edit mode, and skipped entirely while focus is inside the header. Only applies when the header is set to visible - the existing hover-to-reveal floating controls already cover the hidden-header case. * chore(api): regenerate the checked-in OpenAPI schema check:openapi was failing in CI - the schema on disk hadn't been regenerated after the header preferences gained autoHideOnScroll. Add scripts/generate-openapi-schema.mts (and a generate:openapi package script) alongside the existing check script so this is a one-command fix next time the schema drifts. * docs(users): document the header studio's visibility and auto-hide options Header customization (drag-to-arrange controls, hide/show, hover-reveal and pin, auto-hide-on-scroll) had no documentation on the user general settings page. --- apps/docs/docs/management/users/index.mdx | 11 +++++ apps/docs/static/api/open-api-schema.json | 4 ++ .../general/_components/header-composer.tsx | 12 ++++- .../header/configurable-header.module.css | 6 +++ .../layout/header/configurable-header.tsx | 7 +++ .../layout/header/use-header-auto-hide.ts | 48 +++++++++++++++++++ package.json | 1 + packages/boards/src/edit-mode.tsx | 5 ++ packages/translation/src/lang/en.json | 4 +- packages/validation/src/header-preferences.ts | 3 ++ scripts/generate-openapi-schema.mts | 8 ++++ 11 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 apps/nextjs/src/components/layout/header/use-header-auto-hide.ts create mode 100644 scripts/generate-openapi-schema.mts diff --git a/apps/docs/docs/management/users/index.mdx b/apps/docs/docs/management/users/index.mdx index 9c32ba56f..655308000 100644 --- a/apps/docs/docs/management/users/index.mdx +++ b/apps/docs/docs/management/users/index.mdx @@ -77,6 +77,17 @@ Choose the first day of the week for the user. This will affect the calendar wid Option to show icons for status check instead of colored dots. +#### Header studio + +Personal control over what appears in the header and where. Drag controls between the Left, Center, and Right +sections, add optional board shortcuts, and choose how the search and logo controls are displayed. + +The header can be hidden entirely with the visibility toggle. While hidden, hovering the top edge of the page +reveals it temporarily; clicking inside it pins it open until the page is refreshed or you click elsewhere. + +When the header is visible, the **Auto-hide on scroll** toggle makes it slide away while scrolling down and +reappear on scroll-up or near the top of the page. It has no effect while the header itself is hidden. + #### Delete This action will permanently delete the user from the database, including any preferences they set. diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 5791847a3..7ce94b623 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -4391,6 +4391,10 @@ "type": "string", "enum": ["logo", "logoAndText"] }, + "autoHideOnScroll": { + "default": false, + "type": "boolean" + }, "zones": { "type": "object", "properties": { diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx index 318e17f69..f85bc5acf 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx @@ -18,6 +18,7 @@ import { Paper, SegmentedControl, Stack, + Switch, Text, ThemeIcon, Tooltip, @@ -202,7 +203,16 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderC {t("description")} - + + + onChange({ ...value, autoHideOnScroll: event.currentTarget.checked })} + /> + new Map(boards.map((board) => [board.id, board])), [boards]); + const isEditMode = useOptionalEditMode(); + const isAutoHidden = useHeaderAutoHide( + headerPreferences.visible && headerPreferences.autoHideOnScroll && !isEditMode, + ); return ( @@ -114,6 +120,7 @@ export const ConfigurableHeader = ({ zIndex="var(--homarr-z-index-board-header)" className={classes.header} data-visible={headerPreferences.visible} + data-auto-hidden={isAutoHidden || undefined} data-advanced-focus-background data-app-shell-header > diff --git a/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts b/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts new file mode 100644 index 000000000..759de931d --- /dev/null +++ b/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const SCROLL_HIDE_THRESHOLD = 24; +const SCROLL_DIRECTION_HYSTERESIS = 6; + +export const useHeaderAutoHide = (enabled: boolean) => { + const [isHidden, setIsHidden] = useState(false); + const lastScrollYRef = useRef(0); + + useEffect(() => { + if (!enabled) { + setIsHidden(false); + return; + } + + lastScrollYRef.current = window.scrollY; + let frame: number | null = null; + + const handleScroll = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + const currentY = window.scrollY; + const delta = currentY - lastScrollYRef.current; + + if (currentY <= SCROLL_HIDE_THRESHOLD) { + setIsHidden(false); + } else if (delta > SCROLL_DIRECTION_HYSTERESIS) { + setIsHidden(true); + } else if (delta < -SCROLL_DIRECTION_HYSTERESIS) { + setIsHidden(false); + } + + lastScrollYRef.current = currentY; + }); + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + window.removeEventListener("scroll", handleScroll); + if (frame !== null) window.cancelAnimationFrame(frame); + }; + }, [enabled]); + + return enabled && isHidden; +}; diff --git a/package.json b/package.json index 477724057..22aeb6b30 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture", "check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs", "check:openapi": "tsx ./scripts/check-openapi-schema.mts", + "generate:openapi": "tsx ./scripts/generate-openapi-schema.mts", "ci:act:all": "bash scripts/ci/act.sh all", "ci:act:cleanup": "bash scripts/ci/act.sh cleanup", "ci:act:docker": "bash scripts/ci/act.sh docker", diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 58288ce2a..7503f872f 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -26,3 +26,8 @@ export const useEditMode = () => { return context; }; + +export const useOptionalEditMode = (): boolean => { + const context = useContext(EditModeContext); + return context ? context[0] : false; +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 04dabe9f7..ccf13fd16 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5725,7 +5725,9 @@ "show": "Show the header", "configure": "Move or configure {item}", "remove": "Remove {item}", - "dropIntoZone": "Move into the {zone} section" + "dropIntoZone": "Move into the {zone} section", + "autoHideOnScroll": "Auto-hide on scroll", + "autoHideOnScrollDescription": "Slides the header away while scrolling down and brings it back when scrolling up" }, "preview": { "board": "Dashboard preview for {name}", diff --git a/packages/validation/src/header-preferences.ts b/packages/validation/src/header-preferences.ts index 1235809b5..3c2896a59 100644 --- a/packages/validation/src/header-preferences.ts +++ b/packages/validation/src/header-preferences.ts @@ -105,6 +105,7 @@ export const headerPreferencesSchema = z visible: z.boolean(), searchDisplay: z.enum(headerSearchDisplayValues), logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"), + autoHideOnScroll: z.boolean().default(false), zones: headerZonesSchema, }) .strict(); @@ -155,6 +156,7 @@ const migrateLegacyItems = ( visible, searchDisplay: "input", logoDisplay: "logoAndText", + autoHideOnScroll: false, zones: { left: zones.left.map(createBuiltinHeaderItem), center: zones.center.map(createBuiltinHeaderItem), @@ -209,6 +211,7 @@ export const defaultHeaderPreferences = { visible: true, searchDisplay: "input", logoDisplay: "logoAndText", + autoHideOnScroll: false, zones: { left: [createBuiltinHeaderItem("logo")], center: [createBuiltinHeaderItem("search")], diff --git a/scripts/generate-openapi-schema.mts b/scripts/generate-openapi-schema.mts new file mode 100644 index 000000000..4ab47e67a --- /dev/null +++ b/scripts/generate-openapi-schema.mts @@ -0,0 +1,8 @@ +import { writeFile } from "node:fs/promises"; + +import { openApiDocument } from "../packages/api/src/open-api"; + +const baseUrl = "http://localhost:3000"; +const generated = openApiDocument(baseUrl); + +await writeFile("apps/docs/static/api/open-api-schema.json", `${JSON.stringify(generated, null, 2)}\n`, "utf8"); From ea250d418d8a7ce0aa151b417d2da6d2a057d4b8 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Sat, 29 Aug 2026 15:19:20 +1000 Subject: [PATCH 6/8] Revert "feat(header): add opt-in auto-hide-on-scroll for the header (#6738)" (#6740) This reverts commit e58c2296643dfc78c80d28d1e8afff7737a64fbd. --- apps/docs/docs/management/users/index.mdx | 11 ----- apps/docs/static/api/open-api-schema.json | 4 -- .../general/_components/header-composer.tsx | 12 +---- .../header/configurable-header.module.css | 6 --- .../layout/header/configurable-header.tsx | 7 --- .../layout/header/use-header-auto-hide.ts | 48 ------------------- package.json | 1 - packages/boards/src/edit-mode.tsx | 5 -- packages/translation/src/lang/en.json | 4 +- packages/validation/src/header-preferences.ts | 3 -- scripts/generate-openapi-schema.mts | 8 ---- 11 files changed, 2 insertions(+), 107 deletions(-) delete mode 100644 apps/nextjs/src/components/layout/header/use-header-auto-hide.ts delete mode 100644 scripts/generate-openapi-schema.mts diff --git a/apps/docs/docs/management/users/index.mdx b/apps/docs/docs/management/users/index.mdx index 655308000..9c32ba56f 100644 --- a/apps/docs/docs/management/users/index.mdx +++ b/apps/docs/docs/management/users/index.mdx @@ -77,17 +77,6 @@ Choose the first day of the week for the user. This will affect the calendar wid Option to show icons for status check instead of colored dots. -#### Header studio - -Personal control over what appears in the header and where. Drag controls between the Left, Center, and Right -sections, add optional board shortcuts, and choose how the search and logo controls are displayed. - -The header can be hidden entirely with the visibility toggle. While hidden, hovering the top edge of the page -reveals it temporarily; clicking inside it pins it open until the page is refreshed or you click elsewhere. - -When the header is visible, the **Auto-hide on scroll** toggle makes it slide away while scrolling down and -reappear on scroll-up or near the top of the page. It has no effect while the header itself is hidden. - #### Delete This action will permanently delete the user from the database, including any preferences they set. diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 7ce94b623..5791847a3 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -4391,10 +4391,6 @@ "type": "string", "enum": ["logo", "logoAndText"] }, - "autoHideOnScroll": { - "default": false, - "type": "boolean" - }, "zones": { "type": "object", "properties": { diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx index f85bc5acf..318e17f69 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx @@ -18,7 +18,6 @@ import { Paper, SegmentedControl, Stack, - Switch, Text, ThemeIcon, Tooltip, @@ -203,16 +202,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderC {t("description")} - - - onChange({ ...value, autoHideOnScroll: event.currentTarget.checked })} - /> - + new Map(boards.map((board) => [board.id, board])), [boards]); - const isEditMode = useOptionalEditMode(); - const isAutoHidden = useHeaderAutoHide( - headerPreferences.visible && headerPreferences.autoHideOnScroll && !isEditMode, - ); return ( @@ -120,7 +114,6 @@ export const ConfigurableHeader = ({ zIndex="var(--homarr-z-index-board-header)" className={classes.header} data-visible={headerPreferences.visible} - data-auto-hidden={isAutoHidden || undefined} data-advanced-focus-background data-app-shell-header > diff --git a/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts b/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts deleted file mode 100644 index 759de931d..000000000 --- a/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; - -const SCROLL_HIDE_THRESHOLD = 24; -const SCROLL_DIRECTION_HYSTERESIS = 6; - -export const useHeaderAutoHide = (enabled: boolean) => { - const [isHidden, setIsHidden] = useState(false); - const lastScrollYRef = useRef(0); - - useEffect(() => { - if (!enabled) { - setIsHidden(false); - return; - } - - lastScrollYRef.current = window.scrollY; - let frame: number | null = null; - - const handleScroll = () => { - if (frame !== null) return; - frame = window.requestAnimationFrame(() => { - frame = null; - const currentY = window.scrollY; - const delta = currentY - lastScrollYRef.current; - - if (currentY <= SCROLL_HIDE_THRESHOLD) { - setIsHidden(false); - } else if (delta > SCROLL_DIRECTION_HYSTERESIS) { - setIsHidden(true); - } else if (delta < -SCROLL_DIRECTION_HYSTERESIS) { - setIsHidden(false); - } - - lastScrollYRef.current = currentY; - }); - }; - - window.addEventListener("scroll", handleScroll, { passive: true }); - return () => { - window.removeEventListener("scroll", handleScroll); - if (frame !== null) window.cancelAnimationFrame(frame); - }; - }, [enabled]); - - return enabled && isHidden; -}; diff --git a/package.json b/package.json index 22aeb6b30..477724057 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture", "check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs", "check:openapi": "tsx ./scripts/check-openapi-schema.mts", - "generate:openapi": "tsx ./scripts/generate-openapi-schema.mts", "ci:act:all": "bash scripts/ci/act.sh all", "ci:act:cleanup": "bash scripts/ci/act.sh cleanup", "ci:act:docker": "bash scripts/ci/act.sh docker", diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 7503f872f..58288ce2a 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -26,8 +26,3 @@ export const useEditMode = () => { return context; }; - -export const useOptionalEditMode = (): boolean => { - const context = useContext(EditModeContext); - return context ? context[0] : false; -}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ccf13fd16..04dabe9f7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5725,9 +5725,7 @@ "show": "Show the header", "configure": "Move or configure {item}", "remove": "Remove {item}", - "dropIntoZone": "Move into the {zone} section", - "autoHideOnScroll": "Auto-hide on scroll", - "autoHideOnScrollDescription": "Slides the header away while scrolling down and brings it back when scrolling up" + "dropIntoZone": "Move into the {zone} section" }, "preview": { "board": "Dashboard preview for {name}", diff --git a/packages/validation/src/header-preferences.ts b/packages/validation/src/header-preferences.ts index 3c2896a59..1235809b5 100644 --- a/packages/validation/src/header-preferences.ts +++ b/packages/validation/src/header-preferences.ts @@ -105,7 +105,6 @@ export const headerPreferencesSchema = z visible: z.boolean(), searchDisplay: z.enum(headerSearchDisplayValues), logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"), - autoHideOnScroll: z.boolean().default(false), zones: headerZonesSchema, }) .strict(); @@ -156,7 +155,6 @@ const migrateLegacyItems = ( visible, searchDisplay: "input", logoDisplay: "logoAndText", - autoHideOnScroll: false, zones: { left: zones.left.map(createBuiltinHeaderItem), center: zones.center.map(createBuiltinHeaderItem), @@ -211,7 +209,6 @@ export const defaultHeaderPreferences = { visible: true, searchDisplay: "input", logoDisplay: "logoAndText", - autoHideOnScroll: false, zones: { left: [createBuiltinHeaderItem("logo")], center: [createBuiltinHeaderItem("search")], diff --git a/scripts/generate-openapi-schema.mts b/scripts/generate-openapi-schema.mts deleted file mode 100644 index 4ab47e67a..000000000 --- a/scripts/generate-openapi-schema.mts +++ /dev/null @@ -1,8 +0,0 @@ -import { writeFile } from "node:fs/promises"; - -import { openApiDocument } from "../packages/api/src/open-api"; - -const baseUrl = "http://localhost:3000"; -const generated = openApiDocument(baseUrl); - -await writeFile("apps/docs/static/api/open-api-schema.json", `${JSON.stringify(generated, null, 2)}\n`, "utf8"); From 8d0b13b30984a56bf03323de78b9bc3b925a1826 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 29 Aug 2026 09:12:16 +0200 Subject: [PATCH 7/8] fix(board): correct widget scaling and edit layering (#6741) --- .../items/actions/test/create-item.spec.ts | 6 +- .../board/sections/grid/grid-editor.css | 2 +- packages/api/src/router/test/onboard.spec.ts | 2 +- .../beszel-system-stats/component.module.css | 9 +- .../src/beszel-system-stats/component.tsx | 139 +++++++++--------- packages/widgets/src/beszel/_shared/chart.tsx | 6 +- .../widgets/src/beszel/_shared/stats-view.tsx | 13 +- .../src/uptime-kuma/component.module.css | 34 ++--- .../widgets/src/uptime-kuma/component.tsx | 7 +- 9 files changed, 114 insertions(+), 104 deletions(-) diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index 3b052d35c..e38dd0ccc 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -21,7 +21,7 @@ describe("item actions create-item", () => { .build(); const baseLayout = board.layouts.find((layout) => layout.role === "base"); - const result = createItemCallback({ kind: "mediaMissing" })(board); + const result = createItemCallback({ kind: "downloads" })(board); const createdItem = result.items.at(0); expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3); @@ -165,7 +165,7 @@ describe("item actions create-item", () => { const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board); expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([ - expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }), + expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }), ]); }); @@ -262,7 +262,7 @@ describe("item actions create-item", () => { layoutId: layout.id, sectionId: "canvas", width: 1, - height: 3, + height: 2, xOffset: 0, yOffset: 1, }), diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 0de7580b3..4b7e47054 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -13,7 +13,7 @@ .board-grid-entry:has([data-board-widget-header]):hover, .board-grid-entry:has([data-board-widget-header]):focus-within { - z-index: var(--homarr-z-index-widget-focus); + z-index: calc(var(--homarr-z-index-board-header) - 1); } .board-grid-entry[data-grid-preview="true"] { diff --git a/packages/api/src/router/test/onboard.spec.ts b/packages/api/src/router/test/onboard.spec.ts index 4b765792a..5e31efef3 100644 --- a/packages/api/src/router/test/onboard.spec.ts +++ b/packages/api/src/router/test/onboard.spec.ts @@ -280,7 +280,7 @@ describe("onboard.completeSetup", () => { expect(mediaMissing).toBeDefined(); expect( await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }), - ).toMatchObject({ width: 4, height: 3 }); + ).toMatchObject({ width: 2, height: 2 }); }); it("requires an exact board id when more than one board exists", async () => { diff --git a/packages/widgets/src/beszel-system-stats/component.module.css b/packages/widgets/src/beszel-system-stats/component.module.css index f9e9b584b..d41f57deb 100644 --- a/packages/widgets/src/beszel-system-stats/component.module.css +++ b/packages/widgets/src/beszel-system-stats/component.module.css @@ -1,11 +1,16 @@ .beszelStatsControls { + position: absolute; + z-index: 2; + top: var(--mantine-spacing-sm); + right: var(--mantine-spacing-sm); + left: var(--mantine-spacing-sm); opacity: 0; transition: opacity 150ms ease-out; pointer-events: none; } -.beszelStatsContainer:hover .beszelStatsControls, -.beszelStatsContainer:focus-within .beszelStatsControls { +.beszelStatsRoot:hover .beszelStatsControls, +.beszelStatsRoot:focus-within .beszelStatsControls { opacity: 1; pointer-events: auto; } diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 2a209e85e..eb24774eb 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -23,9 +23,8 @@ import { getUsableWidgetQueryData } from "../common/query-state"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; -const CONTROLS_ROW_HEIGHT = 36; -const CONTROLS_ROW_GAP = 16; const STACK_PADDING = 24; +const SCROLL_FIT_BUFFER = 2; export default function BeszelSystemStatsWidget({ options, @@ -33,6 +32,7 @@ export default function BeszelSystemStatsWidget({ isEditMode, width, height, + displayScale = 1, boardId, itemId, setOptions, @@ -71,6 +71,12 @@ export default function BeszelSystemStatsWidget({ { value: "1w", label: t("period.oneWeek") }, { value: "30d", label: t("period.thirtyDays") }, ]; + let responsiveWidth = width; + let responsiveHeight = height; + if (Number.isFinite(displayScale) && displayScale > 0) { + responsiveWidth *= displayScale; + responsiveHeight *= displayScale; + } useWidgetRuntimeQueries( widgetRuntimeRef, selectedSystem && options.timePeriod !== "1m" @@ -185,86 +191,83 @@ export default function BeszelSystemStatsWidget({ } return ( - + + {!isEditMode && ( + + {systems.length > 1 && ( + + + + + + {systems.map((system) => ( + handleSelectSystem(system.value)} + > + {system.label} + + ))} + + + )} + {responsiveWidth >= 560 ? ( + + {periodOptions.map((period) => ( + + ))} + + ) : ( + value && handleTimePeriod(value)} - disabled={isSelectionSavePending} - data={periodOptions} - className={classes.beszelStatsPeriodToggle} - /> - )} - - )} + 600 ? 2 : 1} - availableHeight={Math.max( - 0, - height - STACK_PADDING - (isEditMode ? 0 : CONTROLS_ROW_HEIGHT + CONTROLS_ROW_GAP), - )} + columns={responsiveWidth > 600 ? 2 : 1} + availableHeight={Math.max(0, responsiveHeight - STACK_PADDING - SCROLL_FIT_BUFFER)} visibility={{ cpu: options.showCpu, memory: options.showMemory, diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index 7a9949b6d..89dacb535 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -101,12 +101,12 @@ interface BeszelChartPanelProps { export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => ( - - + + {title} {subtitle && ( - + {subtitle} )} diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx index ef67f40ee..91996d266 100644 --- a/packages/widgets/src/beszel/_shared/stats-view.tsx +++ b/packages/widgets/src/beszel/_shared/stats-view.tsx @@ -35,13 +35,13 @@ const tooltips = { bytesTotal: makeTooltipProps(formatStorageBytes, true), }; -const ChartSkeleton = () => ( +const ChartSkeleton = ({ height }: { height: number }) => ( - + ); @@ -67,7 +67,7 @@ interface BeszelStatsViewProps { } const MIN_CHART_HEIGHT = 100; -const PANEL_ROW_OVERHEAD = 28; +const PANEL_ROW_OVERHEAD = 25; const GRID_ROW_GAP = 16; const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => { @@ -238,10 +238,13 @@ export function BeszelStatsView({ if (!activeStats) { const visibleChartCount = Object.values(visibility).filter(Boolean).length; + const effectiveColumns = Math.min(columns, Math.max(1, visibleChartCount)) as 1 | 2; + const rowCount = Math.ceil(visibleChartCount / effectiveColumns); + const chartHeight = computeChartHeight(availableHeight, rowCount); return ( - + {Array.from({ length: visibleChartCount }, (_, index) => ( - + ))} ); diff --git a/packages/widgets/src/uptime-kuma/component.module.css b/packages/widgets/src/uptime-kuma/component.module.css index f56a12bda..57fb16aed 100644 --- a/packages/widgets/src/uptime-kuma/component.module.css +++ b/packages/widgets/src/uptime-kuma/component.module.css @@ -4,8 +4,8 @@ flex-direction: column; height: 100%; min-height: 0; - padding: clamp(6px, 3cqi, 14px); - gap: clamp(4px, 2cqi, 10px); + padding: clamp(6px, 3cqmin, 14px); + gap: clamp(4px, 2cqmin, 10px); overflow: hidden; } @@ -13,11 +13,10 @@ display: flex; align-items: center; justify-content: space-between; - gap: clamp(8px, 3cqi, 18px); + gap: clamp(8px, 3cqmin, 18px); flex-shrink: 0; - padding-bottom: clamp(4px, 2cqi, 10px); - border-bottom: 1px solid - rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)); + padding-bottom: clamp(4px, 2cqmin, 10px); + border-bottom: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)); } .heroExpanded { @@ -37,25 +36,25 @@ } .heroTextOnly .heroValue { - font-size: clamp(22px, 10cqi, 36px); + font-size: clamp(22px, 10cqmin, 36px); } .heroText { display: flex; flex-direction: column; - gap: clamp(2px, 1cqi, 4px); + gap: clamp(2px, 1cqmin, 4px); min-width: 0; flex: 1; } .heroLabel { - font-size: clamp(9px, 2.8cqi, 13px); + font-size: clamp(9px, 2.8cqmin, 13px); color: var(--mantine-color-dimmed); line-height: 1.2; } .heroValue { - font-size: clamp(18px, 8cqi, 32px); + font-size: clamp(18px, 8cqmin, 32px); font-weight: 700; line-height: 1.1; } @@ -68,7 +67,7 @@ display: grid; flex: 1; min-height: 0; - gap: clamp(4px, 2cqi, 10px); + gap: clamp(4px, 2cqmin, 10px); grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr)); grid-auto-rows: 1fr; align-content: stretch; @@ -81,11 +80,10 @@ justify-content: center; min-height: 0; min-width: 0; - gap: clamp(2px, 1.5cqi, 5px); - padding: clamp(4px, 2.5cqi, 10px); + gap: clamp(2px, 1.5cqmin, 5px); + padding: clamp(4px, 2.5cqmin, 10px); border-radius: var(--mantine-radius-md); - border: 1px solid - rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)); + border: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)); background-color: transparent; overflow: hidden; } @@ -95,14 +93,14 @@ } .statValue { - font-size: clamp(14px, 5.5cqi, 24px); + font-size: clamp(14px, 5.5cqmin, 24px); font-weight: 700; line-height: 1.1; text-align: center; } .statLabel { - font-size: clamp(8px, 2.5cqi, 12px); + font-size: clamp(8px, 2.5cqmin, 12px); color: var(--mantine-color-dimmed); line-height: 1.2; text-align: center; @@ -119,6 +117,6 @@ justify-content: center; min-height: 0; color: var(--mantine-color-dimmed); - font-size: clamp(11px, 3cqi, 14px); + font-size: clamp(11px, 3cqmin, 14px); text-align: center; } diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 908d8ed52..6108fec8f 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -129,8 +129,9 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode ); const uptimeValue = clampPercent(combined.averageUptimePercent); const uptimeColor = getUptimeColor(uptimeValue); - const ringSize = getRingSize(width); - const iconSize = getIconSize(width); + const visualScale = Math.min(width, height); + const ringSize = getRingSize(visualScale); + const iconSize = getIconSize(visualScale); const gridCols = getGridCols(width); const isAdvanced = displayMode === "advanced"; @@ -247,7 +248,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode - {summaryContent} + {summaryContent} {monitorList} From 7d1d9062ed722bb519d970b056d2114b75f22cd3 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Sat, 29 Aug 2026 17:14:53 +1000 Subject: [PATCH 8/8] fix(calendar): keep date numbers aligned regardless of indicator dots (#6742) --- packages/widgets/src/calendar/calender-day.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widgets/src/calendar/calender-day.tsx b/packages/widgets/src/calendar/calender-day.tsx index feafbbdc6..44174bc78 100644 --- a/packages/widgets/src/calendar/calender-day.tsx +++ b/packages/widgets/src/calendar/calender-day.tsx @@ -95,7 +95,7 @@ const NotificationIndicator = ({ events, size, marginTop, visible }: Notificatio (color): color is string => Boolean(color), ); - if (!visible || notificationEvents.length === 0) return null; + if (!visible) return null; return (