) => {
+ const resize = resizeRef.current;
+ const current = widgetRef.current;
+ if (!resize || resize.pointerId !== event.pointerId || !current) return;
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ endResize();
+ const block = current.blocks.find((item) => item.id === resize.blockId);
+ if (!block) return;
+ const layout = getLayout(block);
+ const { preview } = resize;
+ if (
+ layout.width === preview.width &&
+ layout.height === preview.height &&
+ layout.x === preview.x
+ ) {
+ return;
+ }
+ applyIfFits(current, placeBlock(current, resize.blockId, preview));
};
return {
gridRef,
draggingBlockId,
dropCell,
+ resizePreview,
+ isRowLimitHit,
pointerHandlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: endDrag },
- resizeBlock,
+ resizeHandlers: {
+ onPointerDown: onResizePointerDown,
+ onPointerMove: onResizePointerMove,
+ onPointerUp: onResizePointerUp,
+ onPointerCancel: endResize,
+ },
};
};
diff --git a/client/src/pages/widget-editor/model/useWidgetEditor.test.ts b/client/src/pages/widget-editor/model/useWidgetEditor.test.ts
index 36e4e8f..103cacd 100644
--- a/client/src/pages/widget-editor/model/useWidgetEditor.test.ts
+++ b/client/src/pages/widget-editor/model/useWidgetEditor.test.ts
@@ -19,14 +19,14 @@ const serverWidget: Widget = {
title: 'Widget',
slug: 'widget',
width: 600,
- height: 315,
+ height: 318,
public: false,
createdAt: '',
updatedAt: '',
config: {
palette: 'lavender',
paletteMode: 'light',
- grid: { columns: 2 },
+ grid: { columns: 4 },
renderFormat: 'iframe',
},
blocks: [
@@ -34,7 +34,7 @@ const serverWidget: Widget = {
id: 'block-1',
type: 'text',
position: 0,
- config: { text: 'Hello', layout: { x: 0, y: 0, width: 1, height: 1 } },
+ config: { text: 'Hello', layout: { x: 0, y: 0, width: 2, height: 2 } },
},
],
};
@@ -117,7 +117,7 @@ it('keeps edits made while a save is in flight', async () => {
it('restores unsaved edits from the local cache', async () => {
localStorage.setItem(
- 'widget-editor:v4:widget-1',
+ 'widget-editor:v5:widget-1',
JSON.stringify({ savedAt: 1, widget: { ...serverWidget, title: 'Unsaved' } }),
);
@@ -130,11 +130,14 @@ it('restores unsaved edits from the local cache', async () => {
it('refuses to add blocks past the limit', async () => {
api.getWidget.mockResolvedValue({
...serverWidget,
- blocks: Array.from({ length: 5 }, (_, index) => ({
+ blocks: Array.from({ length: 8 }, (_, index) => ({
...serverWidget.blocks[0],
id: `block-${index}`,
position: index,
- config: { text: 'x', layout: { x: 0, y: index, width: 1, height: 1 } },
+ config: {
+ text: 'x',
+ layout: { x: index % 4, y: Math.floor(index / 4), width: 1, height: 1 },
+ },
})),
});
const { result } = await renderEditor();
diff --git a/client/src/pages/widget-editor/ui/EditorBlock.tsx b/client/src/pages/widget-editor/ui/EditorBlock.tsx
index b5b4883..8389e2a 100644
--- a/client/src/pages/widget-editor/ui/EditorBlock.tsx
+++ b/client/src/pages/widget-editor/ui/EditorBlock.tsx
@@ -4,7 +4,6 @@ import type { PointerEvent, ReactNode } from 'react';
import type { WidgetBlock } from '@/entities/widget/model';
import type { BlockBox } from '@shared/widget/canvasParts';
-import { blockSizes, getLayout } from '@/pages/widget-editor/model/layout';
import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css';
export type BlockPointerHandlers = {
@@ -24,9 +23,11 @@ type EditorBlockProps = {
dragging: boolean;
removeLabel: string;
pointerHandlers: BlockPointerHandlers;
+ /** Corner handle: drag to resize, snapping to the block's allowed sizes. */
+ resizeHandlers: BlockPointerHandlers;
+ resizeLabel: string;
onSelect: () => void;
onRemove: () => void;
- onResize: (width: number, height: number) => void;
};
export const EditorBlock = ({
@@ -37,58 +38,49 @@ export const EditorBlock = ({
dragging,
removeLabel,
pointerHandlers,
+ resizeHandlers,
+ resizeLabel,
onSelect,
onRemove,
- onResize,
-}: EditorBlockProps) => {
- const layout = getLayout(block);
- return (
- (
+
+ pointerHandlers.onPointerDown(event, block.id)}
+ onPointerMove={pointerHandlers.onPointerMove}
+ onPointerUp={pointerHandlers.onPointerUp}
+ onPointerCancel={pointerHandlers.onPointerCancel}
>
- pointerHandlers.onPointerDown(event, block.id)}
- onPointerMove={pointerHandlers.onPointerMove}
- onPointerUp={pointerHandlers.onPointerUp}
- onPointerCancel={pointerHandlers.onPointerCancel}
- >
-
-
- event.stopPropagation()}>
- {blockSizes.map((size) => (
- onResize(size.width, size.height)}
- >
- {size.label}
-
- ))}
-
- {children}
- {
- event.stopPropagation();
- onRemove();
- }}
- aria-label={removeLabel}
- >
-
-
-
- );
-};
+
+
+ {children}
+ {
+ event.stopPropagation();
+ onRemove();
+ }}
+ aria-label={removeLabel}
+ >
+
+
+ event.stopPropagation()}
+ onPointerDown={(event) => resizeHandlers.onPointerDown(event, block.id)}
+ onPointerMove={resizeHandlers.onPointerMove}
+ onPointerUp={resizeHandlers.onPointerUp}
+ onPointerCancel={resizeHandlers.onPointerCancel}
+ />
+
+);
diff --git a/client/src/pages/widget-editor/ui/EditorCanvas.tsx b/client/src/pages/widget-editor/ui/EditorCanvas.tsx
index 41798d5..6d018f1 100644
--- a/client/src/pages/widget-editor/ui/EditorCanvas.tsx
+++ b/client/src/pages/widget-editor/ui/EditorCanvas.tsx
@@ -4,6 +4,7 @@ import { ScaledWidgetFrame, WidgetSurface } from '@/entities/widget';
import { paletteTokens, type RenderedBlock, type Widget } from '@/entities/widget/model';
import { WIDGET_WIDTH, blockBox, canvasPadding, widgetDimensions } from '@shared/widget/geometry';
import { MAX_COLUMNS, dragGridRows, occupiedRows } from '@/pages/widget-editor/model/layout';
+import type { ResizePreview } from '@/pages/widget-editor/model/useGridDrag';
import { EditorBlock, type BlockPointerHandlers } from '@/pages/widget-editor/ui/EditorBlock';
import { messages, type Locale } from '@/shared/locale/content';
import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css';
@@ -17,9 +18,10 @@ type EditorCanvasProps = {
draggingBlockId: string | null;
dropCell: { x: number; y: number } | null;
pointerHandlers: BlockPointerHandlers;
+ resizeHandlers: BlockPointerHandlers;
+ resizePreview: ResizePreview | null;
onSelectBlock: (blockId: string) => void;
onRemoveBlock: (blockId: string) => void;
- onResizeBlock: (blockId: string, width: number, height: number) => void;
};
const padding = canvasPadding(WIDGET_WIDTH);
@@ -34,23 +36,29 @@ export const EditorCanvas = ({
draggingBlockId,
dropCell,
pointerHandlers,
+ resizeHandlers,
+ resizePreview,
onSelectBlock,
onRemoveBlock,
- onResizeBlock,
}: EditorCanvasProps) => {
const t = messages[locale];
// While dragging, the grid shows one spare row below the blocks as a drop target.
const dropRows = dragGridRows(widget);
- const displayRows = draggingBlockId ? dropRows : occupiedRows(widget);
+ // While resizing, the canvas grows to show the previewed size.
+ const displayRows = draggingBlockId
+ ? dropRows
+ : Math.max(
+ occupiedRows(widget),
+ resizePreview ? resizePreview.layout.y + resizePreview.layout.height : 0,
+ );
const canvasHeight =
widget.blocks.length === 0
? widget.height
: widgetDimensions([{ x: 0, y: displayRows - 1, width: 1, height: 1 }]).height;
- const cell = (x: number, y: number) =>
- blockBox(
- { x, y, width: 1, height: 1 },
- { width: WIDGET_WIDTH, height: canvasHeight, rows: displayRows },
- );
+ const boxOf = (layout: { x: number; y: number; width: number; height: number }) =>
+ blockBox(layout, { width: WIDGET_WIDTH, height: canvasHeight, rows: displayRows });
+ const cell = (x: number, y: number) => boxOf({ x, y, width: 1, height: 1 });
+ const previewBox = resizePreview ? boxOf(resizePreview.layout) : null;
return (
onSelectBlock(block.id)}
onRemove={() => onRemoveBlock(block.id)}
- onResize={(width, height) => onResizeBlock(block.id, width, height)}
>
{content}
@@ -119,6 +128,17 @@ export const EditorCanvas = ({
/>
);
})}
+ {previewBox && (
+
+ )}
diff --git a/client/src/pages/widget-editor/ui/WidgetEditorPage.module.css b/client/src/pages/widget-editor/ui/WidgetEditorPage.module.css
index 48dbd97..ab53e07 100644
--- a/client/src/pages/widget-editor/ui/WidgetEditorPage.module.css
+++ b/client/src/pages/widget-editor/ui/WidgetEditorPage.module.css
@@ -259,7 +259,7 @@
.sortableBlock {
position: absolute;
z-index: 2;
- border-radius: 20px;
+ border-radius: 16px;
cursor: pointer;
transition: box-shadow 160ms ease;
}
@@ -273,46 +273,21 @@
.dropCell {
position: absolute;
border: 1px dashed color-mix(in srgb, var(--widget-accent) 25%, transparent);
- border-radius: 20px;
+ border-radius: 16px;
background: color-mix(in srgb, var(--widget-accent) 5%, transparent);
}
+.resizePreview {
+ position: absolute;
+ z-index: 3;
+ border: 2px solid var(--widget-accent);
+ border-radius: 16px;
+ background: color-mix(in srgb, var(--widget-accent) 10%, transparent);
+ pointer-events: none;
+}
.dropCellActive {
border-color: var(--widget-accent);
background: color-mix(in srgb, var(--widget-accent) 18%, transparent);
}
-.sizeControls {
- position: absolute;
- right: 10px;
- bottom: 10px;
- z-index: 4;
- display: flex;
- gap: 3px;
- padding: 3px;
- border: 1px solid var(--line);
- border-radius: 9px;
- background: color-mix(in srgb, var(--widget-surface) 88%, transparent);
- opacity: 0;
- transition: opacity 150ms ease;
-}
-.sizeButton,
-.sizeButtonActive {
- padding: 3px 5px;
- color: var(--muted);
- border: 0;
- border-radius: 5px;
- background: transparent;
- cursor: pointer;
- font-size: 9px;
- line-height: 1;
-}
-.sizeButtonActive {
- color: var(--widget-ink);
- background: color-mix(in srgb, var(--widget-accent) 20%, transparent);
-}
-.sortableBlock:hover .sizeControls,
-.sortableBlock:focus-within .sizeControls {
- opacity: 1;
-}
.draggingBlock {
opacity: 0.45;
}
@@ -374,6 +349,19 @@
.sortableBlock:focus-within .resizeHandle {
opacity: 1;
}
+.gridNotice {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 0 8px 8px;
+ padding: 10px 14px;
+ color: var(--text);
+ border-radius: 14px;
+ background: color-mix(in srgb, var(--action) 10%, transparent);
+ font-size: 13px;
+ line-height: 1.4;
+}
.inlineError {
margin: 0;
padding: 0 8px;
diff --git a/client/src/pages/widget-editor/ui/WidgetEditorPage.tsx b/client/src/pages/widget-editor/ui/WidgetEditorPage.tsx
index 6cac726..96fb71f 100644
--- a/client/src/pages/widget-editor/ui/WidgetEditorPage.tsx
+++ b/client/src/pages/widget-editor/ui/WidgetEditorPage.tsx
@@ -6,6 +6,7 @@ import type { Widget } from '@/entities/widget/model';
import { useBlockPreviews } from '@/pages/widget-editor/model/useBlockPreviews';
import { useGridDrag } from '@/pages/widget-editor/model/useGridDrag';
import { useWidgetEditor } from '@/pages/widget-editor/model/useWidgetEditor';
+import { MAX_ROWS, occupiedRows, packIntoRows } from '@/pages/widget-editor/model/layout';
import { BlockConfigPanel } from '@/pages/widget-editor/ui/BlockConfigPanel';
import { BlockLibrary } from '@/pages/widget-editor/ui/BlockLibrary';
import { EditorCanvas } from '@/pages/widget-editor/ui/EditorCanvas';
@@ -60,6 +61,15 @@ export const WidgetEditorPage = ({
});
const { widget, selectedBlock } = editor;
+ const [fitFailed, setFitFailed] = useState(false);
+ // Widgets migrated from the 2-column grid can be taller than the row limit.
+ const fitWidget = () => {
+ const current = editor.widgetRef.current;
+ const packed = current ? packIntoRows(current) : null;
+ setFitFailed(!packed);
+ if (packed) editor.updateLocalWidget(() => packed);
+ };
+
const leave = async () => {
if (editor.isDirtyRef.current) await editor.save();
onBack();
@@ -125,10 +135,24 @@ export const WidgetEditorPage = ({
draggingBlockId={drag.draggingBlockId}
dropCell={drag.dropCell}
pointerHandlers={drag.pointerHandlers}
+ resizeHandlers={drag.resizeHandlers}
+ resizePreview={drag.resizePreview}
onSelectBlock={editor.selectBlock}
onRemoveBlock={(blockId) => void editor.removeBlock(blockId)}
- onResizeBlock={drag.resizeBlock}
/>
+ {occupiedRows(widget) > MAX_ROWS && (
+
+ {fitFailed ? t.fitWidgetFailed : t.tallWidget}
+
+ {t.fitWidget}
+
+
+ )}
+ {drag.isRowLimitHit && (
+
+ {t.gridRowLimit}
+
+ )}
{editor.error && (
{editor.error}
diff --git a/client/src/shared/locale/content.ts b/client/src/shared/locale/content.ts
index 46b0236..86c0c33 100644
--- a/client/src/shared/locale/content.ts
+++ b/client/src/shared/locale/content.ts
@@ -114,8 +114,8 @@ export const messages = {
layout: 'Размер и позиция',
light: 'Светлая',
dark: 'Темная',
- maxBlocks: 'Максимум 5 блоков',
- blocksLimit: 'В виджете может быть не больше 5 блоков.',
+ maxBlocks: 'Максимум 8 блоков',
+ blocksLimit: 'В виджете может быть не больше 8 блоков.',
moveResize: 'Перетаскивайте блоки и меняйте их размер за угол.',
resize: 'Изменить размер блока',
saved: 'Сохранено',
@@ -133,6 +133,12 @@ export const messages = {
iframeCode: 'Iframe-код',
copied: 'Скопировано',
removeBlock: 'Удалить блок',
+ resizeBlock: 'Изменить размер блока',
+ gridRowLimit: 'Виджет может быть не выше 5 рядов — освободите место или уменьшите блок.',
+ tallWidget:
+ 'Виджет выше 5 рядов: так было в старой сетке. Уместите его, чтобы добавлять блоки.',
+ fitWidget: 'Уместить в 5 рядов',
+ fitWidgetFailed: 'Блоки не помещаются в 5 рядов — уменьшите или удалите часть из них.',
selectBlock: 'Выберите блок на canvas, чтобы настроить его.',
unsavedWarning: 'Есть несохранённые изменения. Покинуть редактор?',
loading: 'Загрузка…',
@@ -256,8 +262,8 @@ export const messages = {
layout: 'Size & position',
light: 'Light',
dark: 'Dark',
- maxBlocks: 'Maximum 5 blocks',
- blocksLimit: 'A widget can contain no more than 5 blocks.',
+ maxBlocks: 'Maximum 8 blocks',
+ blocksLimit: 'A widget can contain no more than 8 blocks.',
moveResize: 'Drag blocks and resize them from the corner.',
resize: 'Resize block',
saved: 'Saved',
@@ -275,6 +281,12 @@ export const messages = {
iframeCode: 'Iframe code',
copied: 'Copied',
removeBlock: 'Remove block',
+ resizeBlock: 'Resize block',
+ gridRowLimit: 'A widget can be at most 5 rows tall — make room or use a smaller size.',
+ tallWidget:
+ 'This widget is taller than 5 rows (from the old grid). Fit it to keep adding blocks.',
+ fitWidget: 'Fit into 5 rows',
+ fitWidgetFailed: "The blocks don't fit into 5 rows — shrink or remove some of them.",
selectBlock: 'Select a block on the canvas to configure it.',
unsavedWarning: 'There are unsaved changes. Leave the editor?',
loading: 'Loading…',
diff --git a/docs/design/grid-and-blocks.md b/docs/design/grid-and-blocks.md
new file mode 100644
index 0000000..dbf6e41
--- /dev/null
+++ b/docs/design/grid-and-blocks.md
@@ -0,0 +1,66 @@
+# Widget grid and blocks
+
+Design reference for how widgets are laid out and how blocks adapt to their size.
+
+## Grid
+
+| | |
+| ---------------------- | -------------------------------- |
+| Widget width | 600 px (fixed) |
+| Columns | 4 |
+| Rows | up to 5 (editable limit) |
+| Cell | 129 × 129 px |
+| Gap | 12 px |
+| Canvas padding | 24 px |
+| Block padding / radius | 12 px / 16 px |
+| Sizes | 4×4 = 600 × 600, 4×5 = 600 × 741 |
+
+Content width of a block by column span (cell − 2 × (padding + border)): 1 → 103 px, 2 → 244 px,
+3 → 385 px, 4 → 526 px. Height works the same way per row span.
+
+The numbers live in `server/src/shared/widget/geometry.ts`. Widgets created on the old 2-column grid
+were migrated by doubling every layout (one old cell = 2 × 2 new cells); those that end up taller
+than 5 rows still render, can be edited without growing, and the editor offers "Fit into 5 rows".
+
+## Block sizes and variants
+
+A block declares the sizes it supports (`server/src/shared/widget/blockSizes.ts`); the editor's
+corner resize snaps to them and the server rejects anything else. Each supported size has its own
+layout ("variant") instead of scaling one design. Not every block needs every size.
+
+Variant families by size (`blockVariant` in `blockSizes.ts`):
+
+| Family | Sizes | Idea |
+| ------- | ------------- | ---------------------------------------------- |
+| `tiny` | 1×1 | one headline number or symbol |
+| `strip` | 2×1, 3×1 | title + 2–3 facts in one line |
+| `wide` | 4×1 | title + a full row of facts or a compact chart |
+| `card` | 2×2, 3×2, 2×4 | the classic block |
+| `large` | 4×2, 4×3, 4×4 | roomy layout; blocks without one use `card` |
+
+Implemented layouts (renderers in `server/src/shared/widget/blocks/`):
+
+| Block | 1×1 | 2×1 | 4×1 | 2×2 | 4×2 |
+| -------------- | ------------------------------------------------------------------------------ | --------------------------------- | ------------------------------------- | ---------------------- | -------------------------------- |
+| text | text fitted to the box, centred vertically, in every size (also 3×1, 3×2, 4×3) | | | | |
+| github-stats | avatar + followers | avatar, name, @user, summary line | identity left, 3 stats right | card | big avatar, name, bio, stats row |
+| github-langs | top language % + mix bar | bar + 2-language legend | bar + 4-language legend | card | card |
+| github-commits | commits + 🔥 streak | commits + current streak | caption + compact heatmap (~48 weeks) | card (19-week heatmap) | card (40-week heatmap) |
+| github-prs | merged % + breakdown bar | total / merged / open | + closed, breakdown bar | card | card |
+| github-status | emoji (+ busy dot) | emoji + 2-line message | emoji + 1-line message | card | card |
+| leetcode-stats | solved + difficulty bar | solved, ranking + difficulty bar | + contest rating | card | stats + one bar per difficulty |
+
+Every block also keeps the doubled legacy sizes (2×2, 2×4, 4×2, 4×4) so migrated widgets stay
+valid. Defaults: new blocks are 2×2 (text 2×1); when that doesn't fit, the next smaller allowed
+size that does is used (`placementSizes`).
+
+## Block limit and plan tiers
+
+| Plan | Blocks per widget |
+| ----------------- | ----------------- |
+| Free | 8 |
+| Paid subscription | 16 |
+| Special accounts | 20 |
+
+Only the free limit is implemented (`MAX_WIDGET_BLOCKS` = 8 on the server, `MAX_BLOCKS` in the
+editor). Paid and special tiers need billing and an account plan field first.
diff --git a/server/prisma/migrations/20261003145347_grid_4_columns/migration.sql b/server/prisma/migrations/20261003145347_grid_4_columns/migration.sql
new file mode 100644
index 0000000..691e460
--- /dev/null
+++ b/server/prisma/migrations/20261003145347_grid_4_columns/migration.sql
@@ -0,0 +1,34 @@
+-- Data-only migration: the widget grid goes from 2 to 4 columns (cells half the size).
+-- Each old cell is exactly 2×2 new cells, so doubling every layout keeps widgets looking the same.
+
+-- 1. Double block layouts (blocks saved before layouts existed are stacked by position).
+UPDATE "Block"
+SET "config" = jsonb_set(
+ "config",
+ '{layout}',
+ jsonb_build_object(
+ 'x', COALESCE(("config"->'layout'->>'x')::int, 0) * 2,
+ 'y', COALESCE(("config"->'layout'->>'y')::int, "position") * 2,
+ 'width', COALESCE(("config"->'layout'->>'width')::int, 1) * 2,
+ 'height', COALESCE(("config"->'layout'->>'height')::int, 1) * 2
+ )
+);
+
+-- 2. Every widget uses the 4-column grid.
+UPDATE "Widget"
+SET "config" = jsonb_set(COALESCE("config", '{}'::jsonb), '{grid}', '{"columns": 4}'::jsonb);
+
+-- 3. Recompute stored heights: 129px cells, 12px gaps, 24px padding (width 600), capped at 1200.
+UPDATE "Widget" AS w
+SET "height" = LEAST(1200, rows.count * 129 + (rows.count - 1) * 12 + 48)
+FROM (
+ SELECT "widgetId",
+ MAX(("config"->'layout'->>'y')::int + ("config"->'layout'->>'height')::int) AS count
+ FROM "Block"
+ GROUP BY "widgetId"
+) AS rows
+WHERE rows."widgetId" = w."id";
+
+UPDATE "Widget"
+SET "height" = 129 + 48
+WHERE NOT EXISTS (SELECT 1 FROM "Block" WHERE "Block"."widgetId" = "Widget"."id");
diff --git a/server/src/controllers/widgetController.ts b/server/src/controllers/widgetController.ts
index 465a179..8689fc5 100644
--- a/server/src/controllers/widgetController.ts
+++ b/server/src/controllers/widgetController.ts
@@ -15,6 +15,7 @@ import {
presetDefinitions,
widgetConfigSchema,
MAX_GRID_COLUMNS,
+ MAX_WIDGET_BLOCKS,
} from '@server/widgets/registry.js';
const widgetIdSchema = z.string().trim().min(1).max(100);
@@ -80,7 +81,9 @@ const previewBlockSchema = z.object({
const updateBlockSchema = z.object({ config: z.unknown().optional() });
const layoutUpdateSchema = z.object({
- layouts: z.array(z.object({ blockId: widgetIdSchema, layout: blockLayoutSchema })).max(5),
+ layouts: z
+ .array(z.object({ blockId: widgetIdSchema, layout: blockLayoutSchema }))
+ .max(MAX_WIDGET_BLOCKS),
columns: z.number().int().min(1).max(MAX_GRID_COLUMNS).optional(),
});
diff --git a/server/src/services/widgetCanvas.test.ts b/server/src/services/widgetCanvas.test.ts
index 7d04dc8..9ef4e17 100644
--- a/server/src/services/widgetCanvas.test.ts
+++ b/server/src/services/widgetCanvas.test.ts
@@ -34,8 +34,8 @@ it('renders the shared widget canvas as SVG', () => {
expect(svg).toContain('widget-surface');
// No drop shadow: it would tint the transparent corners around the rounded canvas.
expect(svg).not.toContain(' {
@@ -96,17 +96,17 @@ it('inlines avatars as data URIs instead of external URLs', () => {
it('renders the new GitHub blocks with the same geometry as the HTML canvas', async () => {
const { heatmapWeeks } = await import('@shared/widget/geometry.js');
- const layout = (x: number, y: number, width = 1) => ({ layout: { x, y, width, height: 1 } });
+ const layout = (x: number, y: number, width = 2) => ({ layout: { x, y, width, height: 2 } });
const svg = renderToStaticMarkup(
createElement(WidgetCanvas, {
blocks: [
{ id: 'commits', type: 'github-commits', position: 0, config: layout(0, 0) },
- { id: 'prs', type: 'github-prs', position: 1, config: layout(1, 0) },
- { id: 'status', type: 'github-status', position: 2, config: layout(0, 1, 2) },
+ { id: 'prs', type: 'github-prs', position: 1, config: layout(2, 0) },
+ { id: 'status', type: 'github-status', position: 2, config: layout(0, 2, 4) },
],
width: 600,
height: 600,
- columns: 2,
+ columns: 4,
renderedBlocks: [
{
id: 'commits',
@@ -137,9 +137,9 @@ it('renders the new GitHub blocks with the same geometry as the HTML canvas', as
}),
);
- // A 1-column block (225px content) fits 17 weeks × 7 days of heatmap cells.
- expect(heatmapWeeks(225)).toBe(17);
- expect(svg.match(/width="10" height="10" rx="2.5"/g)).toHaveLength(17 * 7);
+ // A 2-column block (244px content) fits 19 weeks × 7 days of heatmap cells.
+ expect(heatmapWeeks(244)).toBe(19);
+ expect(svg.match(/width="10" height="10" rx="2.5"/g)).toHaveLength(19 * 7);
// "1,200" at 22.5px/800 with -0.06em spacing (~57px) fits the 63px stat column.
expect(svg).toContain('>1,200<');
expect(svg).toMatch(/>Merged<\/text>.*?>70%<\/text>/);
diff --git a/server/src/services/widgetService.ts b/server/src/services/widgetService.ts
index d4484b4..2d4152e 100644
--- a/server/src/services/widgetService.ts
+++ b/server/src/services/widgetService.ts
@@ -2,7 +2,18 @@ import { randomBytes } from 'node:crypto';
import type { Prisma } from '@prisma/client';
-import { widgetDimensions as sharedWidgetDimensions } from '@shared/widget/geometry.js';
+import {
+ DEFAULT_BLOCK_SIZE,
+ isAllowedBlockSize,
+ placementSizes,
+} from '@shared/widget/blockSizes.js';
+import {
+ MAX_GRID_ROWS,
+ findFreeSpot,
+ gridRows,
+ layoutsOverlap,
+ widgetDimensions as sharedWidgetDimensions,
+} from '@shared/widget/geometry.js';
import { prisma } from '@server/lib/prisma.js';
import { AppError } from '@server/lib/errors.js';
@@ -81,7 +92,7 @@ const layoutFromBlock = (block: { position: number; config: unknown }): BlockLay
: {};
const layout = value.layout;
const result = blockLayoutSchema.safeParse(layout);
- return result.success ? result.data : { x: 0, y: block.position, width: 1, height: 1 };
+ return result.success ? result.data : { x: 0, y: block.position, ...DEFAULT_BLOCK_SIZE };
};
const widgetDimensions = (blocks: { position: number; config: unknown }[]) =>
@@ -95,16 +106,22 @@ const normalizeBlockConfig = (type: BlockType, config: unknown, layout?: BlockLa
: (getDefaultBlockConfig(type) as Record);
const result = parseBlockConfig(type, {
...defaults,
- layout: layout ?? input.layout ?? { x: 0, y: 0, width: 1, height: 1 },
+ layout: layout ?? input.layout ?? { x: 0, y: 0, ...DEFAULT_BLOCK_SIZE },
});
if (!result.success)
throw new AppError(400, result.error.issues[0]?.message ?? 'Invalid block config');
return result.data;
};
+/**
+ * Layout rules: block count, sizes the block type supports, grid bounds, no overlaps, and at most
+ * MAX_GRID_ROWS rows. Widgets migrated from the 2-column grid can be taller; they stay editable
+ * as long as a change doesn't make them taller still (`previousBlocks`).
+ */
const validateLayouts = (
blocks: { id: string; position: number; type: string; config: unknown }[],
config: WidgetConfig,
+ previousBlocks: { position: number; config: unknown }[] = blocks,
) => {
if (blocks.length > MAX_WIDGET_BLOCKS) {
throw new AppError(400, `A widget can contain at most ${MAX_WIDGET_BLOCKS} blocks`);
@@ -112,22 +129,26 @@ const validateLayouts = (
const columns = config.grid.columns;
const layouts = blocks.map((block) => ({ block, layout: layoutFromBlock(block) }));
- for (const { layout } of layouts) {
+ for (const { block, layout } of layouts) {
if (layout.x + layout.width > columns) {
throw new AppError(400, 'Block layout exceeds the widget grid');
}
+ if (!isAllowedBlockSize(block.type, layout.width, layout.height)) {
+ throw new AppError(400, `This block can't be ${layout.width}×${layout.height}`);
+ }
+ }
+
+ const rows = gridRows(layouts.map((item) => item.layout));
+ const previousRows = gridRows(previousBlocks.map(layoutFromBlock));
+ if (rows > Math.max(MAX_GRID_ROWS, previousRows)) {
+ throw new AppError(400, `A widget can be at most ${MAX_GRID_ROWS} rows tall`);
}
for (let index = 0; index < layouts.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < layouts.length; otherIndex += 1) {
- const left = layouts[index].layout;
- const right = layouts[otherIndex].layout;
- const overlaps =
- left.x < right.x + right.width &&
- left.x + left.width > right.x &&
- left.y < right.y + right.height &&
- left.y + left.height > right.y;
- if (overlaps) throw new AppError(400, 'Block layouts cannot overlap');
+ if (layoutsOverlap(layouts[index].layout, layouts[otherIndex].layout)) {
+ throw new AppError(400, 'Block layouts cannot overlap');
+ }
}
}
};
@@ -189,7 +210,12 @@ export class WidgetService {
...block.config,
...(getSourceForBlock(block.type) === source && username ? { username } : {}),
},
- { x: 0, y: position, width: 1, height: 1 },
+ // Default-size blocks, two per row.
+ {
+ x: (position % 2) * DEFAULT_BLOCK_SIZE.width,
+ y: Math.floor(position / 2) * DEFAULT_BLOCK_SIZE.height,
+ ...DEFAULT_BLOCK_SIZE,
+ },
),
}));
const dimensions = widgetDimensions(blockInputs);
@@ -261,16 +287,12 @@ export class WidgetService {
throw new AppError(400, `A widget can contain at most ${MAX_WIDGET_BLOCKS} blocks`);
}
const position = widget.blocks.reduce((max, block) => Math.max(max, block.position), -1) + 1;
- const lastRow = widget.blocks.reduce(
- (max, block) => Math.max(max, layoutFromBlock(block).y + layoutFromBlock(block).height),
- 0,
- );
- const normalizedConfig = normalizeBlockConfig(input.type, input.config, {
- x: 0,
- y: lastRow,
- width: 1,
- height: 1,
- });
+ const taken = widget.blocks.map(layoutFromBlock);
+ const spot = placementSizes(input.type)
+ .map((size) => findFreeSpot(taken, size, { columns: MAX_GRID_COLUMNS }))
+ .find((candidate) => candidate !== null);
+ if (!spot) throw new AppError(400, 'There is no free space for a new block in this widget');
+ const normalizedConfig = normalizeBlockConfig(input.type, input.config, spot);
const [created] = await prisma.$transaction([
prisma.block.create({
data: { widgetId, type: input.type, position, config: toJson(normalizedConfig) },
@@ -301,7 +323,7 @@ export class WidgetService {
block.widget.config,
block.widget.config as Partial,
);
- validateLayouts(nextBlocks, widgetConfig);
+ validateLayouts(nextBlocks, widgetConfig, block.widget.blocks);
return prisma.block.update({ where: { id: blockId }, data: { config: toJson(config) } });
};
@@ -347,7 +369,7 @@ export class WidgetService {
config: normalizeBlockConfig(block.type as BlockType, block.config, nextLayout),
};
});
- validateLayouts(nextBlocks, config);
+ validateLayouts(nextBlocks, config, widget.blocks);
await prisma.$transaction([
...nextBlocks.map((block) =>
prisma.block.update({ where: { id: block.id }, data: { config: toJson(block.config) } }),
diff --git a/server/src/shared/widget/BlockContent.tsx b/server/src/shared/widget/BlockContent.tsx
index 6af77a2..6dc321c 100644
--- a/server/src/shared/widget/BlockContent.tsx
+++ b/server/src/shared/widget/BlockContent.tsx
@@ -1,42 +1,21 @@
// Content of one widget block, drawn in the block's content box (origin at its top-left).
-import {
- BLOCK_LINE_HEIGHT,
- HEATMAP_CELL,
- HEATMAP_GAP,
- TEXT_BLOCK_LINE_HEIGHT,
- estimateTextWidth,
- heatmapColumns,
- heatmapWeeks,
-} from './geometry.js';
-import {
- difficultyColors,
- errorColor,
- heatmapOpacity,
- languageColor,
- widgetLabels,
- type PaletteTokens,
- type WidgetLocale,
-} from './theme.js';
+// Shared states live here; each block type renders its size variants in ./blocks/.
+import { blockVariant } from './blockSizes.js';
+import { BLOCK_LINE_HEIGHT } from './geometry.js';
+import { errorColor, widgetLabels, type PaletteTokens, type WidgetLocale } from './theme.js';
import type { WidgetCanvasBlock, WidgetCanvasRenderedBlock } from './types.js';
+import { blockRenderers } from './blocks/index.js';
+import { wrapText } from './blocks/parts.js';
import {
MultilineText,
PreviewState,
- StatsRow,
SvgText,
- TitleRow,
- asNumber,
asRecord,
asString,
baseline,
- clamp,
- fitText,
- formatNumber,
- imageUrl,
+ layoutOf,
linesOf,
- safeId,
- statsRowHeight,
type BlockFrame,
- type StatItem,
} from './svgPrimitives.js';
// Placeholder while live data loads (editor and public page; exports always have data).
@@ -67,6 +46,59 @@ const BlockSkeleton = ({ frame, tokens }: { frame: BlockFrame; tokens: PaletteTo
);
};
+// "Add a username" for blocks too small for the full PreviewState.
+const CompactPreview = ({
+ frame,
+ tokens,
+ locale,
+}: {
+ frame: BlockFrame;
+ tokens: PaletteTokens;
+ locale: WidgetLocale;
+}) => {
+ const lines = wrapText(widgetLabels(locale).addUsername, frame.width, 11, {
+ weight: 700,
+ maxLines: 2,
+ });
+ const markSize = 26;
+ const height = markSize + 8 + lines.length * 11 * BLOCK_LINE_HEIGHT;
+ const top = Math.max((frame.height - height) / 2, 0);
+ return (
+
+
+
+
+
+ );
+};
+
export const BlockContent = ({
block,
rendered,
@@ -82,16 +114,27 @@ export const BlockContent = ({
frame: BlockFrame;
avatarDataUri?: string;
}) => {
- const labels = widgetLabels(locale);
- const { typography: t } = frame;
const config = asRecord(block.config);
- const data = asRecord(rendered?.data);
+ const username = asString(config.username);
const source = block.type.startsWith('github')
? 'GitHub'
: block.type.startsWith('leetcode')
? 'LeetCode'
: null;
- const username = asString(config.username);
+ const layout = layoutOf(block);
+ const context = {
+ block,
+ variant: blockVariant(layout.width, layout.height),
+ config,
+ data: asRecord(rendered?.data),
+ labels: widgetLabels(locale),
+ t: frame.typography,
+ frame,
+ tokens,
+ locale,
+ username,
+ avatarDataUri,
+ };
if (rendered?.error) {
return (
@@ -99,471 +142,28 @@ export const BlockContent = ({
x={0}
top={0}
lineHeight={1.5}
- lines={linesOf(rendered.error, Math.floor(frame.width / (13 * 0.52)), 4)}
+ lines={linesOf(
+ rendered.error,
+ Math.floor(frame.width / (13 * 0.52)),
+ Math.max(Math.floor(frame.height / 19.5), 1),
+ )}
fill={errorColor}
size={13}
/>
);
}
- if (block.type === 'text') {
- const align = config.align === 'center' || config.align === 'right' ? config.align : 'left';
- const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
- const x = align === 'center' ? frame.width / 2 : align === 'right' ? frame.width : 0;
- const maxLines = Math.max(Math.floor(frame.height / (t.text * TEXT_BLOCK_LINE_HEIGHT)), 1);
- return (
-
- );
- }
+ if (block.type === 'text') return blockRenderers.text(context);
if (source && !username && !rendered?.data) {
- return ;
- }
-
- if (!rendered?.data) return ;
-
- if (block.type === 'github-stats') {
- const avatarHref = avatarDataUri ?? imageUrl(data.avatarUrl);
- const avatarId = `avatar-${safeId(block.id)}`;
- const tightGap = clamp(frame.width / 100, 2, 3);
- const textColumnHeight = t.heading * BLOCK_LINE_HEIGHT + tightGap + t.meta * BLOCK_LINE_HEIGHT;
- const headingHeight = Math.max(t.avatar, textColumnHeight);
- const avatarY = (headingHeight - t.avatar) / 2;
- const textTop = (headingHeight - textColumnHeight) / 2;
- const textX = t.avatar + t.headingGap;
- const textWidth = frame.width - textX;
- const stats = [
- config.showRepositories !== false
- ? { label: labels.repositories, value: data.publicRepositories }
- : null,
- config.showFollowers !== false ? { label: labels.followers, value: data.followers } : null,
- config.showFollowing !== false ? { label: labels.following, value: data.following } : null,
- ].filter((item): item is StatItem => item !== null);
- return (
- <>
-
- {avatarHref && (
- <>
-
-
-
-
- >
- )}
-
-
- {stats.length > 0 && (
-
- )}
- >
- );
- }
-
- if (block.type === 'github-langs') {
- const languages = Array.isArray(data.languages)
- ? data.languages
- .filter((item): item is { name: string; percentage: number } => {
- const value = asRecord(item);
- return typeof value.name === 'string' && typeof value.percentage === 'number';
- })
- .slice(0, 8)
- : [];
- const barY = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const listTop = barY + 10 + t.sectionGap;
- const columnWidth = (frame.width - t.columnGap) / 2;
- const rowHeight = t.meta * BLOCK_LINE_HEIGHT;
- const barClipId = `bar-${safeId(block.id)}`;
- let barOffset = 0;
- return (
- <>
-
-
-
-
-
-
- {languages.map((language) => {
- const segmentWidth = (Math.max(language.percentage, 0) / 100) * frame.width;
- const x = barOffset;
- barOffset += segmentWidth;
- return (
-
- );
- })}
-
- {languages.map((language, index) => {
- const x = (index % 2) * (columnWidth + t.columnGap);
- const top = listTop + Math.floor(index / 2) * (rowHeight + t.rowGap);
- const percent = `${language.percentage}%`;
- return (
-
-
-
-
-
- );
- })}
- >
- );
- }
-
- if (block.type === 'github-commits') {
- const stats: StatItem[] = [
- { label: labels.commits, value: data.commitsYear },
- ...(config.showStreak !== false
- ? [
- { label: labels.currentStreak, value: data.currentStreak },
- { label: labels.longestStreak, value: data.longestStreak },
- ]
- : []),
- ];
- const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const heatmapTop = statsTop + statsRowHeight(t) + t.sectionGap;
- const levels = Array.isArray(data.levels)
- ? data.levels.filter((level): level is number => typeof level === 'number')
- : [];
- const columns = heatmapColumns(
- levels,
- heatmapWeeks(frame.width),
- asNumber(data.firstDayOfWeek),
- );
- return (
- <>
-
-
- {config.showHeatmap !== false &&
- columns.map((column, week) =>
- column.map((level, weekday) =>
- level === null ? null : (
-
- ),
- ),
- )}
- >
- );
- }
-
- if (block.type === 'github-prs') {
- const total = asNumber(data.total);
- const parts = [
- { label: labels.merged, value: asNumber(data.merged), fill: tokens.accent, opacity: 1 },
- { label: labels.open, value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 },
- { label: labels.closed, value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 },
- ];
- const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const barY = statsTop + statsRowHeight(t) + t.sectionGap;
- const legendTop = barY + 10 + t.sectionGap;
- const legendWidth = (frame.width - t.columnGap) / 2;
- const legendRowHeight = t.meta * BLOCK_LINE_HEIGHT;
- const barClipId = `prs-${safeId(block.id)}`;
- let barOffset = 0;
- return (
- <>
-
-
- {config.showBreakdown !== false && (
- <>
-
-
-
-
-
- {total > 0 &&
- parts.map((part) => {
- const width = (part.value / total) * frame.width;
- const x = barOffset;
- barOffset += width;
- return (
-
- );
- })}
-
- {parts.map((part, index) => {
- // Same two-column list as the languages block: label left, percent right.
- const x = (index % 2) * (legendWidth + t.columnGap);
- const top = legendTop + Math.floor(index / 2) * (legendRowHeight + t.rowGap);
- const percent = `${total > 0 ? Math.round((part.value / total) * 100) : 0}%`;
- return (
-
-
-
-
-
- );
- })}
- >
- )}
- >
+ return frame.width < 200 || frame.height < 90 ? (
+
+ ) : (
+
);
}
- if (block.type === 'github-status') {
- const emoji = asString(data.emoji);
- const message = asString(data.message);
- const top = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const messageTop = emoji ? top + t.text * BLOCK_LINE_HEIGHT + t.headingGap : top;
- const messageLines = message
- ? linesOf(message, Math.floor(frame.width / (t.heading * 0.55)), 3)
- : [];
- const chipTop = messageTop + messageLines.length * t.heading * BLOCK_LINE_HEIGHT + t.headingGap;
- const chipHeight = t.meta * BLOCK_LINE_HEIGHT + 8;
- return (
- <>
-
- {!emoji && !message ? (
-
- ) : (
- <>
- {emoji && (
-
- )}
-
- {data.busy === true && (
-
-
-
-
- )}
- >
- )}
- >
- );
- }
+ if (!rendered?.data) return ;
- const solved = asRecord(data.solved);
- const stats = [
- { label: labels.solved, value: solved.all },
- config.showRanking !== false ? { label: labels.ranking, value: data.ranking } : null,
- config.showContestRating !== false
- ? { label: labels.contestRating, value: data.contestRating }
- : null,
- ].filter((item): item is StatItem => item !== null);
- const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
- const difficultyTop = statsTop + statsRowHeight(t) + t.sectionGap;
- const difficultyWidth = (frame.width - t.columnGap * 2) / 3;
- const difficulty = [
- { label: labels.easy, value: solved.easy, color: difficultyColors.easy },
- { label: labels.medium, value: solved.medium, color: difficultyColors.medium },
- { label: labels.hard, value: solved.hard, color: difficultyColors.hard },
- ];
- return (
- <>
-
-
- {difficulty.map((item, index) => {
- const x = index * (difficultyWidth + t.columnGap);
- return (
-
-
-
-
- );
- })}
- >
- );
+ return blockRenderers[block.type]?.(context) ?? null;
};
diff --git a/server/src/shared/widget/blockSizes.test.ts b/server/src/shared/widget/blockSizes.test.ts
new file mode 100644
index 0000000..5a60599
--- /dev/null
+++ b/server/src/shared/widget/blockSizes.test.ts
@@ -0,0 +1,52 @@
+import {
+ DEFAULT_BLOCK_SIZE,
+ isAllowedBlockSize,
+ snapToAllowedSize,
+} from '@shared/widget/blockSizes.js';
+
+it('allows only the sizes a block declares', () => {
+ expect(DEFAULT_BLOCK_SIZE).toEqual({ width: 2, height: 2 });
+ expect(isAllowedBlockSize('github-stats', 2, 2)).toBe(true);
+ expect(isAllowedBlockSize('github-stats', 3, 3)).toBe(false);
+ // Unknown types fall back to the legacy set.
+ expect(isAllowedBlockSize('future-block', 4, 2)).toBe(true);
+});
+
+it('snaps a dragged size to the nearest allowed one', () => {
+ expect(snapToAllowedSize('github-stats', 3, 2)).toEqual({ width: 2, height: 2 });
+ expect(snapToAllowedSize('github-stats', 4, 3)).toEqual({ width: 4, height: 2 });
+ expect(snapToAllowedSize('github-stats', 4, 4)).toEqual({ width: 4, height: 4 });
+ expect(snapToAllowedSize('github-stats', 1, 1)).toEqual({ width: 1, height: 1 });
+ expect(snapToAllowedSize('github-stats', 3, 1)).toEqual({ width: 2, height: 1 });
+ // Text supports more sizes.
+ expect(snapToAllowedSize('text', 3, 2)).toEqual({ width: 3, height: 2 });
+});
+
+it('prefers sizes that fit the space available from the block position', () => {
+ // Dragging to 4 wide from column 2 only has room for 2 columns.
+ expect(snapToAllowedSize('github-prs', 4, 2, { maxWidth: 2 })).toEqual({ width: 2, height: 2 });
+ expect(snapToAllowedSize('github-prs', 2, 4, { maxHeight: 3 })).toEqual({ width: 2, height: 2 });
+});
+
+it('maps sizes to layout variants', async () => {
+ const { blockVariant, defaultBlockSize } = await import('@shared/widget/blockSizes.js');
+ expect(blockVariant(1, 1)).toBe('tiny');
+ expect(blockVariant(2, 1)).toBe('strip');
+ expect(blockVariant(3, 1)).toBe('strip');
+ expect(blockVariant(4, 1)).toBe('wide');
+ expect(blockVariant(2, 2)).toBe('card');
+ expect(blockVariant(2, 4)).toBe('card');
+ expect(blockVariant(4, 2)).toBe('large');
+ expect(defaultBlockSize('text')).toEqual({ width: 2, height: 1 });
+ expect(defaultBlockSize('github-stats')).toEqual({ width: 2, height: 2 });
+});
+
+it('falls back to smaller sizes when placing a new block', async () => {
+ const { placementSizes } = await import('@shared/widget/blockSizes.js');
+ expect(placementSizes('github-stats')).toEqual([
+ { width: 2, height: 2 },
+ { width: 4, height: 1 },
+ { width: 2, height: 1 },
+ { width: 1, height: 1 },
+ ]);
+});
diff --git a/server/src/shared/widget/blockSizes.ts b/server/src/shared/widget/blockSizes.ts
new file mode 100644
index 0000000..cf0428f
--- /dev/null
+++ b/server/src/shared/widget/blockSizes.ts
@@ -0,0 +1,85 @@
+// Which sizes (in grid cells) each block type supports. A block only offers sizes it has a
+// design for; the editor snaps corner-resizing to these and the server rejects anything else.
+import { MAX_BLOCK_HEIGHT, MAX_BLOCK_WIDTH } from './geometry.js';
+
+export type BlockSize = { width: number; height: number };
+
+const size = (width: number, height: number): BlockSize => ({ width, height });
+
+// The 2-column grid's sizes, doubled. Every block keeps them so migrated widgets stay valid; they
+// render with the "card" / "large" variants.
+const LEGACY_SIZES = [size(2, 2), size(2, 4), size(4, 2), size(4, 4)];
+// Compact sizes with dedicated layouts: tile, strip and wide strip.
+const COMPACT_SIZES = [size(1, 1), size(2, 1), size(4, 1)];
+
+export const BLOCK_SIZES: Record = {
+ text: [...COMPACT_SIZES, size(3, 1), size(3, 2), size(4, 3), ...LEGACY_SIZES],
+ 'github-stats': [...COMPACT_SIZES, ...LEGACY_SIZES],
+ 'github-langs': [...COMPACT_SIZES, ...LEGACY_SIZES],
+ 'github-commits': [...COMPACT_SIZES, ...LEGACY_SIZES],
+ 'github-prs': [...COMPACT_SIZES, ...LEGACY_SIZES],
+ 'github-status': [...COMPACT_SIZES, ...LEGACY_SIZES],
+ 'leetcode-stats': [...COMPACT_SIZES, ...LEGACY_SIZES],
+};
+
+export const DEFAULT_BLOCK_SIZE: BlockSize = size(2, 2);
+
+const DEFAULT_SIZES: Record = { text: size(2, 1) };
+
+export const defaultBlockSize = (type: string) => DEFAULT_SIZES[type] ?? DEFAULT_BLOCK_SIZE;
+
+/**
+ * Sizes to try when placing a new block: the default first, then the other allowed sizes no
+ * larger than it, biggest first (so a nearly full widget still takes a compact block).
+ */
+export const placementSizes = (type: string): BlockSize[] => {
+ const preferred = defaultBlockSize(type);
+ const area = (item: BlockSize) => item.width * item.height;
+ const smaller = allowedBlockSizes(type)
+ .filter(
+ (item) =>
+ area(item) <= area(preferred) &&
+ !(item.width === preferred.width && item.height === preferred.height),
+ )
+ .sort((left, right) => area(right) - area(left) || right.width - left.width);
+ return [preferred, ...smaller];
+};
+
+/**
+ * Layout family for a block size: `tiny` 1×1 tile, `strip` 2–3×1, `wide` 4×1, `card` 2–3 columns
+ * × 2+ rows (the classic block look), `large` 4 columns × 2+ rows. A block without a dedicated
+ * `large` layout renders its `card` layout there.
+ */
+export type BlockVariant = 'tiny' | 'strip' | 'wide' | 'card' | 'large';
+
+export const blockVariant = (width: number, height: number): BlockVariant => {
+ if (width === 1) return 'tiny';
+ if (height === 1) return width >= 4 ? 'wide' : 'strip';
+ return width >= 4 ? 'large' : 'card';
+};
+
+export const allowedBlockSizes = (type: string) => BLOCK_SIZES[type] ?? LEGACY_SIZES;
+
+export const isAllowedBlockSize = (type: string, width: number, height: number) =>
+ allowedBlockSizes(type).some((item) => item.width === width && item.height === height);
+
+/**
+ * The allowed size closest to what the user dragged to (in cells), preferring sizes that fit the
+ * space available from the block's position. Ties go to the smaller block.
+ */
+export const snapToAllowedSize = (
+ type: string,
+ width: number,
+ height: number,
+ { maxWidth = MAX_BLOCK_WIDTH, maxHeight = MAX_BLOCK_HEIGHT } = {},
+): BlockSize => {
+ const candidates = allowedBlockSizes(type);
+ const fitting = candidates.filter((item) => item.width <= maxWidth && item.height <= maxHeight);
+ const pool = fitting.length ? fitting : candidates;
+ return pool.reduce((best, item) => {
+ const distance = Math.abs(item.width - width) + Math.abs(item.height - height);
+ const bestDistance = Math.abs(best.width - width) + Math.abs(best.height - height);
+ if (distance !== bestDistance) return distance < bestDistance ? item : best;
+ return item.width * item.height < best.width * best.height ? item : best;
+ });
+};
diff --git a/server/src/shared/widget/blockVariants.test.tsx b/server/src/shared/widget/blockVariants.test.tsx
new file mode 100644
index 0000000..13a6dc1
--- /dev/null
+++ b/server/src/shared/widget/blockVariants.test.tsx
@@ -0,0 +1,79 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+
+import { BLOCK_SIZES, blockVariant } from '@shared/widget/blockSizes.js';
+import { WidgetCanvas } from '@shared/widget/WidgetCanvas.js';
+
+const sampleData: Record> = {
+ 'github-stats': {
+ username: 'octocat',
+ name: 'The Octocat',
+ bio: 'Mascot',
+ publicRepositories: 8,
+ followers: 4321,
+ following: 9,
+ },
+ 'github-langs': {
+ username: 'octocat',
+ languages: [
+ { name: 'TypeScript', percentage: 60 },
+ { name: 'Go', percentage: 40 },
+ ],
+ },
+ 'github-commits': {
+ username: 'octocat',
+ commitsYear: 1234,
+ currentStreak: 5,
+ longestStreak: 20,
+ levels: Array(371).fill(2),
+ firstDayOfWeek: 0,
+ },
+ 'github-prs': { username: 'octocat', total: 10, merged: 7, open: 1, closed: 2 },
+ 'github-status': { username: 'octocat', emoji: '🚀', message: 'Shipping', busy: true },
+ 'leetcode-stats': {
+ username: 'tourist',
+ ranking: 42,
+ contestRating: 3000,
+ solved: { all: 600, easy: 300, medium: 200, hard: 100 },
+ },
+};
+
+// Something each variant must show, proving the right data reached it.
+const expectedText: Record string> = {
+ text: () => 'Hello',
+ 'github-stats': (variant) => (variant === 'tiny' ? '4,321' : 'The Octocat'),
+ 'github-langs': (variant) => (variant === 'tiny' ? '60%' : 'TypeScript'),
+ 'github-commits': () => '1,234',
+ 'github-prs': (variant) => (variant === 'tiny' ? '70%' : '10'),
+ 'github-status': () => '🚀',
+ 'leetcode-stats': () => '600',
+};
+
+const render = (type: string, width: number, height: number) =>
+ renderToStaticMarkup(
+ ,
+ );
+
+describe.each(Object.entries(BLOCK_SIZES))('%s', (type, sizes) => {
+ it.each(sizes.map((size) => [`${size.width}×${size.height}`, size] as const))(
+ 'renders the %s variant with its data',
+ (_label, size) => {
+ const svg = render(type, size.width, size.height);
+ expect(svg).toContain(expectedText[type](blockVariant(size.width, size.height)));
+ expect(svg).not.toContain('NaN');
+ expect(svg).not.toContain('undefined');
+ },
+ );
+});
diff --git a/server/src/shared/widget/blocks/context.ts b/server/src/shared/widget/blocks/context.ts
new file mode 100644
index 0000000..e2670dc
--- /dev/null
+++ b/server/src/shared/widget/blocks/context.ts
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+
+import type { BlockVariant } from '../blockSizes.js';
+import type { BlockTypography } from '../geometry.js';
+import type { PaletteTokens, WidgetLocale, widgetLabels } from '../theme.js';
+import type { WidgetCanvasBlock } from '../types.js';
+import type { BlockFrame } from '../svgPrimitives.js';
+
+/** Everything a block renderer needs; coordinates are relative to the block's content box. */
+export type BlockRenderContext = {
+ block: WidgetCanvasBlock;
+ variant: BlockVariant;
+ config: Record;
+ data: Record;
+ labels: ReturnType;
+ t: BlockTypography;
+ frame: BlockFrame;
+ tokens: PaletteTokens;
+ locale: WidgetLocale;
+ username: string;
+ avatarDataUri?: string;
+};
+
+export type BlockRenderer = (context: BlockRenderContext) => ReactNode;
diff --git a/server/src/shared/widget/blocks/githubCommits.tsx b/server/src/shared/widget/blocks/githubCommits.tsx
new file mode 100644
index 0000000..b91c402
--- /dev/null
+++ b/server/src/shared/widget/blocks/githubCommits.tsx
@@ -0,0 +1,163 @@
+import type { BlockRenderer } from './context.js';
+import {
+ BLOCK_LINE_HEIGHT,
+ HEATMAP_CELL,
+ HEATMAP_GAP,
+ heatmapColumns,
+ heatmapWeeks,
+} from '../geometry.js';
+import { formatStatValue, heatmapOpacity } from '../theme.js';
+import {
+ StatsRow,
+ SvgText,
+ TitleRow,
+ asNumber,
+ asString,
+ baseline,
+ clamp,
+ statsRowHeight,
+ type StatItem,
+} from '../svgPrimitives.js';
+import { BigStat, bigStatHeight } from './parts.js';
+
+// 1×1: commits this year and the current streak.
+const tiny: BlockRenderer = ({ data, labels, frame, tokens }) => {
+ const statHeight = bigStatHeight(26, 10);
+ const top = Math.max((frame.height - statHeight - 18) / 2, 0);
+ return (
+ <>
+
+
+ >
+ );
+};
+
+// 2×1: title with commits and current streak.
+const strip: BlockRenderer = ({ data, labels, t, frame, tokens, username }) => (
+ <>
+
+
+ >
+);
+
+// 4×1: caption and a compact heatmap filling the rest of the strip.
+const wide: BlockRenderer = ({ data, labels, t, frame, tokens, username }) => {
+ const gap = 2;
+ const top = t.heading * BLOCK_LINE_HEIGHT + 8;
+ const cell = Math.max(Math.floor((frame.height - top - gap * 6) / 7), 4);
+ const levels = Array.isArray(data.levels)
+ ? data.levels.filter((level): level is number => typeof level === 'number')
+ : [];
+ const weeks = clamp(Math.floor((frame.width + gap) / (cell + gap)), 4, 53);
+ const columns = heatmapColumns(levels, weeks, asNumber(data.firstDayOfWeek));
+ return (
+ <>
+
+ {columns.map((column, week) =>
+ column.map((level, weekday) =>
+ level === null ? null : (
+
+ ),
+ ),
+ )}
+ >
+ );
+};
+
+const card: BlockRenderer = ({ config, data, labels, t, frame, tokens, username }) => {
+ const stats: StatItem[] = [
+ { label: labels.commits, value: data.commitsYear },
+ ...(config.showStreak !== false
+ ? [
+ { label: labels.currentStreak, value: data.currentStreak },
+ { label: labels.longestStreak, value: data.longestStreak },
+ ]
+ : []),
+ ];
+ const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const heatmapTop = statsTop + statsRowHeight(t) + t.sectionGap;
+ const levels = Array.isArray(data.levels)
+ ? data.levels.filter((level): level is number => typeof level === 'number')
+ : [];
+ const columns = heatmapColumns(levels, heatmapWeeks(frame.width), asNumber(data.firstDayOfWeek));
+ return (
+ <>
+
+
+ {config.showHeatmap !== false &&
+ columns.map((column, week) =>
+ column.map((level, weekday) =>
+ level === null ? null : (
+
+ ),
+ ),
+ )}
+ >
+ );
+};
+
+export const renderGithubCommits: BlockRenderer = (context) => {
+ if (context.variant === 'tiny') return tiny(context);
+ if (context.variant === 'strip') return strip(context);
+ if (context.variant === 'wide') return wide(context);
+ return card(context);
+};
diff --git a/server/src/shared/widget/blocks/githubLangs.tsx b/server/src/shared/widget/blocks/githubLangs.tsx
new file mode 100644
index 0000000..354b451
--- /dev/null
+++ b/server/src/shared/widget/blocks/githubLangs.tsx
@@ -0,0 +1,226 @@
+import type { BlockRenderer } from './context.js';
+import { BLOCK_LINE_HEIGHT, estimateTextWidth } from '../geometry.js';
+import { languageColor } from '../theme.js';
+import { SvgText, TitleRow, asRecord, baseline, fitText, safeId } from '../svgPrimitives.js';
+import { SegmentBar, fitFontSize } from './parts.js';
+
+type Language = { name: string; percentage: number };
+
+const languagesOf = (data: Record): Language[] =>
+ Array.isArray(data.languages)
+ ? data.languages.filter((item): item is Language => {
+ const value = asRecord(item);
+ return typeof value.name === 'string' && typeof value.percentage === 'number';
+ })
+ : [];
+
+const languageSegments = (languages: Language[]) =>
+ languages.map((language) => ({
+ key: language.name,
+ value: language.percentage,
+ fill: languageColor(language.name),
+ }));
+
+// 1×1: the top language and its share, with the full mix as a thin bar.
+const tiny: BlockRenderer = ({ block, data, labels, frame, tokens }) => {
+ const languages = languagesOf(data);
+ const top = languages[0];
+ const percent = `${top?.percentage ?? 0}%`;
+ const size = fitFontSize(percent, frame.width, { max: 30, min: 14, letterSpacing: -0.06 });
+ return (
+ <>
+
+
+ {top && (
+ <>
+
+
+ >
+ )}
+
+ >
+ );
+};
+
+// 2×1 and 4×1: title, bar and one row of legend (2 or 4 languages).
+const legendStrip: BlockRenderer = ({ block, data, labels, t, frame, tokens, variant }) => {
+ const languages = languagesOf(data);
+ const shown = languages.slice(0, variant === 'wide' ? 4 : 2);
+ const barY = t.heading * BLOCK_LINE_HEIGHT + 10;
+ const legendTop = barY + 10 + 10;
+ const columnWidth = (frame.width - t.columnGap * (shown.length - 1)) / Math.max(shown.length, 1);
+ return (
+ <>
+
+
+ {shown.map((language, index) => {
+ const x = index * (columnWidth + t.columnGap);
+ const percent = `${language.percentage}%`;
+ const percentWidth = estimateTextWidth(percent, t.meta, { weight: 700 });
+ return (
+
+
+
+
+
+ );
+ })}
+ >
+ );
+};
+
+const card: BlockRenderer = ({ block, data, labels, t, frame, tokens }) => {
+ const languages = Array.isArray(data.languages)
+ ? data.languages
+ .filter((item): item is { name: string; percentage: number } => {
+ const value = asRecord(item);
+ return typeof value.name === 'string' && typeof value.percentage === 'number';
+ })
+ .slice(0, 8)
+ : [];
+ const barY = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const listTop = barY + 10 + t.sectionGap;
+ const columnWidth = (frame.width - t.columnGap) / 2;
+ const rowHeight = t.meta * BLOCK_LINE_HEIGHT;
+ const barClipId = `bar-${safeId(block.id)}`;
+ let barOffset = 0;
+ return (
+ <>
+
+
+
+
+
+
+ {languages.map((language) => {
+ const segmentWidth = (Math.max(language.percentage, 0) / 100) * frame.width;
+ const x = barOffset;
+ barOffset += segmentWidth;
+ return (
+
+ );
+ })}
+
+ {languages.map((language, index) => {
+ const x = (index % 2) * (columnWidth + t.columnGap);
+ const top = listTop + Math.floor(index / 2) * (rowHeight + t.rowGap);
+ const percent = `${language.percentage}%`;
+ return (
+
+
+
+
+
+ );
+ })}
+ >
+ );
+};
+
+export const renderGithubLangs: BlockRenderer = (context) => {
+ if (context.variant === 'tiny') return tiny(context);
+ if (context.variant === 'strip' || context.variant === 'wide') return legendStrip(context);
+ return card(context);
+};
diff --git a/server/src/shared/widget/blocks/githubPrs.tsx b/server/src/shared/widget/blocks/githubPrs.tsx
new file mode 100644
index 0000000..7cc2740
--- /dev/null
+++ b/server/src/shared/widget/blocks/githubPrs.tsx
@@ -0,0 +1,205 @@
+import type { BlockRenderer } from './context.js';
+import { BLOCK_LINE_HEIGHT, estimateTextWidth } from '../geometry.js';
+import {
+ StatsRow,
+ SvgText,
+ TitleRow,
+ asNumber,
+ asString,
+ baseline,
+ fitText,
+ safeId,
+ statsRowHeight,
+ type StatItem,
+} from '../svgPrimitives.js';
+import { SegmentBar, fitFontSize } from './parts.js';
+
+const prSegments = (data: Record, tokens: { accent: string; ink: string }) => [
+ { key: 'merged', value: asNumber(data.merged), fill: tokens.accent },
+ { key: 'open', value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 },
+ { key: 'closed', value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 },
+];
+
+// 1×1: share of merged pull requests.
+const tiny: BlockRenderer = ({ block, data, labels, frame, tokens }) => {
+ const total = asNumber(data.total);
+ const percent = `${total > 0 ? Math.round((asNumber(data.merged) / total) * 100) : 0}%`;
+ const size = fitFontSize(percent, frame.width, { max: 30, min: 14, letterSpacing: -0.06 });
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+// 2×1 and 4×1: title and stats (4×1 adds closed and the breakdown bar).
+const strips: BlockRenderer = ({ block, data, labels, t, frame, tokens, username, variant }) => {
+ const statsTop = t.heading * BLOCK_LINE_HEIGHT + 8;
+ const items: StatItem[] = [
+ { label: labels.total, value: data.total },
+ { label: labels.merged, value: data.merged },
+ { label: labels.open, value: data.open },
+ ...(variant === 'wide' ? [{ label: labels.closed, value: data.closed }] : []),
+ ];
+ return (
+ <>
+
+
+ {variant === 'wide' && (
+
+ )}
+ >
+ );
+};
+
+const card: BlockRenderer = ({ block, config, data, labels, t, frame, tokens, username }) => {
+ const total = asNumber(data.total);
+ const parts = [
+ { label: labels.merged, value: asNumber(data.merged), fill: tokens.accent, opacity: 1 },
+ { label: labels.open, value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 },
+ { label: labels.closed, value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 },
+ ];
+ const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const barY = statsTop + statsRowHeight(t) + t.sectionGap;
+ const legendTop = barY + 10 + t.sectionGap;
+ const legendWidth = (frame.width - t.columnGap) / 2;
+ const legendRowHeight = t.meta * BLOCK_LINE_HEIGHT;
+ const barClipId = `prs-${safeId(block.id)}`;
+ let barOffset = 0;
+ return (
+ <>
+
+
+ {config.showBreakdown !== false && (
+ <>
+
+
+
+
+
+ {total > 0 &&
+ parts.map((part) => {
+ const width = (part.value / total) * frame.width;
+ const x = barOffset;
+ barOffset += width;
+ return (
+
+ );
+ })}
+
+ {parts.map((part, index) => {
+ // Same two-column list as the languages block: label left, percent right.
+ const x = (index % 2) * (legendWidth + t.columnGap);
+ const top = legendTop + Math.floor(index / 2) * (legendRowHeight + t.rowGap);
+ const percent = `${total > 0 ? Math.round((part.value / total) * 100) : 0}%`;
+ return (
+
+
+
+
+
+ );
+ })}
+ >
+ )}
+ >
+ );
+};
+
+export const renderGithubPrs: BlockRenderer = (context) => {
+ if (context.variant === 'tiny') return tiny(context);
+ if (context.variant === 'strip' || context.variant === 'wide') return strips(context);
+ return card(context);
+};
diff --git a/server/src/shared/widget/blocks/githubStats.tsx b/server/src/shared/widget/blocks/githubStats.tsx
new file mode 100644
index 0000000..d13699b
--- /dev/null
+++ b/server/src/shared/widget/blocks/githubStats.tsx
@@ -0,0 +1,337 @@
+import type { BlockRenderer } from './context.js';
+import { BLOCK_LINE_HEIGHT } from '../geometry.js';
+import { formatStatValue } from '../theme.js';
+import {
+ MultilineText,
+ StatsRow,
+ SvgText,
+ asString,
+ baseline,
+ clamp,
+ fitText,
+ imageUrl,
+ safeId,
+ statsRowHeight,
+ type StatItem,
+} from '../svgPrimitives.js';
+import { Avatar, BigStat, fitFontSize, wrapText } from './parts.js';
+
+const avatarOf = (data: Record, avatarDataUri?: string) =>
+ avatarDataUri ?? imageUrl(data.avatarUrl);
+
+// 1×1: avatar and followers.
+const tiny: BlockRenderer = ({ block, data, labels, frame, tokens, avatarDataUri }) => {
+ const avatar = Math.round(Math.min(44, frame.height * 0.42));
+ return (
+ <>
+
+
+ >
+ );
+};
+
+// 2×1: avatar, name, handle and a one-line summary.
+const strip: BlockRenderer = ({
+ block,
+ data,
+ labels,
+ t,
+ frame,
+ tokens,
+ username,
+ avatarDataUri,
+}) => {
+ const avatar = Math.round(Math.min(56, frame.height));
+ const x = avatar + 12;
+ const width = frame.width - x;
+ const summary = `${formatStatValue(data.publicRepositories)} ${labels.repositories.toLowerCase()} · ${formatStatValue(data.followers)} ${labels.followers.toLowerCase()}`;
+ const columnHeight = (t.heading + t.meta * 2) * BLOCK_LINE_HEIGHT + 8;
+ const top = (frame.height - columnHeight) / 2;
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+const profileStats = (
+ config: Record,
+ data: Record,
+ labels: { repositories: string; followers: string; following: string },
+): StatItem[] =>
+ [
+ config.showRepositories !== false
+ ? { label: labels.repositories, value: data.publicRepositories }
+ : null,
+ config.showFollowers !== false ? { label: labels.followers, value: data.followers } : null,
+ config.showFollowing !== false ? { label: labels.following, value: data.following } : null,
+ ].filter((item) => item !== null);
+
+// 4×1: identity on the left, stats on the right.
+const wide: BlockRenderer = ({
+ block,
+ config,
+ data,
+ labels,
+ t,
+ frame,
+ tokens,
+ username,
+ avatarDataUri,
+}) => {
+ const avatar = Math.round(Math.min(64, frame.height));
+ const x = avatar + 12;
+ const identityWidth = frame.width * 0.38 - x;
+ const stats = profileStats(config, data, labels);
+ const statsX = frame.width * 0.4;
+ const statsWidth = frame.width - statsX;
+ const columnWidth = (statsWidth - t.columnGap * (stats.length - 1)) / Math.max(stats.length, 1);
+ const identityTop = (frame.height - (t.heading + t.meta) * BLOCK_LINE_HEIGHT - 2) / 2;
+ const statsTop = (frame.height - statsRowHeight(t)) / 2;
+ return (
+ <>
+
+
+
+ {stats.map((item, index) => (
+
+ ))}
+ >
+ );
+};
+
+// 4×2: big avatar, name, handle, bio and stats along the bottom.
+const large: BlockRenderer = ({
+ block,
+ config,
+ data,
+ labels,
+ t,
+ frame,
+ tokens,
+ username,
+ avatarDataUri,
+}) => {
+ const avatar = Math.round(Math.min(88, frame.height * 0.45));
+ const x = avatar + 16;
+ const width = frame.width - x;
+ const name = asString(data.name, labels.githubProfile);
+ const nameSize = fitFontSize(name, width, { max: 26, min: t.heading });
+ const bio = asString(data.bio);
+ const bioSize = 13;
+ const bioLines = bio ? wrapText(bio, width, bioSize, { maxLines: 2 }) : [];
+ const stats = profileStats(config, data, labels);
+ return (
+ <>
+
+
+
+
+ {stats.length > 0 && (
+
+ )}
+ >
+ );
+};
+
+const card: BlockRenderer = ({
+ block,
+ config,
+ data,
+ labels,
+ t,
+ frame,
+ tokens,
+ username,
+ avatarDataUri,
+}) => {
+ const avatarHref = avatarDataUri ?? imageUrl(data.avatarUrl);
+ const avatarId = `avatar-${safeId(block.id)}`;
+ const tightGap = clamp(frame.width / 100, 2, 3);
+ const textColumnHeight = t.heading * BLOCK_LINE_HEIGHT + tightGap + t.meta * BLOCK_LINE_HEIGHT;
+ const headingHeight = Math.max(t.avatar, textColumnHeight);
+ const avatarY = (headingHeight - t.avatar) / 2;
+ const textTop = (headingHeight - textColumnHeight) / 2;
+ const textX = t.avatar + t.headingGap;
+ const textWidth = frame.width - textX;
+ const stats = [
+ config.showRepositories !== false
+ ? { label: labels.repositories, value: data.publicRepositories }
+ : null,
+ config.showFollowers !== false ? { label: labels.followers, value: data.followers } : null,
+ config.showFollowing !== false ? { label: labels.following, value: data.following } : null,
+ ].filter((item): item is StatItem => item !== null);
+ return (
+ <>
+
+ {avatarHref && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ {stats.length > 0 && (
+
+ )}
+ >
+ );
+};
+
+export const renderGithubStats: BlockRenderer = (context) => {
+ if (context.variant === 'tiny') return tiny(context);
+ if (context.variant === 'strip') return strip(context);
+ if (context.variant === 'wide') return wide(context);
+ if (context.variant === 'large') return large(context);
+ return card(context);
+};
diff --git a/server/src/shared/widget/blocks/githubStatus.tsx b/server/src/shared/widget/blocks/githubStatus.tsx
new file mode 100644
index 0000000..d668579
--- /dev/null
+++ b/server/src/shared/widget/blocks/githubStatus.tsx
@@ -0,0 +1,172 @@
+import type { BlockRenderer } from './context.js';
+import { BLOCK_LINE_HEIGHT, estimateTextWidth } from '../geometry.js';
+import { MultilineText, SvgText, TitleRow, asString, baseline, linesOf } from '../svgPrimitives.js';
+import { wrapText } from './parts.js';
+
+// 1×1: the emoji, with a dot when busy.
+const tiny: BlockRenderer = ({ data, labels, frame, tokens }) => {
+ const emoji = asString(data.emoji);
+ const message = asString(data.message);
+ if (!emoji) {
+ const lines = wrapText(message || labels.noStatus, frame.width, 12, {
+ weight: 700,
+ maxLines: 4,
+ });
+ return (
+
+ );
+ }
+ const size = Math.round(Math.min(frame.width, frame.height) * 0.5);
+ return (
+ <>
+
+ {data.busy === true && }
+ >
+ );
+};
+
+// 2×1 and 4×1: emoji on the left, message (2 lines / 1 line) and busy flag on the right.
+const strips: BlockRenderer = ({ data, labels, t, frame, tokens, variant }) => {
+ const emoji = asString(data.emoji);
+ const message = asString(data.message) || labels.noStatus;
+ const emojiSize = Math.round(Math.min(frame.height * 0.5, variant === 'wide' ? 40 : 34));
+ const x = emoji ? emojiSize * 1.35 : 0;
+ const width = frame.width - x;
+ const lines = wrapText(message, width, t.heading, {
+ weight: 700,
+ maxLines: variant === 'wide' ? 1 : 2,
+ });
+ const busy = data.busy === true;
+ const textHeight =
+ lines.length * t.heading * BLOCK_LINE_HEIGHT + (busy ? t.meta * BLOCK_LINE_HEIGHT + 6 : 0);
+ const top = (frame.height - textHeight) / 2;
+ return (
+ <>
+ {emoji && (
+
+ )}
+
+ {busy && (
+
+ )}
+ >
+ );
+};
+
+const card: BlockRenderer = ({ data, labels, t, frame, tokens, username }) => {
+ const emoji = asString(data.emoji);
+ const message = asString(data.message);
+ const top = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const messageTop = emoji ? top + t.text * BLOCK_LINE_HEIGHT + t.headingGap : top;
+ const messageLines = message
+ ? linesOf(message, Math.floor(frame.width / (t.heading * 0.55)), 3)
+ : [];
+ const chipTop = messageTop + messageLines.length * t.heading * BLOCK_LINE_HEIGHT + t.headingGap;
+ const chipHeight = t.meta * BLOCK_LINE_HEIGHT + 8;
+ return (
+ <>
+
+ {!emoji && !message ? (
+
+ ) : (
+ <>
+ {emoji && (
+
+ )}
+
+ {data.busy === true && (
+
+
+
+
+ )}
+ >
+ )}
+ >
+ );
+};
+
+export const renderGithubStatus: BlockRenderer = (context) => {
+ if (context.variant === 'tiny') return tiny(context);
+ if (context.variant === 'strip' || context.variant === 'wide') return strips(context);
+ return card(context);
+};
diff --git a/server/src/shared/widget/blocks/index.ts b/server/src/shared/widget/blocks/index.ts
new file mode 100644
index 0000000..1463749
--- /dev/null
+++ b/server/src/shared/widget/blocks/index.ts
@@ -0,0 +1,20 @@
+import type { BlockRenderer } from './context.js';
+import { renderGithubCommits } from './githubCommits.js';
+import { renderGithubLangs } from './githubLangs.js';
+import { renderGithubPrs } from './githubPrs.js';
+import { renderGithubStats } from './githubStats.js';
+import { renderGithubStatus } from './githubStatus.js';
+import { renderLeetcode } from './leetcode.js';
+import { renderText } from './text.js';
+
+export type { BlockRenderContext, BlockRenderer } from './context.js';
+
+export const blockRenderers: Record = {
+ text: renderText,
+ 'github-stats': renderGithubStats,
+ 'github-langs': renderGithubLangs,
+ 'github-commits': renderGithubCommits,
+ 'github-prs': renderGithubPrs,
+ 'github-status': renderGithubStatus,
+ 'leetcode-stats': renderLeetcode,
+};
diff --git a/server/src/shared/widget/blocks/leetcode.tsx b/server/src/shared/widget/blocks/leetcode.tsx
new file mode 100644
index 0000000..47ff68f
--- /dev/null
+++ b/server/src/shared/widget/blocks/leetcode.tsx
@@ -0,0 +1,235 @@
+import type { BlockRenderer } from './context.js';
+import { BLOCK_LINE_HEIGHT } from '../geometry.js';
+import { difficultyColors } from '../theme.js';
+import {
+ StatsRow,
+ SvgText,
+ TitleRow,
+ asNumber,
+ asRecord,
+ asString,
+ baseline,
+ fitText,
+ formatNumber,
+ statsRowHeight,
+ type StatItem,
+} from '../svgPrimitives.js';
+import { BigStat, SegmentBar, bigStatHeight } from './parts.js';
+
+const solvedOf = (data: Record) => asRecord(data.solved);
+
+const difficultySegments = (solved: Record) => [
+ { key: 'easy', value: asNumber(solved.easy), fill: difficultyColors.easy },
+ { key: 'medium', value: asNumber(solved.medium), fill: difficultyColors.medium },
+ { key: 'hard', value: asNumber(solved.hard), fill: difficultyColors.hard },
+];
+
+// 1×1: solved count with the difficulty mix.
+const tiny: BlockRenderer = ({ block, data, labels, frame, tokens }) => {
+ const solved = solvedOf(data);
+ const statHeight = bigStatHeight(26, 10);
+ return (
+ <>
+
+
+ >
+ );
+};
+
+// 2×1 and 4×1: title, stats and the difficulty bar.
+const strips: BlockRenderer = ({
+ block,
+ config,
+ data,
+ labels,
+ t,
+ frame,
+ tokens,
+ username,
+ variant,
+}) => {
+ const solved = solvedOf(data);
+ const items: StatItem[] = [
+ { label: labels.solved, value: solved.all },
+ ...(config.showRanking !== false ? [{ label: labels.ranking, value: data.ranking }] : []),
+ ...(variant === 'wide' && config.showContestRating !== false
+ ? [{ label: labels.contestRating, value: data.contestRating }]
+ : []),
+ ];
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+// 4×2: title, stats and one bar per difficulty.
+const large: BlockRenderer = ({ block, config, data, labels, t, frame, tokens, username }) => {
+ const solved = solvedOf(data);
+ const total = Math.max(asNumber(solved.all), 1);
+ const items: StatItem[] = [
+ { label: labels.solved, value: solved.all },
+ ...(config.showRanking !== false ? [{ label: labels.ranking, value: data.ranking }] : []),
+ ...(config.showContestRating !== false
+ ? [{ label: labels.contestRating, value: data.contestRating }]
+ : []),
+ ];
+ const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const rowsTop = statsTop + statsRowHeight(t) + t.sectionGap;
+ const rowHeight = t.meta * BLOCK_LINE_HEIGHT + 4 + 6;
+ const rowGap = Math.max((frame.height - rowsTop - rowHeight * 3) / 2, 4);
+ const rows = [
+ { key: 'easy', label: labels.easy, value: asNumber(solved.easy), color: difficultyColors.easy },
+ {
+ key: 'medium',
+ label: labels.medium,
+ value: asNumber(solved.medium),
+ color: difficultyColors.medium,
+ },
+ { key: 'hard', label: labels.hard, value: asNumber(solved.hard), color: difficultyColors.hard },
+ ];
+ return (
+ <>
+
+
+ {rows.map((row, index) => {
+ const top = rowsTop + index * (rowHeight + rowGap);
+ return (
+
+
+
+
+
+ );
+ })}
+ >
+ );
+};
+
+const card: BlockRenderer = ({ config, data, labels, t, frame, tokens, username }) => {
+ const solved = asRecord(data.solved);
+ const stats = [
+ { label: labels.solved, value: solved.all },
+ config.showRanking !== false ? { label: labels.ranking, value: data.ranking } : null,
+ config.showContestRating !== false
+ ? { label: labels.contestRating, value: data.contestRating }
+ : null,
+ ].filter((item): item is StatItem => item !== null);
+ const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap;
+ const difficultyTop = statsTop + statsRowHeight(t) + t.sectionGap;
+ const difficultyWidth = (frame.width - t.columnGap * 2) / 3;
+ const difficulty = [
+ { label: labels.easy, value: solved.easy, color: difficultyColors.easy },
+ { label: labels.medium, value: solved.medium, color: difficultyColors.medium },
+ { label: labels.hard, value: solved.hard, color: difficultyColors.hard },
+ ];
+ return (
+ <>
+
+
+ {difficulty.map((item, index) => {
+ const x = index * (difficultyWidth + t.columnGap);
+ return (
+
+
+
+
+ );
+ })}
+ >
+ );
+};
+
+export const renderLeetcode: BlockRenderer = (context) => {
+ if (context.variant === 'tiny') return tiny(context);
+ if (context.variant === 'strip' || context.variant === 'wide') return strips(context);
+ if (context.variant === 'large') return large(context);
+ return card(context);
+};
diff --git a/server/src/shared/widget/blocks/parts.tsx b/server/src/shared/widget/blocks/parts.tsx
new file mode 100644
index 0000000..fa9269a
--- /dev/null
+++ b/server/src/shared/widget/blocks/parts.tsx
@@ -0,0 +1,203 @@
+// Building blocks for size variants (tiles and strips) shared by several block renderers.
+import { BLOCK_LINE_HEIGHT, estimateTextWidth } from '../geometry.js';
+import { formatStatValue, type PaletteTokens } from '../theme.js';
+import { SvgText, baseline, fitText, safeId } from '../svgPrimitives.js';
+
+/** Largest font size (within bounds) at which `text` fits `maxWidth`. */
+export const fitFontSize = (
+ text: string,
+ maxWidth: number,
+ {
+ max,
+ min,
+ weight = 800,
+ letterSpacing = 0,
+ }: {
+ max: number;
+ min: number;
+ weight?: number;
+ letterSpacing?: number;
+ },
+) => {
+ for (let size = max; size > min; size -= 0.5) {
+ if (estimateTextWidth(text, size, { weight, letterSpacing }) <= maxWidth) return size;
+ }
+ return min;
+};
+
+/** A headline number with a muted caption under it, sized to `width`. */
+export const BigStat = ({
+ x = 0,
+ top,
+ width,
+ value,
+ label,
+ tokens,
+ maxSize,
+ labelSize,
+ anchor = 'start',
+}: {
+ x?: number;
+ top: number;
+ width: number;
+ value: unknown;
+ label: string;
+ tokens: PaletteTokens;
+ maxSize: number;
+ labelSize: number;
+ anchor?: 'start' | 'middle';
+}) => {
+ const text = formatStatValue(value, width, maxSize);
+ const size = fitFontSize(text, width, { max: maxSize, min: 12, letterSpacing: -0.06 });
+ return (
+
+
+
+
+ );
+};
+
+export const bigStatHeight = (size: number, labelSize: number) =>
+ size * BLOCK_LINE_HEIGHT + 2 + labelSize * BLOCK_LINE_HEIGHT;
+
+export type BarSegment = { key: string; value: number; fill: string; opacity?: number };
+
+/** Rounded stacked bar; segments are proportional to their values. */
+export const SegmentBar = ({
+ id,
+ y,
+ width,
+ height = 8,
+ segments,
+ tokens,
+}: {
+ id: string;
+ y: number;
+ width: number;
+ height?: number;
+ segments: BarSegment[];
+ tokens: PaletteTokens;
+}) => {
+ const total = segments.reduce((sum, segment) => sum + Math.max(segment.value, 0), 0);
+ const clipId = `bar-${safeId(id)}`;
+ let offset = 0;
+ return (
+ <>
+
+
+
+
+
+ {total > 0 &&
+ segments.map((segment) => {
+ const segmentWidth = (Math.max(segment.value, 0) / total) * width;
+ const x = offset;
+ offset += segmentWidth;
+ return (
+
+ );
+ })}
+
+ >
+ );
+};
+
+export const Avatar = ({
+ id,
+ x,
+ y,
+ size,
+ href,
+ tokens,
+}: {
+ id: string;
+ x: number;
+ y: number;
+ size: number;
+ href: string | null;
+ tokens: PaletteTokens;
+}) => {
+ const clipId = `avatar-${safeId(id)}`;
+ const radius = Math.round(size * 0.3);
+ return (
+ <>
+
+ {href && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+ );
+};
+
+/** Greedy word wrap by estimated width; overflowing last line gets an ellipsis. */
+export const wrapText = (
+ text: string,
+ width: number,
+ size: number,
+ { weight = 500, letterSpacing = 0, maxLines = Infinity } = {},
+) => {
+ const measure = (value: string) => estimateTextWidth(value, size, { weight, letterSpacing });
+ const words = text.split(/\s+/).filter(Boolean);
+ const lines: string[] = [];
+ let current = '';
+ for (const word of words) {
+ const next = current ? `${current} ${word}` : word;
+ if (current && measure(next) > width) {
+ lines.push(current);
+ current = word;
+ } else {
+ current = next;
+ }
+ }
+ if (current) lines.push(current);
+ if (lines.length <= maxLines) return lines;
+ const kept = lines.slice(0, maxLines);
+ kept[maxLines - 1] = fitText(`${kept[maxLines - 1]} ${lines[maxLines]}`, width, size, weight);
+ return kept;
+};
diff --git a/server/src/shared/widget/blocks/text.tsx b/server/src/shared/widget/blocks/text.tsx
new file mode 100644
index 0000000..2e87827
--- /dev/null
+++ b/server/src/shared/widget/blocks/text.tsx
@@ -0,0 +1,41 @@
+import type { BlockRenderer } from './context.js';
+import { TEXT_BLOCK_LINE_HEIGHT } from '../geometry.js';
+import { MultilineText, asString } from '../svgPrimitives.js';
+import { wrapText } from './parts.js';
+
+// Text fills its box: the largest size (up to 40px) at which the wrapped text fits, centred
+// vertically, in every allowed size.
+export const renderText: BlockRenderer = ({ config, labels, frame, tokens }) => {
+ const align = config.align === 'center' || config.align === 'right' ? config.align : 'left';
+ const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
+ const x = align === 'center' ? frame.width / 2 : align === 'right' ? frame.width : 0;
+ const value = asString(config.text, labels.defaultText);
+ const style = { weight: 800, letterSpacing: -0.05 };
+ let size = 12;
+ let lines = wrapText(value, frame.width, size, {
+ ...style,
+ maxLines: Math.max(Math.floor(frame.height / (size * TEXT_BLOCK_LINE_HEIGHT)), 1),
+ });
+ for (let candidate = Math.min(40, Math.floor(frame.height)); candidate > 12; candidate -= 1) {
+ const wrapped = wrapText(value, frame.width, candidate, style);
+ if (wrapped.length * candidate * TEXT_BLOCK_LINE_HEIGHT <= frame.height) {
+ size = candidate;
+ lines = wrapped;
+ break;
+ }
+ }
+ const top = Math.max((frame.height - lines.length * size * TEXT_BLOCK_LINE_HEIGHT) / 2, 0);
+ return (
+
+ );
+};
diff --git a/server/src/shared/widget/geometry.test.ts b/server/src/shared/widget/geometry.test.ts
index 6fd0626..72f3c43 100644
--- a/server/src/shared/widget/geometry.test.ts
+++ b/server/src/shared/widget/geometry.test.ts
@@ -7,43 +7,55 @@ import {
widgetDimensions,
} from '@shared/widget/geometry.js';
-it('uses square 267px cells for the default 600px widget', () => {
+it('uses square 129px cells: a 4×4 grid is exactly 600×600', () => {
expect(canvasPadding(600)).toBe(24);
- expect(cellSize(600)).toBe(267);
- expect(widgetDimensions([{ x: 0, y: 0, width: 1, height: 1 }])).toEqual({
+ expect(cellSize(600)).toBe(129);
+ expect(widgetDimensions([{ x: 0, y: 0, width: 2, height: 2 }])).toEqual({
width: 600,
- height: 315,
+ height: 318,
});
- expect(widgetDimensions([{ x: 0, y: 1, width: 2, height: 1 }])).toEqual({
+ expect(widgetDimensions([{ x: 0, y: 3, width: 4, height: 1 }])).toEqual({
width: 600,
height: 600,
});
+ // The editable maximum: 5 rows.
+ expect(widgetDimensions([{ x: 0, y: 4, width: 1, height: 1 }]).height).toBe(741);
});
it('places blocks on the same grid the stored size was computed from', () => {
const layouts = [
{ x: 0, y: 0, width: 1, height: 1 },
- { x: 1, y: 0, width: 1, height: 2 },
+ { x: 1, y: 0, width: 3, height: 2 },
{ x: 0, y: 1, width: 1, height: 1 },
];
const canvas = { ...widgetDimensions(layouts), rows: 2 };
- expect(blockBox(layouts[0], canvas)).toEqual({ x: 24, y: 24, width: 267, height: 267 });
- expect(blockBox(layouts[1], canvas)).toEqual({ x: 309, y: 24, width: 267, height: 552 });
- expect(blockBox(layouts[2], canvas)).toEqual({ x: 24, y: 309, width: 267, height: 267 });
+ expect(blockBox(layouts[0], canvas)).toEqual({ x: 24, y: 24, width: 129, height: 129 });
+ expect(blockBox(layouts[1], canvas)).toEqual({ x: 165, y: 24, width: 411, height: 270 });
+ expect(blockBox(layouts[2], canvas)).toEqual({ x: 24, y: 165, width: 129, height: 129 });
});
it('scales block typography with content width within fixed bounds', () => {
- const half = blockTypography(blockContentWidth(267));
+ const half = blockTypography(blockContentWidth(270));
const full = blockTypography(blockContentWidth(552));
- expect(blockContentWidth(267)).toBe(225);
- expect(half.statValue).toBeCloseTo(22.5);
+ expect([129, 270, 411, 552].map(blockContentWidth)).toEqual([103, 244, 385, 526]);
+ expect(half.statValue).toBeCloseTo(24.4);
expect(full.statValue).toBe(30);
- expect(half.meta).toBeCloseTo(10.125);
+ expect(half.meta).toBeCloseTo(10.98);
expect(full.meta).toBe(12);
});
+it('finds the top-most free spot within the row limit', async () => {
+ const { findFreeSpot } = await import('@shared/widget/geometry.js');
+ const taken = [
+ { x: 0, y: 0, width: 2, height: 2 },
+ { x: 2, y: 0, width: 2, height: 1 },
+ ];
+ expect(findFreeSpot(taken, { width: 2, height: 2 })).toEqual({ x: 2, y: 1, width: 2, height: 2 });
+ expect(findFreeSpot([{ x: 0, y: 0, width: 4, height: 5 }], { width: 1, height: 1 })).toBeNull();
+});
+
it('switches stats to compact notation only when they do not fit the column', async () => {
const { formatStatValue } = await import('@shared/widget/theme.js');
diff --git a/server/src/shared/widget/geometry.ts b/server/src/shared/widget/geometry.ts
index 0afdea2..cb3e804 100644
--- a/server/src/shared/widget/geometry.ts
+++ b/server/src/shared/widget/geometry.ts
@@ -2,14 +2,18 @@
// the SVG export and the stored widget size all derive their numbers from here.
export const WIDGET_WIDTH = 600;
+// Height cap for rendering; editing is limited to MAX_GRID_ROWS, but widgets migrated from the
+// 2-column grid may be taller and must still render.
export const MAX_WIDGET_HEIGHT = 1200;
-export const MAX_GRID_COLUMNS = 2;
-export const MAX_BLOCK_HEIGHT = 2;
-export const GRID_GAP = 18;
-export const BLOCK_PADDING = 20;
+export const MAX_GRID_COLUMNS = 4;
+export const MAX_GRID_ROWS = 5;
+export const MAX_BLOCK_WIDTH = MAX_GRID_COLUMNS;
+export const MAX_BLOCK_HEIGHT = 4;
+export const GRID_GAP = 12;
+export const BLOCK_PADDING = 12;
export const BLOCK_BORDER = 1;
export const CANVAS_RADIUS = 28;
-export const BLOCK_RADIUS = 20;
+export const BLOCK_RADIUS = 16;
export const BLOCK_LINE_HEIGHT = 1.25;
export const TEXT_BLOCK_LINE_HEIGHT = 1.12;
@@ -142,3 +146,24 @@ export const estimateTextWidth = (
for (const character of text) width += glyphWidth(character) * boldness + letterSpacing;
return width * size;
};
+
+export const layoutsOverlap = (left: GridLayout, right: GridLayout) =>
+ left.x < right.x + right.width &&
+ left.x + left.width > right.x &&
+ left.y < right.y + right.height &&
+ left.y + left.height > right.y;
+
+/** Top-most, then left-most free spot for a block of `size` within the editable rows. */
+export const findFreeSpot = (
+ layouts: GridLayout[],
+ size: { width: number; height: number },
+ { columns = MAX_GRID_COLUMNS, rows = MAX_GRID_ROWS } = {},
+): GridLayout | null => {
+ for (let y = 0; y + size.height <= rows; y += 1) {
+ for (let x = 0; x + size.width <= columns; x += 1) {
+ const candidate = { x, y, ...size };
+ if (!layouts.some((layout) => layoutsOverlap(layout, candidate))) return candidate;
+ }
+ }
+ return null;
+};
diff --git a/server/src/widgets.test.ts b/server/src/widgets.test.ts
index bbf1d81..95ff820 100644
--- a/server/src/widgets.test.ts
+++ b/server/src/widgets.test.ts
@@ -94,25 +94,26 @@ it('creates a widget with preset blocks and a generated slug', async () => {
};
};
expect(createCall.data.width).toBe(600);
- expect(createCall.data.height).toBe(600);
+ // Two default 2×2 blocks side by side: 2 rows.
+ expect(createCall.data.height).toBe(318);
expect(createCall.data.blocks.create).toEqual(
expect.arrayContaining([
expect.objectContaining({
- config: expect.objectContaining({ layout: { x: 0, y: 0, width: 1, height: 1 } }),
+ config: expect.objectContaining({ layout: { x: 0, y: 0, width: 2, height: 2 } }),
}),
expect.objectContaining({
- config: expect.objectContaining({ layout: { x: 0, y: 1, width: 1, height: 1 } }),
+ config: expect.objectContaining({ layout: { x: 2, y: 0, width: 2, height: 2 } }),
}),
]),
);
});
-it('creates a new block as a single-column item', async () => {
+it('places a new block at the first free spot with the default size', async () => {
const { agent, token } = await authenticatedAgent();
prismaMocks.widget.findFirst.mockResolvedValue({
id: widget.id,
userId: user.id,
- config: { grid: { columns: 2 }, palette: 'lavender', renderFormat: 'iframe' },
+ config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' },
blocks: [],
});
prismaMocks.block.create.mockResolvedValue({
@@ -120,7 +121,7 @@ it('creates a new block as a single-column item', async () => {
widgetId: widget.id,
position: 0,
type: 'text',
- config: { text: 'New block', layout: { x: 0, y: 0, width: 1, height: 1 } },
+ config: { text: 'New block', layout: { x: 0, y: 0, width: 2, height: 2 } },
});
await agent
@@ -132,7 +133,8 @@ it('creates a new block as a single-column item', async () => {
expect(prismaMocks.block.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
- config: expect.objectContaining({ layout: { x: 0, y: 0, width: 1, height: 1 } }),
+ // Text blocks default to 2×1.
+ config: expect.objectContaining({ layout: { x: 0, y: 0, width: 2, height: 1 } }),
}),
}),
);
@@ -223,17 +225,17 @@ it('deletes a widget through the protected route', async () => {
expect(prismaMocks.widget.delete).toHaveBeenCalledWith({ where: { id: widget.id } });
});
-it('rejects adding a sixth block', async () => {
+it('rejects adding a ninth block', async () => {
const { agent, token } = await authenticatedAgent();
prismaMocks.widget.findFirst.mockResolvedValue({
id: widget.id,
userId: user.id,
- config: { grid: { columns: 1 }, palette: 'lavender', renderFormat: 'iframe' },
- blocks: Array.from({ length: 5 }, (_, index) => ({
+ config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' },
+ blocks: Array.from({ length: 8 }, (_, index) => ({
id: `block-${index}`,
position: index,
type: 'text',
- config: { text: `Block ${index}`, layout: { x: 0, y: index, width: 1, height: 1 } },
+ config: { text: `Block ${index}`, layout: { x: 0, y: index, width: 2, height: 2 } },
})),
});
@@ -241,7 +243,7 @@ it('rejects adding a sixth block', async () => {
.post(`/api/widgets/${widget.id}/blocks`)
.set('Authorization', `Bearer ${token}`)
.send({ type: 'text', config: { text: 'Too many' } })
- .expect(400, { error: 'A widget can contain at most 5 blocks' });
+ .expect(400, { error: 'A widget can contain at most 8 blocks' });
});
it('renders a public widget as an SVG image', async () => {
@@ -313,7 +315,7 @@ it('inlines the GitHub avatar as a data URI in the public SVG image', async () =
widgetId: widget.id,
position: 0,
type: 'github-stats',
- config: { layout: { x: 0, y: 0, width: 1, height: 1 } },
+ config: { layout: { x: 0, y: 0, width: 2, height: 2 } },
},
],
};
@@ -390,3 +392,128 @@ it('allows far more SVG image requests per IP than JSON widget requests', async
delete process.env.VERCEL;
}
});
+
+describe('grid layout rules', () => {
+ const blockAt = (id: string, layout: Record) => ({
+ id,
+ widgetId: widget.id,
+ position: 0,
+ type: 'text',
+ config: { text: id, layout },
+ });
+ const widgetWith = (blocks: ReturnType[]) => ({
+ ...widget,
+ config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' },
+ blocks,
+ });
+ const reorder = async (layouts: { blockId: string; layout: Record }[]) => {
+ const { agent, token } = await authenticatedAgent();
+ return agent
+ .put(`/api/widgets/${widget.id}/blocks`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ columns: 4, layouts });
+ };
+
+ it('rejects sizes the block type does not support', async () => {
+ prismaMocks.widget.findFirst.mockResolvedValue(
+ widgetWith([blockAt('a', { x: 0, y: 0, width: 2, height: 2 })]),
+ );
+
+ const response = await reorder([{ blockId: 'a', layout: { x: 0, y: 0, width: 3, height: 3 } }]);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe("This block can't be 3×3");
+ });
+
+ it('rejects layouts taller than 5 rows', async () => {
+ prismaMocks.widget.findFirst.mockResolvedValue(
+ widgetWith([blockAt('a', { x: 0, y: 0, width: 2, height: 2 })]),
+ );
+
+ const response = await reorder([{ blockId: 'a', layout: { x: 0, y: 4, width: 2, height: 2 } }]);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('A widget can be at most 5 rows tall');
+ });
+
+ it('keeps migrated widgets that are already taller editable, but not taller still', async () => {
+ const tall = widgetWith([
+ blockAt('a', { x: 0, y: 0, width: 2, height: 2 }),
+ blockAt('b', { x: 0, y: 4, width: 2, height: 2 }),
+ ]);
+ prismaMocks.widget.findFirst.mockResolvedValue(tall);
+
+ const same = await reorder([
+ { blockId: 'a', layout: { x: 2, y: 0, width: 2, height: 2 } },
+ { blockId: 'b', layout: { x: 0, y: 4, width: 2, height: 2 } },
+ ]);
+ expect(same.status).toBe(200);
+
+ const taller = await reorder([
+ { blockId: 'a', layout: { x: 0, y: 0, width: 2, height: 2 } },
+ { blockId: 'b', layout: { x: 0, y: 6, width: 2, height: 2 } },
+ ]);
+ expect(taller.status).toBe(400);
+ });
+});
+
+it('places a new block in a smaller size when the default does not fit', async () => {
+ const { agent, token } = await authenticatedAgent();
+ // Four 2×2 blocks fill rows 0–3; only the single 5th row is left.
+ prismaMocks.widget.findFirst.mockResolvedValue({
+ ...widget,
+ config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' },
+ blocks: [0, 1, 2, 3].map((index) => ({
+ id: `block-${index}`,
+ position: index,
+ type: 'text',
+ config: {
+ text: 'x',
+ layout: { x: (index % 2) * 2, y: Math.floor(index / 2) * 2, width: 2, height: 2 },
+ },
+ })),
+ });
+ prismaMocks.block.create.mockImplementation(async ({ data }: { data: unknown }) => ({
+ id: 'new',
+ ...(data as object),
+ }));
+
+ await agent
+ .post(`/api/widgets/${widget.id}/blocks`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ type: 'github-stats', config: {} })
+ .expect(201);
+
+ expect(prismaMocks.block.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ config: expect.objectContaining({ layout: { x: 0, y: 4, width: 4, height: 1 } }),
+ }),
+ }),
+ );
+});
+
+it('saves layouts for up to 8 blocks', async () => {
+ const { agent, token } = await authenticatedAgent();
+ const blocks = Array.from({ length: 8 }, (_, index) => ({
+ id: `block-${index}`,
+ widgetId: widget.id,
+ position: index,
+ type: 'text',
+ config: { text: 'x', layout: { x: index % 4, y: Math.floor(index / 4), width: 1, height: 1 } },
+ }));
+ prismaMocks.widget.findFirst.mockResolvedValue({
+ ...widget,
+ config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' },
+ blocks,
+ });
+
+ await agent
+ .put(`/api/widgets/${widget.id}/blocks`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ columns: 4,
+ layouts: blocks.map((block) => ({ blockId: block.id, layout: block.config.layout })),
+ })
+ .expect(200);
+});
diff --git a/server/src/widgets/registry.ts b/server/src/widgets/registry.ts
index 3c7638d..07a37d4 100644
--- a/server/src/widgets/registry.ts
+++ b/server/src/widgets/registry.ts
@@ -1,5 +1,6 @@
import { z } from 'zod';
+import { DEFAULT_BLOCK_SIZE } from '@shared/widget/blockSizes.js';
import { MAX_BLOCK_HEIGHT, MAX_GRID_COLUMNS } from '@shared/widget/geometry.js';
export const BLOCK_TYPES = [
@@ -21,7 +22,9 @@ export type PaletteId = (typeof PALETTE_IDS)[number];
export const PALETTE_MODES = ['light', 'dark'] as const;
export type PaletteMode = (typeof PALETTE_MODES)[number];
-export const MAX_WIDGET_BLOCKS = 5;
+// Free plan limit. Planned tiers (not implemented): paid 16, special accounts 20 — see
+// docs/design/grid-and-blocks.md.
+export const MAX_WIDGET_BLOCKS = 8;
export { MAX_GRID_COLUMNS };
export const blockTypeSchema = z.enum(BLOCK_TYPES);
@@ -49,7 +52,7 @@ export type BlockLayout = z.infer;
const usernameSchema = z.string().trim().max(100).optional();
const sharedBlockFields = {
- layout: blockLayoutSchema.default({ x: 0, y: 0, width: 1, height: 1 }),
+ layout: blockLayoutSchema.default({ x: 0, y: 0, ...DEFAULT_BLOCK_SIZE }),
};
const blockSchemas: Record = {