Skip to content
Open
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
44 changes: 44 additions & 0 deletions .claude/commands/open-pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
description: Create a pull request to develop following the project PR template. Use when you want to open a PR for the current branch.
argument-hint: '[additional context or notes for the PR description]'
---

Create a pull request from the current branch following the project template.

## Steps

1. Detect the parent branch by running:
`git log --oneline --decorate --all --simplify-by-decoration HEAD | grep -v "HEAD" | head -3`
Pick the closest ancestor branch from the output (e.g. `develop`, `main`, `release/x.y`).
2. Run `git diff <base>...HEAD` and `git log <base>..HEAD --oneline` to understand all changes
3. Read `.github/PULL_REQUEST_TEMPLATE.md` for the exact template structure
4. Determine:
- PR title using the emoji/type mapping below
- Which **Type of Change** checkbox to tick (Bugfix / Improvement / New feature / Breaking change)
- What changed at the component/module level
5. Run `gh pr create --base <base>` filling each section:
- **Why?** — the problem this solves
- **Type of Change** — tick the correct checkbox
- **What Changed?** — layers, classes, entities affected
- **Screenshots** — remove the table if there are no UI changes
- **Testing** — tick platforms and verification items based on the diff; describe test scenarios
- **Reference Links / Dependencies** — include `$ARGUMENTS` if provided; omit if empty
- **Checklist** — tick Self-review and Tests/Docs items based on the diff
Comment on lines +19 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the command file to see Step 5 exactly
cat -n .claude/commands/open-pr.md | head -30

Repository: squillteam/bricks-web-client

Length of output: 1855


🏁 Script executed:

# Then check the PR template file
cat -n .github/PULL_REQUEST_TEMPLATE.md

Repository: squillteam/bricks-web-client

Length of output: 803


Mismatch between command instructions and actual PR template structure.

Step 5 instructs users to fill sections—Screenshots, Testing, Reference Links / Dependencies, and Merge Rules—that do not exist in .github/PULL_REQUEST_TEMPLATE.md. The template contains only: Why?, Type of change, What changed?, and Checklist.

Additionally, line 14 instructs users to read the template to understand its structure, but Step 5 then contradicts this by listing non-existent fields. This creates confusion when users follow the command and discover the template lacks these sections.

Either expand the template to include the missing sections or update Step 5 to match the actual template structure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/commands/open-pr.md around lines 19 - 26, Step 5 in open-pr.md
references PR template sections that do not exist in the actual
.github/PULL_REQUEST_TEMPLATE.md file. Update Step 5 to remove instructions for
non-existent sections (Screenshots, Testing, Reference Links/Dependencies) and
keep only the sections that are actually present in the template: Why?, Type of
Change, What Changed?, and Checklist. Ensure the instructions accurately reflect
what users will encounter when they create the PR so there is no contradiction
with the earlier instruction on line 14 to read the template structure.

- **Merge Rules** — keep the table exactly as-is, do not modify it
6. Return the PR URL

## PR title format

`<emoji> <Type> | <short description>`

| Emoji | Type | When to use |
|-------|----------|--------------------------------------------------|
| ✨ | Feat | New functionality (including analytics/tracking) |
| 🐛 | Fix | Bug fixes |
| ♻️ | Refactor | Restructuring without behavior change |
| 🚀 | Deploy | Merge to main (branch: `release/*`) |
| 🚑️ | Hotfix | Critical production fixes (branch: `hotfix/*`) |
| 🔒️ | Sec | Security fixes or improvements |
| 📝 | Docs | Documentation-only changes |
| ⬆️ | Deps | Dependency upgrades or additions |
| ✅ | Test | Adding/updating tests, no production code change |
22 changes: 22 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## Why?

> Describe the problem or motivation behind this change.

## Type of change

- [ ] Bug fix (non-breaking)
- [ ] New feature (non-breaking)
- [ ] Breaking change (affects public API or wire format)
- [ ] Internal / refactor (no public API impact)

## What changed?

> Summarize what was modified — types, components, renderer behavior, etc.
> For breaking changes, describe what consumers need to update.

## Checklist

