diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 00ca097b5..b60ab124a 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -19,35 +19,11 @@ align-self: start; box-sizing: border-box; height: var(--board-gutter-viewport-height); - padding-top: 24px; - margin-top: -24px; overflow: clip; } .gutter[data-board-editing="true"] { - outline: 1px dashed var(--mantine-color-default-border); - outline-offset: -1px; + outline: 0.0625rem solid var(--mantine-color-default-border); + outline-offset: -0.0625rem; border-radius: var(--mantine-radius-md); } - -.gutter[data-board-editing="true"]::before { - position: sticky; - top: 0; - inset-inline-start: 0; - z-index: 2; - display: block; - width: fit-content; - height: 24px; - padding-inline: var(--mantine-spacing-xs); - margin-top: -24px; - overflow: hidden; - font-size: var(--mantine-font-size-xs); - line-height: 24px; - color: var(--mantine-color-dimmed); - text-overflow: ellipsis; - white-space: nowrap; - pointer-events: none; - content: attr(aria-label); - background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); - border-radius: var(--mantine-radius-sm); -} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 7a0f7f40f..7c9ab7c4e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,5 +1,6 @@ "use client"; +import type { PropsWithChildren } from "react"; import { Box, Paper, Stack, Text } from "@mantine/core"; import { IconBulb } from "@tabler/icons-react"; @@ -22,6 +23,7 @@ import { import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas"; import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary"; +import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry"; import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host"; import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse"; import { BoardBackgroundVideo } from "~/components/layout/background"; @@ -31,6 +33,12 @@ import classes from "./_client.module.css"; const APP_SHELL_INLINE_PADDING = 32; +const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => ( + + {children} + +); + export const ClientBoard = () => { const board = useRequiredBoard(); const t = useI18n("board.landmark"); @@ -68,7 +76,7 @@ export const ClientBoard = () => { .join(" "); const content = ( - + @@ -144,7 +152,7 @@ export const ClientBoard = () => { - + ); if (representativeWidth === null) return content; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 1abba2121..7c73ffcc2 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -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) { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index bdeb8ed45..a8b8eea9b 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -2368,7 +2368,7 @@ const EmptyThread = () => { - + @@ -2957,7 +2957,6 @@ const Composer = (props: ComposerProps) => { ) : ( void; } -const AssistantPanelIdentity = () => { - const t = useI18n("assistant"); - const threadTitle = useAuiState((state) => state.threadListItem.title); - - return ( - - - - - - - {t("title")} - - - {threadTitle ?? t("newConversation")} - - - - ); -}; - export const AssistantConversationSurface = ({ isRunning, pendingAction, @@ -3212,23 +3190,20 @@ export const AssistantConversationSurface = ({ return ( - - + + {onExpand && ( diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx index f48888ccc..f82a52d39 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight.tsx +++ b/apps/nextjs/src/components/assistant/assistant-spotlight.tsx @@ -16,7 +16,7 @@ const AssistantPromptDetail = () => { const t = useI18n("assistant"); return ( - + diff --git a/apps/nextjs/src/components/assistant/assistant-widget.tsx b/apps/nextjs/src/components/assistant/assistant-widget.tsx index 1b7acee3e..1fee8e930 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget.tsx @@ -137,7 +137,7 @@ const CompactAssistantWidget = ({ - + @@ -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) => (
diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index cded56a68..c0beae1fd 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -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(({ 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, ): 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), }, ] : []; diff --git a/apps/nextjs/src/components/board/layout/README.md b/apps/nextjs/src/components/board/layout/README.md index 12ac371d0..6eb9c412f 100644 --- a/apps/nextjs/src/components/board/layout/README.md +++ b/apps/nextjs/src/components/board/layout/README.md @@ -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 diff --git a/apps/nextjs/src/components/board/layout/constants.ts b/apps/nextjs/src/components/board/layout/constants.ts index 26e3f0765..e4f309700 100644 --- a/apps/nextjs/src/components/board/layout/constants.ts +++ b/apps/nextjs/src/components/board/layout/constants.ts @@ -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; 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 feb2a8ac5..e0f3b6d65 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 @@ -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)); diff --git a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts index 83058fd89..373a5ec6d 100644 --- a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts @@ -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)); }); }); diff --git a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts index 3d46398bb..0db173bb6 100644 --- a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts @@ -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); }); }); diff --git a/apps/nextjs/src/components/board/sections/container-section.tsx b/apps/nextjs/src/components/board/sections/container-section.tsx index b18777dba..7e5284f1f 100644 --- a/apps/nextjs/src/components/board/sections/container-section.tsx +++ b/apps/nextjs/src/components/board/sections/container-section.tsx @@ -46,6 +46,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) => { + ); + })} + + ) : ( +