Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions .claude/skills/add-block-type/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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/<name>.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 `<img>` 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/`

Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ 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 `<svg>` (`WidgetCanvas.tsx`); the browser places the background and each block as separate `<svg>`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 `<svg>` (`WidgetCanvas.tsx`); the browser places the background and each block as separate `<svg>`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.
- Limits: `MAX_WIDGET_BLOCKS = 5` in the registry; `MAX_GRID_COLUMNS = 2` and block height ≤ 2 in shared `geometry.ts` (re-exported by the registry). Layout validation (bounds + no overlap) runs in `widgetService.validateLayouts`.
- Widget width is currently fixed at 600 and height is recomputed from rows by `widgetDimensions` on every update — client-supplied `width`/`height` are ignored.
- **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.
- GitHub data: profile, repositories and languages use REST (a token only raises limits; languages switch to exact GraphQL byte counts with a token). Activity, pull request and status blocks use GraphQL only and show an error without `GITHUB_TOKEN`.
- `statsService` and the avatar cache are per-process in-memory caches; on Vercel each cold instance starts empty. Successful lookups are cached 15 min, failed ones 60 s; the public SVG `Cache-Control` follows the same TTL. Rate limits (`express-rate-limit`, memory store) are also per instance; `trust proxy` is enabled on Vercel so limits are per client IP.
- `api/*.ts` import from `server/dist/src/app.js`. `server/tsconfig.build.json` pins `rootDir: "."` to keep that path stable, and CI runs `typecheck:api` to catch breakage.
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -17,6 +19,9 @@ All notable changes to this project are documented here.

### Changed

- The widget grid is now 4 columns × up to 5 rows of half-size cells (a 600×600 widget fits 16 cells instead of 4). Existing widgets were converted automatically and look the same; ones that end up taller than 5 rows can be fitted from the editor.
- Blocks are resized by dragging their corner, snapping to the sizes each block supports; the size buttons are gone.
- A widget can hold up to 8 blocks (was 5).
- The editor, public page and iframe now render widgets with the same SVG components as the image export (one renderer instead of an HTML and an SVG copy), so every surface shows exactly the same widget.
- SVG text truncation and stat number formatting now use a per-character width estimate calibrated to the widget font (Cyrillic, bold and `%` are wider), so the SVG export cuts text in the same places as the HTML widget.
- Split the 1,200-line widget editor into layout rules, state/autosave, drag and preview hooks, and focused UI components, and shared the embed snippet builder between the gallery and the editor.
Expand All @@ -29,6 +34,8 @@ All notable changes to this project are documented here.

### Fixed

- The page could shift sideways when the browser scrolled an element into view (decorative background overflow).
- Restored the move animation for blocks pushed aside while dragging or resizing in the editor.
- Starting Yandex sign-in when it isn't configured now returns to the app with an error message instead of a raw JSON response.
- Fixed editor blocks jumping to another column or far down the grid when resized: the edited block stays in place, neighbours move down and gaps close; dropping a block onto one of the same size swaps them, and every moved block animates.
- Fixed saving failing with a raw validation error after clearing a text block or entering an out-of-range language count; the count is now a 3–8 select and text/username fields have length limits.
Expand Down
4 changes: 3 additions & 1 deletion client/src/app/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
align-items: center;
position: relative;
min-height: 100vh;
overflow-x: hidden;
/* clip, not hidden: a hidden overflow is still programmatically scrollable, so focus or
scrollIntoView could shift the page sideways by the decorative orbs' overflow. */
overflow-x: clip;
padding: 24px;
background:
radial-gradient(
Expand Down
15 changes: 8 additions & 7 deletions client/src/entities/widget/ui/WidgetCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const githubBlock: WidgetBlock = {
id: 'gh',
type: 'github-stats',
position: 0,
config: { username: 'octocat', layout: { x: 0, y: 0, width: 1, height: 1 } },
config: { username: 'octocat', layout: { x: 0, y: 0, width: 2, height: 2 } },
};

const renderedGithub = {
Expand Down Expand Up @@ -89,11 +89,11 @@ it('shows the empty state without blocks', () => {
});

describe('GitHub activity, pull request and status blocks', () => {
const block = (id: string, type: WidgetBlock['type'], config = {}, width = 1): WidgetBlock => ({
const block = (id: string, type: WidgetBlock['type'], config = {}, width = 2): WidgetBlock => ({
id,
type,
position: 0,
config: { username: 'octo', layout: { x: 0, y: 0, width, height: 1 }, ...config },
config: { username: 'octo', layout: { x: 0, y: 0, width, height: 2 }, ...config },
});

it('draws as many heatmap weeks as fit the block width', () => {
Expand All @@ -111,17 +111,18 @@ describe('GitHub activity, pull request and status blocks', () => {
renderedBlocks={[rendered('narrow')]}
/>,
);
expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(17 * 7);
// 2-column block: (244 + 3) / 13 → 19 weeks.
expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(19 * 7);

rerender(
<WidgetCanvas
blocks={[block('wide', 'github-commits', {}, 2)]}
blocks={[block('wide', 'github-commits', {}, 4)]}
palette="lavender"
renderedBlocks={[rendered('wide')]}
/>,
);
// Full-width block: (510 + 3) / 13 → 39 weeks.
expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(39 * 7);
// Full-width block: (526 + 3) / 13 → 40 weeks.
expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(40 * 7);
});

it('hides streaks and the heatmap when turned off', () => {
Expand Down
3 changes: 2 additions & 1 deletion client/src/pages/widget-editor/model/editorCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { Widget } from '@/entities/widget/model';
// Unsaved editor state survives reloads and crashes until the next successful save.
type CachedEditorState = { savedAt: number; widget: Widget };

const cacheKey = (widgetId: string) => `widget-editor:v4:${widgetId}`;
// v5: layouts use the 4-column grid; older drafts hold 2-column layouts and are ignored.
const cacheKey = (widgetId: string) => `widget-editor:v5:${widgetId}`;

export const readCachedWidget = (widgetId: string): Widget | null => {
try {
Expand Down
66 changes: 54 additions & 12 deletions client/src/pages/widget-editor/model/layout.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Widget, WidgetBlock } from '@/entities/widget/model';
import {
dragGridRows,
fitsRowLimit,
getLayout,
moveBlock,
normalizeWidget,
packIntoRows,
placeBlock,
} from '@/pages/widget-editor/model/layout';

Expand All @@ -20,14 +22,14 @@ const widgetWith = (blocks: WidgetBlock[], config: Partial<Widget['config']> = {
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',
...config,
},
Expand Down Expand Up @@ -65,9 +67,9 @@ describe('placeBlock', () => {
});
});

it('shifts a block in the right column left when it grows wider than the grid allows', () => {
const next = layouts(placeBlock(grid, 'b', { x: 1, y: 0, width: 2, height: 1 }));
expect(next.b).toEqual({ x: 0, y: 0, width: 2, height: 1 });
it('shifts a block left when it grows wider than the grid allows', () => {
const next = layouts(placeBlock(grid, 'b', { x: 1, y: 0, width: 4, height: 1 }));
expect(next.b).toEqual({ x: 0, y: 0, width: 4, height: 1 });
expect(next.a).toEqual({ x: 0, y: 1, width: 1, height: 1 });
});

Expand Down Expand Up @@ -129,9 +131,49 @@ describe('moveBlock', () => {
});
});

it('offers one spare drop row below the lowest block', () => {
it('offers one spare drop row below the lowest block, within the row limit', () => {
expect(dragGridRows(widgetWith([]))).toBe(2);
expect(dragGridRows(widgetWith([block('a', { x: 0, y: 1, width: 1, height: 2 })]))).toBe(4);
expect(dragGridRows(widgetWith([block('a', { x: 0, y: 3, width: 2, height: 2 })]))).toBe(5);
// Migrated widgets taller than the limit get no extra row.
expect(dragGridRows(widgetWith([block('a', { x: 0, y: 4, width: 2, height: 2 })]))).toBe(6);
});

describe('row limit', () => {
const short = widgetWith([block('a', { x: 0, y: 0, width: 2, height: 2 })]);
const fiveRows = widgetWith([block('a', { x: 0, y: 3, width: 2, height: 2 })]);
const sixRows = widgetWith([block('a', { x: 0, y: 4, width: 2, height: 2 })]);
const eightRows = widgetWith([block('a', { x: 0, y: 6, width: 2, height: 2 })]);

it('allows changes up to 5 rows, or within the height a migrated widget already has', () => {
expect(fitsRowLimit(short, fiveRows)).toBe(true);
expect(fitsRowLimit(short, sixRows)).toBe(false);
expect(fitsRowLimit(sixRows, sixRows)).toBe(true);
expect(fitsRowLimit(sixRows, eightRows)).toBe(false);
});

it('packs a tall migrated widget into 5 rows keeping block sizes', () => {
// An old 2-column widget whose blocks were stacked in one column: 2×2 blocks at y 0, 2, 4.
const stacked = widgetWith([
block('a', { x: 0, y: 0, width: 2, height: 2 }),
block('b', { x: 0, y: 2, width: 2, height: 2 }),
block('c', { x: 0, y: 4, width: 4, height: 2 }),
]);

expect(layouts(packIntoRows(stacked)!)).toEqual({
a: { x: 0, y: 0, width: 2, height: 2 },
b: { x: 2, y: 0, width: 2, height: 2 },
c: { x: 0, y: 2, width: 4, height: 2 },
});
});

it('reports widgets whose blocks cannot fit', () => {
const big = widgetWith([
block('a', { x: 0, y: 0, width: 4, height: 4 }),
block('b', { x: 0, y: 4, width: 4, height: 2 }),
]);
expect(packIntoRows(big)).toBeNull();
});
});

describe('normalizeWidget', () => {
Expand All @@ -140,13 +182,13 @@ describe('normalizeWidget', () => {
widgetWith([
block(
'a',
{ x: 0, y: 0, width: 1, height: 1 },
{ config: { username: 'octocat', layout: { x: 0, y: 0, width: 1, height: 1 } } },
{ x: 0, y: 0, width: 2, height: 2 },
{ config: { username: 'octocat', layout: { x: 0, y: 0, width: 2, height: 2 } } },
),
]),
);
expect(changed).toBe(false);
expect(widget.height).toBe(315);
expect(widget.height).toBe(318);
});

it('migrates legacy widgets and marks them for saving', () => {
Expand All @@ -166,13 +208,13 @@ describe('normalizeWidget', () => {

expect(changed).toBe(true);
expect(widget.config.paletteMode).toBe('light');
expect(widget.config.grid.columns).toBe(2);
expect(widget.config.grid.columns).toBe(4);
expect(widget.blocks.map((item) => item.id)).toEqual(['first', 'second']);
expect(widget.blocks[0].config).toMatchObject({
username: 'octocat',
layout: { x: 1, y: 0, width: 1, height: 2 },
layout: { x: 3, y: 0, width: 1, height: 4 },
});
expect(widget.blocks[1].config.layout).toEqual({ x: 0, y: 1, width: 1, height: 1 });
expect(widget.blocks[1].config.layout).toEqual({ x: 0, y: 1, width: 2, height: 2 });
expect(widget.height).toBe(600);
});
});
Loading
Loading