- [ ] `bun test` passes
- [ ] `bun run typecheck` passes
- [ ] Public API changes are reflected in the README
- [ ] Breaking changes are noted above
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,32 +59,54 @@ export function App({ blueprint }) {

## Adding a custom brick type

Bricks are just React components keyed by a string. Compose the map by spreading:
Every BrickComponent is a plain React component. The `data` fields from the JSON arrive as props alongside `id` (the brick's unique id) and `children` (already-rendered subtree, if any).

```tsx
// inline — no helper needed
const brickMap = {
badge: ({ label }: { label: string }) => (
<span className="badge">{label}</span>
),
};
```

Use `defineBrick` when you want the discriminator string and the data type locked together:

```tsx
import { defineBrick } from "bricks-sdui/core";
import { webBricks } from "bricks-sdui/web";

const badge = defineBrick<"badge", { label: string }>(
"badge",
({ brick }) => <span className="badge">{brick.data?.label}</span>,
({ label }) => <span className="badge">{label}</span>,
);

const bricks = { ...webBricks, ...badge };
```

`defineBrick` returns a one-key object (`{ badge: BadgeComponent }`) so the discriminator string and the component's data type stay locked together. You can also assemble the map directly without `defineBrick`:
`defineBrick` returns a one-key object (`{ badge: BadgeComponent }`) so you can spread multiple definitions cleanly. You can also assemble the map directly:

```tsx
const bricks = {
...webBricks,
badge: BadgeComponent,
video: VideoComponent,
badge: ({ label }: { label: string }) => <span>{label}</span>,
video: ({ src, title }: { src: string; title?: string }) => (
<video src={src} aria-label={title} controls />
),
};
```

Now the backend can ship `{ "type": "badge", "data": { "label": "New" } }` and it renders.

The `id` prop is always available if you need it for `data-testid`, aria attributes, or analytics:

```tsx
const badge = defineBrick<"badge", { label: string }>(
"badge",
({ id, label }) => <span data-testid={id}>{label}</span>,
);
```

> Keep the map at module scope or wrap it in `useMemo`. Passing a new object every render will change the provider's context value and cascade re-renders.

## What's in the box
Expand Down
2 changes: 1 addition & 1 deletion src/core/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { BrickComponent } from "./types";
*
* const bricks = { ...webBricks, ...badge };
*/
export function defineBrick<T extends string, D>(
export function defineBrick<T extends string, D extends object>(
type: T,
component: BrickComponent<D>,
): Record<T, BrickComponent<D>> {
Expand Down
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type {
Brick,
BrickAction,
BrickComponent,
BrickComponentProps,
BrickProps,
BricksMap,
BrickStyle,
StyleValue,
Expand Down
3 changes: 2 additions & 1 deletion src/core/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export const BrickNode = memo(function BrickNode({ brick }: { brick: Brick }) {
</Fragment>
) : undefined;

return <Component brick={brick}>{children}</Component>;
const props = { id: brick.id, ...(brick.data as object | undefined), children };
return <Component {...(props as any)} />;
Comment on lines +34 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve canonical brick identity when flattening props.

Line 34 currently lets brick.data.id overwrite brick.id because of spread order. That breaks the flat-props contract and can propagate incorrect IDs into adapters (data-brick-id, analytics hooks, test selectors). Ensure renderer-controlled fields win.

🔧 Proposed fix
-  const props = { id: brick.id, ...(brick.data as object | undefined), children };
+  const props = { ...(brick.data as object | undefined), id: brick.id, children };
   return <Component {...(props as any)} />;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const props = { id: brick.id, ...(brick.data as object | undefined), children };
return <Component {...(props as any)} />;
const props = { ...(brick.data as object | undefined), id: brick.id, children };
return <Component {...(props as any)} />;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/renderer.tsx` around lines 34 - 35, The props object on line 34 is
spreading brick.data after setting id, which allows brick.data.id to overwrite
the canonical brick.id value. This breaks the flat-props contract. Reorder the
spread operation in the props object so that the id assignment comes after
spreading brick.data, ensuring the renderer-controlled brick.id field takes
precedence and cannot be overwritten by any id property in brick.data.

});

/**
Expand Down
12 changes: 6 additions & 6 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ export interface Brick<T extends string = string, D = unknown> {
/** A Blueprint is the root Brick of a screen/section. */
export type Blueprint = Brick;

/** Props every BrickComponent receives. */
export interface BrickComponentProps<D = unknown> {
brick: Brick<string, D>;
/** Already-rendered children (React nodes), if the brick had `bricks: []`. */
/** Props every BrickComponent receives: the brick's data fields plus id and children. */
export type BrickProps<D extends object = object> = D & {
/** Brick id — use for data-testid, aria attributes, or analytics. */
id: string;
children?: ReactNode;
}
};

/** A registered renderer for one BrickType. */
export type BrickComponent<D = unknown> = ComponentType<BrickComponentProps<D>>;
export type BrickComponent<D extends object = object> = ComponentType<BrickProps<D>>;

/**
* Plain map of brick type → React component. This is the contract passed to
Expand Down
16 changes: 8 additions & 8 deletions src/web/adapters/Container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,22 @@ export interface ContainerData {
}

export const Container: BrickComponent<ContainerData> = function Container({
brick,
id,
style: rawStyle,
action,
domId,
children,
}) {
const { theme } = useBricks();
const dispatch = useBrickAction();
const style = resolveStyle(brick.data?.style, theme) as
| CSSProperties
| undefined;
const action = brick.data?.action;
const style = resolveStyle(rawStyle, theme) as CSSProperties | undefined;

if (action) {
return (
<button
type="button"
id={brick.data?.domId}
data-brick-id={brick.id}
id={domId}
data-brick-id={id}
style={{
all: "unset",
cursor: "pointer",
Expand All @@ -49,7 +49,7 @@ export const Container: BrickComponent<ContainerData> = function Container({
}

return (
<div id={brick.data?.domId} data-brick-id={brick.id} style={style}>
<div id={domId} data-brick-id={id} style={style}>
{children}
</div>
);
Expand Down
21 changes: 10 additions & 11 deletions src/web/adapters/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,20 @@ export interface ImageData {
fit?: "cover" | "contain" | "fill" | "none" | "scale-down";
}

export const Image: BrickComponent<ImageData> = function Image({ brick }) {
export const Image: BrickComponent<ImageData> = function Image({
id,
src,
alt,
style: rawStyle,
fit,
}) {
const { theme } = useBricks();
const data = brick.data;
if (!data) return null;
const baseStyle: CSSProperties = { objectFit: data.fit ?? "cover" };
const baseStyle: CSSProperties = { objectFit: fit ?? "cover" };
const style = {
...baseStyle,
...((resolveStyle(data.style, theme) ?? {}) as CSSProperties),
...((resolveStyle(rawStyle, theme) ?? {}) as CSSProperties),
};
return (
<img
data-brick-id={brick.id}
src={data.src}
alt={data.alt ?? ""}
style={style}
/>
<img data-brick-id={id} src={src} alt={alt ?? ""} style={style} />
);
};
17 changes: 10 additions & 7 deletions src/web/adapters/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@ export interface TextData {
style?: BrickStyle;
}

export const Text: BrickComponent<TextData> = function Text({ brick }) {
export const Text: BrickComponent<TextData> = function Text({
id,
value,
as,
style: rawStyle,
}) {
const { theme } = useBricks();
const data = brick.data;
if (!data) return null;
const Tag = (data.as ?? "span") as keyof JSX.IntrinsicElements;
const style = resolveStyle(data.style, theme) as CSSProperties | undefined;
const Tag = (as ?? "span") as keyof JSX.IntrinsicElements;
const style = resolveStyle(rawStyle, theme) as CSSProperties | undefined;
return (
<Tag data-brick-id={brick.id} style={style}>
{data.value}
<Tag data-brick-id={id} style={style}>
{value}
</Tag>
);
};
2 changes: 1 addition & 1 deletion test/renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe("BrickRenderer", () => {
test("custom brick types via defineBrick + spread", () => {
const badge = defineBrick<"badge", { label: string }>(
"badge",
({ brick }) => <em data-test="badge">{brick.data?.label}</em>,
({ label }) => <em data-test="badge">{label}</em>,
);
const bricks = { ...webBricks, ...badge };
const bp: Blueprint = { id: "r", type: "badge", data: { label: "NEW" } };
Expand Down