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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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.
- **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.
Expand Down
2 changes: 2 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 Down
38 changes: 25 additions & 13 deletions docs/design/grid-and-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 9 additions & 4 deletions server/src/services/widgetService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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([
Expand Down
Loading
Loading