diff --git a/.claude/skills/add-block-type/SKILL.md b/.claude/skills/add-block-type/SKILL.md
index d8f85a3..ad97e33 100644
--- a/.claude/skills/add-block-type/SKILL.md
+++ b/.claude/skills/add-block-type/SKILL.md
@@ -25,13 +25,23 @@ the `/image.svg` export use the same SVG components.
- Branch on the new type in `getBlockData`. Throw `AppError` for "not found"; `renderWidgetStats` turns errors into `block.error`.
- Mind the budget: public SVG/iframe views call this per request (15 min cache per instance). Prefer one API call per block.
-## 3. Rendering — `server/src/shared/widget/BlockContent.tsx` (used everywhere)
-
-- Add a block renderer in `BlockContent` next to the existing ones. Use the `frame` (content width/height + `blockTypography`), `baseline()`, `fitText`, `StatsRow`/`TitleRow` helpers — no hard-coded font sizes.
-- Primitives live in `svgPrimitives.tsx`; the block shell and canvas pieces in `canvasParts.tsx`.
-- Put new labels in `server/src/shared/widget/theme.ts` (`widgetLabels`, ru + en).
-- Loading (`BlockSkeleton`), `error` and missing-username states are handled before the type switch; make sure the new block doesn't bypass them.
-- Images: the export needs data URIs (see `buildAvatarDataUris` in `widgetController.ts`) because external `href`s don't load when the SVG is used as ` ` on GitHub; in the browser the URL is used directly.
+## 3. Sizes and rendering — `server/src/shared/widget/` (used everywhere)
+
+- `blockSizes.ts`: add the type to `BLOCK_SIZES` (always include the legacy sizes 2×2, 2×4, 4×2,
+ 4×4 plus the compact sizes you design for) and, if not 2×2, to `DEFAULT_SIZES`.
+- Create `blocks/.tsx` exporting a `BlockRenderer` that switches on `context.variant`
+ (`tiny` 1×1, `strip` 2–3×1, `wide` 4×1, `card`, `large`); register it in `blocks/index.ts`.
+ Unhandled variants should fall back to `card`.
+- Reuse `blocks/parts.tsx` (`BigStat`, `SegmentBar`, `Avatar`, `fitFontSize`, `wrapText`) and
+ `svgPrimitives.tsx` (`TitleRow`, `StatsRow`, `fitText`, `baseline`). Size text from the frame
+ (`context.t`, `frame.width/height`), never hard-code positions for a single size.
+- Loading, `error` and missing-username states are handled in `BlockContent.tsx` before your
+ renderer runs.
+- Put new labels in `theme.ts` (`widgetLabels`, ru + en).
+- Images: the export needs data URIs (see `buildAvatarDataUris` in `widgetController.ts`) because
+ external `href`s don't load when the SVG is used as ` ` on GitHub; the browser uses URLs.
+- Add sample data and an expected string for the type in `blockVariants.test.tsx`; it renders every
+ allowed size.
## 4. Client — `client/src/entities/widget/`
diff --git a/AGENTS.md b/AGENTS.md
index 8f92d7d..8f9ef0b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,7 +73,7 @@ Server imports must use `.js` extensions (NodeNext).
## Invariants and gotchas
-- **One renderer.** Every surface (editor, public page, iframe, `/image.svg`) draws widgets with the same SVG components in `server/src/shared/widget/` (`BlockContent`, `canvasParts`, `svgPrimitives`), using numbers from `geometry.ts`/`theme.ts`. The export composes them into one `` (`WidgetCanvas.tsx`); the browser places the background and each block as separate ``s (`client/src/entities/widget/ui/WidgetSurface.tsx`) so the editor can attach controls, and scales the whole thing with `ScaledWidgetFrame`. Text widths are estimated (`estimateTextWidth`), so truncate/wrap in SVG code, not CSS. See the `widget-render-parity` skill.
+- **One renderer.** Every surface (editor, public page, iframe, `/image.svg`) draws widgets with the same SVG components in `server/src/shared/widget/` (`BlockContent` → per-type renderers in `blocks/` with size variants, `canvasParts`, `svgPrimitives`), using numbers from `geometry.ts`/`theme.ts`. The export composes them into one `` (`WidgetCanvas.tsx`); the browser places the background and each block as separate ``s (`client/src/entities/widget/ui/WidgetSurface.tsx`) so the editor can attach controls, and scales the whole thing with `ScaledWidgetFrame`. Text widths are estimated (`estimateTextWidth`), so truncate/wrap in SVG code, not CSS. See the `widget-render-parity` skill.
- **Block types are defined twice.** Server `widgets/registry.ts` (zod schemas, presets) and client `entities/widget/model/{types,registry}.ts` (labels, ru/en descriptions). Rendering is written once in `shared/widget/BlockContent.tsx`. Use the `add-block-type` skill.
- **Grid:** 4 columns × up to 5 rows of 129px cells (12px gap, 24px padding, width 600; 4×4 = 600×600). Block sizes are declared per type in `shared/widget/blockSizes.ts` (`BLOCK_SIZES`, `snapToAllowedSize`); the editor resizes by dragging the corner and the server (`widgetService.validateLayouts`) rejects other sizes, overlaps, and layouts taller than 5 rows — unless a widget migrated from the old 2-column grid is already taller and the change doesn't grow it. `MAX_WIDGET_BLOCKS = 8` (free tier; planned tiers in `docs/design/grid-and-blocks.md`). Design reference: `docs/design/grid-and-blocks.md`.
- Widget width is fixed at 600 and height is recomputed from rows by `widgetDimensions` on every update — client-supplied `width`/`height` are ignored.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 381f223..7c57774 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ All notable changes to this project are documented here.
### Added
+- Blocks adapt to their size: every block has compact 1×1 tile, 2×1 strip and 4×1 wide-strip layouts (e.g. avatar + followers, top language %, a compact heatmap, merged %, status emoji, solved count), plus roomier 4×2 layouts for the GitHub profile and LeetCode. Text fits itself to any size.
+- When there's no room for a new block at its default size, it's added in the largest smaller size that fits.
- Added three GitHub blocks: Activity (commits this year, current and best streak, and a contribution heatmap that shows as many weeks as fit the block), Pull Requests (total, merged, open with a status breakdown) and Status (emoji, message, busy flag), plus a "GitHub Activity" preset. They use the GitHub GraphQL API and need `GITHUB_TOKEN` on the server.
- Added password reset by email ("Forgot password?" on sign-in) and email confirmation after sign-up, with a resend option on the account page. Emails are sent through Resend once `RESEND_API_KEY` and `EMAIL_FROM` are configured; until then the features stay hidden in production and emails are printed to the server log in development.
- Added an account page with sign-in methods: connect Yandex ID to an existing account, or disconnect it when a password remains.
diff --git a/docs/design/grid-and-blocks.md b/docs/design/grid-and-blocks.md
index 708c066..dbf6e41 100644
--- a/docs/design/grid-and-blocks.md
+++ b/docs/design/grid-and-blocks.md
@@ -28,19 +28,31 @@ A block declares the sizes it supports (`server/src/shared/widget/blockSizes.ts`
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.
-Planned variant matrix (✓ = supported):
-
-| Block | 1×1 | 2×1 | 2×2 | 4×1 | 4×2 | other |
-| -------------- | ------------------ | ----------------------- | --------------------------- | ----------------------------- | ---------------------------- | --------------------------------- |
-| text | short text | ✓ | ✓ | ✓ | ✓ | 3×1, 3×2, 4×3 (font fits the box) |
-| github-stats | avatar + followers | avatar, name, @user | card: avatar, name, 3 stats | identity left, stats right | big avatar, name, bio, stats | |
-| github-langs | top language + % | bar + top-3 legend | bar + list (≤ 6) | wide bar + inline legend | bar + 2-column list (≤ 8) | |
-| github-commits | commits + streak | commits + streak | stats + ~19-week heatmap | caption + 40-week heatmap | stats + 40-week heatmap | |
-| github-prs | merged % | total + merged | stats + bar + legend | stats + bar inline | — | |
-| github-status | emoji (+ busy dot) | emoji + 2-line message | emoji, message, busy | emoji + one-line message | — | |
-| leetcode-stats | solved | solved + difficulty bar | title, 3 stats, difficulty | inline stats + difficulty bar | full + per-difficulty bars | |
-
-Defaults: new blocks are 2×2 (text 2×1), placed at the first free spot.
+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
diff --git a/server/src/services/widgetService.ts b/server/src/services/widgetService.ts
index fa09791..2d4152e 100644
--- a/server/src/services/widgetService.ts
+++ b/server/src/services/widgetService.ts
@@ -2,7 +2,11 @@ import { randomBytes } from 'node:crypto';
import type { Prisma } from '@prisma/client';
-import { DEFAULT_BLOCK_SIZE, isAllowedBlockSize } from '@shared/widget/blockSizes.js';
+import {
+ DEFAULT_BLOCK_SIZE,
+ isAllowedBlockSize,
+ placementSizes,
+} from '@shared/widget/blockSizes.js';
import {
MAX_GRID_ROWS,
findFreeSpot,
@@ -283,9 +287,10 @@ 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 spot = findFreeSpot(widget.blocks.map(layoutFromBlock), DEFAULT_BLOCK_SIZE, {
- columns: MAX_GRID_COLUMNS,
- });
+ 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([
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
index 150e143..5a60599 100644
--- a/server/src/shared/widget/blockSizes.test.ts
+++ b/server/src/shared/widget/blockSizes.test.ts
@@ -13,14 +13,40 @@ it('allows only the sizes a block declares', () => {
});
it('snaps a dragged size to the nearest allowed one', () => {
- expect(snapToAllowedSize('text', 3, 2)).toEqual({ width: 2, height: 2 });
- expect(snapToAllowedSize('text', 4, 3)).toEqual({ width: 4, height: 2 });
- expect(snapToAllowedSize('text', 4, 4)).toEqual({ width: 4, height: 4 });
- expect(snapToAllowedSize('text', 1, 1)).toEqual({ width: 2, height: 2 });
+ 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('text', 4, 2, { maxWidth: 2 })).toEqual({ width: 2, height: 2 });
- expect(snapToAllowedSize('text', 2, 4, { maxHeight: 3 })).toEqual({ width: 2, height: 2 });
+ 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
index 688fc6a..cf0428f 100644
--- a/server/src/shared/widget/blockSizes.ts
+++ b/server/src/shared/widget/blockSizes.ts
@@ -6,21 +6,58 @@ export type BlockSize = { width: number; height: number };
const size = (width: number, height: number): BlockSize => ({ width, height });
-// The 2-column grid's sizes, doubled: what every block could do before size variants.
+// 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: LEGACY_SIZES,
- 'github-stats': LEGACY_SIZES,
- 'github-langs': LEGACY_SIZES,
- 'github-commits': LEGACY_SIZES,
- 'github-prs': LEGACY_SIZES,
- 'github-status': LEGACY_SIZES,
- 'leetcode-stats': LEGACY_SIZES,
+ 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) =>
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/widgets.test.ts b/server/src/widgets.test.ts
index 881d790..95ff820 100644
--- a/server/src/widgets.test.ts
+++ b/server/src/widgets.test.ts
@@ -133,7 +133,8 @@ it('places a new block at the first free spot with the default size', async () =
expect(prismaMocks.block.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
- config: expect.objectContaining({ layout: { x: 0, y: 0, width: 2, height: 2 } }),
+ // Text blocks default to 2×1.
+ config: expect.objectContaining({ layout: { x: 0, y: 0, width: 2, height: 1 } }),
}),
}),
);
@@ -314,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 } },
},
],
};
@@ -455,3 +456,64 @@ describe('grid layout rules', () => {
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);
+});