diff --git a/apps/nextjs/src/components/board/sections/container-section.tsx b/apps/nextjs/src/components/board/sections/container-section.tsx
index dff3d3f7c..5c936aed2 100644
--- a/apps/nextjs/src/components/board/sections/container-section.tsx
+++ b/apps/nextjs/src/components/board/sections/container-section.tsx
@@ -11,6 +11,7 @@ import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { COLLAPSED_SECTION_ROW_COUNT } from "~/components/board/layout";
import { SectionGrid } from "./grid/section-grid";
import { useSectionCollapse } from "./section-collapse";
+import { useSectionContext } from "./section-context";
import { useOpenSectionApps } from "./use-open-section-apps";
import classes from "./item.module.css";
@@ -25,6 +26,7 @@ interface Props {
export const BoardContainerSection = ({ section }: Props) => {
const board = useRequiredBoard();
+ const { section: parentSection } = useSectionContext();
const [isEditMode] = useEditMode();
const t = useI18n("section.container");
const tSection = useI18n("section");
@@ -39,8 +41,26 @@ export const BoardContainerSection = ({ section }: Props) => {
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
- const labelLeft = 8;
- const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
+ let labelLeft = 8;
+ let labelRight = 8;
+ if (isEditMode) {
+ // Match the root/nested menu offsets in BoardContainerMenu, plus its width and gap.
+ labelLeft = 36;
+ if (parentSection.kind === "container") labelLeft = 68;
+ } else if (options.showOpenAll) {
+ labelRight = 40;
+ }
+ // Expanded controls sit on the border like the ordinary label, without reserving a header row.
+ const toggleLayout = isVisuallyCollapsed
+ ? { top: 0, left: 0, w: "100%", h: "100%", maw: "100%" }
+ : {
+ top: "calc(var(--mantine-spacing-xs) * -1)",
+ left: labelLeft,
+ w: "auto",
+ h: 20,
+ maw: `calc(100% - ${labelLeft + labelRight}px)`,
+ };
+ const toggleIcon = isVisuallyCollapsed ? : ;
return (
@@ -68,24 +88,14 @@ export const BoardContainerSection = ({ section }: Props) => {
- ) : (
-
- )
- ) : undefined
- }
+ leftSection={options.showLabel && toggleIcon}
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
@@ -94,13 +104,7 @@ export const BoardContainerSection = ({ section }: Props) => {
data-board-container-label
title={options.showLabel ? label : undefined}
>
- {options.showLabel ? (
- label
- ) : isVisuallyCollapsed ? (
-
- ) : (
-
- )}
+ {options.showLabel ? label : toggleIcon}
)}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
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 a50350c4c..14b711bd8 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
@@ -133,6 +133,21 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
}
+.board-grid-placeholder[data-grid-placeholder-type="section"] {
+ clip-path: none;
+ background: none;
+ border: 0;
+}
+
+.board-grid-placeholder[data-grid-placeholder-type="section"]::after {
+ position: absolute;
+ inset: var(--board-container-inset, 0px);
+ content: "";
+ background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
+ border: 2px dashed var(--mantine-primary-color-filled);
+ border-radius: inherit;
+}
+
.board-grid-resize-outline {
z-index: 1002;
clip-path: inset(var(--board-grid-item-inset, 0px));
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx b/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx
index 20881a97f..abcccb35e 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx
@@ -136,6 +136,7 @@ export const GridPreviewLayer = ({
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
+ data-grid-placeholder-type={targetPlacement.type}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.module.css b/apps/nextjs/src/components/board/sections/grid/section-grid.module.css
index b722c4d59..c750f0cf7 100644
--- a/apps/nextjs/src/components/board/sections/grid/section-grid.module.css
+++ b/apps/nextjs/src/components/board/sections/grid/section-grid.module.css
@@ -10,9 +10,9 @@
while growing during a drag happening anywhere else on the board - any container (not just
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
inside that section's DOM tree, so the stray value cascades straight through and silently
- defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
- its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
- unset, not merely inherited. Reset it here so every container can never inherit a stray
+ defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx, which only
+ kicks in when the property is entirely unset, not merely inherited. Reset it here so every
+ container can never inherit a stray
value from anywhere above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
.viewport[data-section-kind="container"] {
@@ -62,9 +62,9 @@
overflow: visible;
}
-/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
+/* SectionGrid fits its content to the same inset in both view and edit mode. */
.staticItem[data-type="section"] > .contentMount {
- inset: 0;
+ inset: var(--board-container-inset, 0px);
}
.staticItem[data-type="item"] > .contentMount {
diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
index b1a04a56d..93ccf371e 100644
--- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
-import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
@@ -29,6 +29,9 @@ import { useSectionItems } from "../use-section-items";
import { useBoardGridPortalHost } from "./grid-portal-host";
import classes from "./section-grid.module.css";
+const GridContentScaleContext = createContext(1);
+const CONTAINER_CARD_INSET = 5;
+
interface SectionGridProps {
section: Exclude | ContainerSectionItem;
columnCount: number;
@@ -50,6 +53,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
+ const parentContentScale = useContext(GridContentScaleContext);
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef(null);
@@ -143,36 +147,37 @@ export const SectionGrid = ({
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
- const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
- // The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
- // Convert that height so the label reserves the same space at every board scale.
- const collapsibleHeaderInset =
- section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
+ const parentScale = canvasScale * parentContentScale;
+ const effectiveCanvasScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1;
+ // Match the card's inset without changing its persisted grid footprint. Include ancestor
+ // container zoom so equally sized nested containers keep distinct borders at every depth.
+ let outerCardInset = 0;
+ if (section.kind === "container") {
+ outerCardInset = (2 * CONTAINER_CARD_INSET) / effectiveCanvasScale;
+ }
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
- const logicalWidth = fullGridWidth;
- const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
- // For a scrollable container, rowCount covers *all* content rows, not just the visible card -
- // fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
- // grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
- // viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
- // describe the real visible card size in both cases (they equal the non-scrollable values when
- // rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
- // A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
- // nested content uniformly scaled within the remaining height so labels and icons retain their
- // proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
- const containerContentScale =
- section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
- ? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
- : 1;
+ const logicalWidth = Math.max(1, fullGridWidth - outerCardInset);
+ const viewportHeight = Math.max(1, fullViewportHeight - outerCardInset);
+ // Fit square cells into the inset card. Scrollable grids use the visible row count,
+ // not their full content height. Collapse controls never change this geometry.
+ let containerContentScale = 1;
+ if (section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0) {
+ containerContentScale = Math.max(
+ 0.01,
+ Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1),
+ );
+ }
+ const contentScale = parentContentScale * containerContentScale;
+ const effectiveContentScale = effectiveCanvasScale * containerContentScale;
// Compute the combined value in JS rather than a CSS calc() referencing the existing
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
// sequential/temporal order the way normal variables do, so any calc() on this element that
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
- const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
+ const combinedUiScale = calculateBoardUiScale(canvasScale) / contentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -268,9 +273,9 @@ export const SectionGrid = ({
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
- marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
"--board-grid-content-scale": containerContentScale,
+ "--board-container-inset": `${CONTAINER_CARD_INSET / effectiveContentScale}px`,
} as CSSProperties
}
data-section-id={section.id}
@@ -289,14 +294,17 @@ export const SectionGrid = ({
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
- ...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
+ "--board-canvas-inverse-scale": 1 / effectiveContentScale,
+ "--board-canvas-ui-scale": combinedUiScale,
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
-
+
+
+
@@ -304,9 +312,6 @@ export const SectionGrid = ({
);
};
-// Matches the collapsible container toggle's h={24} in container-section.tsx.
-const CONTAINER_HEADER_HEIGHT = 24;
-
const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);