Use each catalog article’s Figma body ornaments instead of one shared pair.
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -44,6 +44,9 @@ npm-cache/
|
||||
!public/favicon-32x32.png
|
||||
!public/apple-touch-icon.png
|
||||
|
||||
# Per-article body ornaments (Figma Content page Template image fills)
|
||||
!public/content/blog/*-ornament-*.png
|
||||
|
||||
# Visual regression snapshots (allow these)
|
||||
!tests/e2e/visual-regression.spec.ts-snapshots/
|
||||
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
* - 19003:23576 / 19000:23009 — left
|
||||
*/
|
||||
import {
|
||||
ASSETS,
|
||||
getAssetPath,
|
||||
howItWorksOrnamentLeftPath,
|
||||
howItWorksOrnamentRightPath,
|
||||
resolveArticleOrnamentPath,
|
||||
} from "../../../lib/assetUtils";
|
||||
|
||||
type ContentTemplateDecorativeShapesVariant = "guide" | "article";
|
||||
|
||||
type ContentTemplateDecorativeShapesProps = {
|
||||
variant: ContentTemplateDecorativeShapesVariant;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
const RIGHT_GUIDE_CLASS =
|
||||
@@ -30,8 +31,15 @@ const LEFT_ARTICLE_CLASS =
|
||||
|
||||
export default function ContentTemplateDecorativeShapes({
|
||||
variant,
|
||||
slug,
|
||||
}: ContentTemplateDecorativeShapesProps) {
|
||||
const isArticle = variant === "article";
|
||||
const rightSrc = isArticle
|
||||
? resolveArticleOrnamentPath(slug, "right")
|
||||
: getAssetPath(howItWorksOrnamentRightPath());
|
||||
const leftSrc = isArticle
|
||||
? resolveArticleOrnamentPath(slug, "left")
|
||||
: getAssetPath(howItWorksOrnamentLeftPath());
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -42,11 +50,7 @@ export default function ContentTemplateDecorativeShapes({
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={
|
||||
isArticle
|
||||
? getAssetPath(ASSETS.CONTENT_SHAPE_1)
|
||||
: getAssetPath(howItWorksOrnamentRightPath())
|
||||
}
|
||||
src={rightSrc}
|
||||
alt=""
|
||||
className="pointer-events-none size-full max-w-none object-contain"
|
||||
/>
|
||||
@@ -58,11 +62,7 @@ export default function ContentTemplateDecorativeShapes({
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={
|
||||
isArticle
|
||||
? getAssetPath(ASSETS.CONTENT_SHAPE_2)
|
||||
: getAssetPath(howItWorksOrnamentLeftPath())
|
||||
}
|
||||
src={leftSrc}
|
||||
alt=""
|
||||
className="pointer-events-none size-full max-w-none object-contain"
|
||||
/>
|
||||
|
||||
@@ -208,7 +208,10 @@ export default async function BlogPostPage({ params }: PageProps) {
|
||||
<ContentBanner post={post} />
|
||||
|
||||
<div className="relative w-full overflow-x-clip">
|
||||
<ContentTemplateDecorativeShapes variant="article" />
|
||||
<ContentTemplateDecorativeShapes
|
||||
variant="article"
|
||||
slug={post.slug}
|
||||
/>
|
||||
|
||||
<article
|
||||
data-node-id="19031:10426"
|
||||
|
||||
@@ -92,6 +92,7 @@ Each article uses SVGs in `public/content/blog/`. For a new article, duplicate a
|
||||
- `{slug}-vertical.svg` — 260px × 390px
|
||||
- `{slug}-horizontal.svg` — 320px × 225.5px (minimum width)
|
||||
- `{slug}-section.svg` — optional md+ banner (1920×672)
|
||||
- `{slug}-ornament-right.png` / `{slug}-ornament-left.png` — body gutter shapes
|
||||
- `{slug}-tag.svg` — tag mark for content cards
|
||||
3. **Customize** the copied SVGs for your article
|
||||
4. **Add to frontmatter**:
|
||||
|
||||
@@ -39,7 +39,8 @@ Do not duplicate the same glyph in both places unless migrating between systems.
|
||||
`shareIconPath`, `featurePanelPath`, `ASSETS`, …) over hardcoded
|
||||
`/assets/...` strings in components.
|
||||
- **Blog art** stays under `public/content/blog/` with
|
||||
`{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`.
|
||||
`{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`,
|
||||
and `-ornament-right.png` / `-ornament-left.png` (body gutter shapes).
|
||||
- **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in
|
||||
`app/layout.tsx` metadata) plus generated root binaries for Safari/iOS:
|
||||
`public/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, and
|
||||
|
||||
@@ -170,6 +170,14 @@ export function contentBlogTagPath(slug: string): string {
|
||||
return `/content/blog/${slug}-tag.svg`;
|
||||
}
|
||||
|
||||
/** Body gutter ornaments (Figma Content page Template 19003:23575 / 19003:23576). */
|
||||
export function contentBlogOrnamentPath(
|
||||
slug: string,
|
||||
side: "right" | "left",
|
||||
): string {
|
||||
return `/content/blog/${slug}-ornament-${side}.png`;
|
||||
}
|
||||
|
||||
/** Stable catalog slug order for blog asset fallbacks when an article has no custom SVGs. */
|
||||
export const CONTENT_CATALOG_SLUG_ORDER = [
|
||||
"resolving-active-conflicts",
|
||||
@@ -182,6 +190,21 @@ export const CONTENT_CATALOG_SLUG_ORDER = [
|
||||
"knowledge-management-and-institutional-amnesia",
|
||||
] as const;
|
||||
|
||||
export function resolveArticleOrnamentPath(
|
||||
slug: string | undefined,
|
||||
side: "right" | "left",
|
||||
): string {
|
||||
const hasCatalogArt =
|
||||
!!slug &&
|
||||
(CONTENT_CATALOG_SLUG_ORDER as readonly string[]).includes(slug);
|
||||
if (hasCatalogArt && slug) {
|
||||
return contentBlogOrnamentPath(slug, side);
|
||||
}
|
||||
return getAssetPath(
|
||||
side === "right" ? ASSETS.CONTENT_SHAPE_1 : ASSETS.CONTENT_SHAPE_2,
|
||||
);
|
||||
}
|
||||
|
||||
/** Pick a catalog article whose `public/content/blog/` SVGs can stand in as fallbacks. */
|
||||
export function contentCatalogSlugForFallback(
|
||||
slug: string,
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -242,6 +242,29 @@ describe("BlogPostPage", () => {
|
||||
expect(shapes[1]).toHaveClass("object-contain");
|
||||
});
|
||||
|
||||
it("uses per-article body ornaments for catalog posts", async () => {
|
||||
const { getBlogPostBySlug } = await import("../../lib/content");
|
||||
vi.mocked(getBlogPostBySlug).mockReturnValue({
|
||||
...mockPost,
|
||||
slug: "resolving-active-conflicts",
|
||||
});
|
||||
|
||||
const BlogPostPageComponent = await BlogPostPage({
|
||||
params: { slug: "resolving-active-conflicts" },
|
||||
});
|
||||
render(BlogPostPageComponent);
|
||||
|
||||
const shapes = screen.getAllByAltText("");
|
||||
expect(shapes[0]).toHaveAttribute(
|
||||
"src",
|
||||
"/content/blog/resolving-active-conflicts-ornament-right.png",
|
||||
);
|
||||
expect(shapes[1]).toHaveAttribute(
|
||||
"src",
|
||||
"/content/blog/resolving-active-conflicts-ornament-left.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies correct styling to article content", async () => {
|
||||
const BlogPostPageComponent = await BlogPostPage({
|
||||
params: { slug: "test-article" },
|
||||
|
||||