+
+
+
+
+
+
diff --git a/src/modules/grid/gridLayout.js b/src/modules/grid/gridLayout.js
new file mode 100644
index 0000000000..51608cd5af
--- /dev/null
+++ b/src/modules/grid/gridLayout.js
@@ -0,0 +1,123 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/** Number of columns of a grid view. */
+export const GRID_COLUMNS = 12
+
+/** Smallest number of columns a widget spans. */
+export const GRID_MIN_WIDTH = 2
+
+/** Height of one grid row. In pixels on purpose: GridStack takes a number, not a CSS variable. */
+export const GRID_CELL_HEIGHT = 80
+
+/** Space between widgets in pixels: two grid baselines of 4px. */
+export const GRID_MARGIN = 8
+
+/** Below this canvas width in pixels the grid shows one column. */
+export const GRID_BREAKPOINT_SINGLE = 560
+
+/** Below this canvas width in pixels the grid shows six columns. */
+export const GRID_BREAKPOINT_MEDIUM = 960
+
+/**
+ * Deep copy of plain data. A JSON round-trip also copies reactive proxies, which
+ * structuredClone refuses.
+ *
+ * @param {object|Array|string|number|boolean|null|undefined} value plain data
+ * @return {object|Array|string|number|boolean|null} the copy
+ */
+function copy(value) {
+ return JSON.parse(JSON.stringify(value ?? null))
+}
+
+/**
+ * @param {Array} widgetTypes the schemas loaded from the server
+ * @param {string} type widget type
+ * @return {object|null} the schema of that type, or null when the server does not know it
+ */
+export function findWidgetType(widgetTypes, type) {
+ return widgetTypes.find(widgetType => widgetType.type === type) ?? null
+}
+
+/**
+ * The configuration a new widget of this type starts with: every property at its default.
+ *
+ * @param {object} widgetType a schema from the server
+ * @return {object} the configuration
+ */
+export function defaultConfiguration(widgetType) {
+ return Object.fromEntries(Object.entries(widgetType?.configuration ?? {}).map(([name, property]) => [name, copy(property.default)]))
+}
+
+/**
+ * The content a new widget of this type starts with: every property at its default.
+ *
+ * @param {object} widgetType a schema from the server
+ * @return {object} the content
+ */
+export function defaultContent(widgetType) {
+ return Object.fromEntries(Object.entries(widgetType?.properties ?? {}).map(([name, property]) => [name, copy(property.default)]))
+}
+
+/**
+ * An empty grid, the value of a view that was never designed.
+ *
+ * @return {{widgets: Array, layout: Array}} the grid
+ */
+export function emptyGrid() {
+ return { widgets: [], layout: [] }
+}
+
+/**
+ * Deep copy of a grid so edits never touch the stored view until saved.
+ *
+ * @param {object} grid grid as stored on the view
+ * @return {{widgets: Array, layout: Array}} the copy
+ */
+export function cloneGrid(grid) {
+ return {
+ widgets: copy(grid?.widgets ?? []),
+ layout: copy(grid?.layout ?? []),
+ }
+}
+
+/**
+ * The row below the lowest widget, where a new widget is placed.
+ *
+ * @param {Array} layout layout items
+ * @return {number} the row
+ */
+export function nextFreeRow(layout) {
+ return layout.reduce((max, item) => Math.max(max, (item.gridY ?? 0) + (item.gridHeight ?? 1)), 0)
+}
+
+/**
+ * How many columns the grid shows on a canvas of this width: 12 on a wide one, 6 on a
+ * medium one and one on a phone.
+ *
+ * @param {number} width width of the canvas in pixels
+ * @return {number} the number of columns
+ */
+export function columnsForWidth(width) {
+ if (width < GRID_BREAKPOINT_SINGLE) {
+ return 1
+ }
+ return width < GRID_BREAKPOINT_MEDIUM ? 6 : GRID_COLUMNS
+}
+
+/**
+ * The layout with the positions of the GridStack nodes that changed applied to it.
+ *
+ * @param {Array} layout layout items
+ * @param {Array<{id: string, x: number, y: number, w: number, h: number}>} nodes the changed nodes
+ * @return {Array} the updated layout
+ */
+export function applyNodePositions(layout, nodes) {
+ const positions = Object.fromEntries(nodes.map(node => [node.id, node]))
+ return layout.map(item => {
+ const node = positions[item.widgetId]
+ return node ? { ...item, gridX: node.x, gridY: node.y, gridWidth: node.w, gridHeight: node.h } : item
+ })
+}
diff --git a/src/modules/grid/widgetRegistry.js b/src/modules/grid/widgetRegistry.js
new file mode 100644
index 0000000000..49bf755b94
--- /dev/null
+++ b/src/modules/grid/widgetRegistry.js
@@ -0,0 +1,28 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import HeaderWidget from './widgets/HeaderWidget.vue'
+import TextWidget from './widgets/TextWidget.vue'
+
+export const WIDGET_TYPE_HEADER = 'header'
+export const WIDGET_TYPE_TEXT = 'text'
+export const WIDGET_TYPE_DATA = 'data'
+
+/**
+ * The component that renders each widget type. Everything else about a type,
+ * its title, default size and the schema of its content, comes from the server
+ * (see GridWidgetTypes.php) so the form and the validation share one definition.
+ */
+const widgetComponents = {
+ [WIDGET_TYPE_HEADER]: HeaderWidget,
+ [WIDGET_TYPE_TEXT]: TextWidget,
+}
+
+/**
+ * @param {string} type widget type
+ * @return {object|null} the component that renders it
+ */
+export function getWidgetComponent(type) {
+ return widgetComponents[type] ?? null
+}
diff --git a/src/modules/grid/widgets/HeaderWidget.vue b/src/modules/grid/widgets/HeaderWidget.vue
new file mode 100644
index 0000000000..3caf3babd3
--- /dev/null
+++ b/src/modules/grid/widgets/HeaderWidget.vue
@@ -0,0 +1,73 @@
+
+
+