From 48bd15533cbc80e0733a11a38e585e93f25b1cd4 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 13 Jul 2026 17:55:36 +0300 Subject: [PATCH 1/9] fix: drop page grain behind content so cards and badges cover it --- src/shared/styles/theme.css | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 869a1b0..5d280ce 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -151,7 +151,12 @@ outline-offset: 2px; } + /* Background lives on html (not body) so the grain overlay can sit behind + * body's content at a negative z-index and still paint against the cream + * canvas — with the background on body, a z-index:-1 body::before would hide + * behind body's own background instead of showing through the page gaps. */ html { + @apply bg-background; font-size: var(--font-size); scroll-behavior: smooth; /* Reserve scrollbar gutter so locking body scroll (e.g. when a modal @@ -160,23 +165,24 @@ } body { - @apply bg-background text-foreground; + @apply text-foreground; font-family: var(--font-body); font-weight: var(--font-weight-body); line-height: var(--line-height-tight); overflow-x: hidden; } - /* Page-wide blue dot grain overlay. z-index 100 puts it above the footer - * (z-50) so the grain reads as continuous across the entire viewport; - * pointer-events: none keeps everything clickable through it. */ + /* Page-wide blue dot grain. z-index -1 drops it behind body's content so + * opaque surfaces (cards, badges, footer) cover it and the grain only shows + * through the transparent page gaps; pointer-events: none keeps everything + * clickable through it. */ body::before { @apply grain-pattern; content: ""; position: fixed; inset: 0; pointer-events: none; - z-index: 100; + z-index: -1; } h1, From 44abc362bc1289578509cacd6f74a89500ce592e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:34:24 +0300 Subject: [PATCH 2/9] refactor(theme): muted offset shadows over hard brutalist stamp --- src/shared/styles/theme.css | 72 +++++++++++++++++++----- src/shared/ui/Button/ui/Button.tsx | 14 ++--- src/shared/ui/TechStack/ui/TechStack.tsx | 2 +- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 5d280ce..54e87e7 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -69,17 +69,21 @@ --space-20: 10rem; /* === BORDERS === */ - --border-width: 3px; - --radius: 0px; + --border-width: 1.5px; + --radius: 5px; - /* === BRUTALIST SHADOWS === */ - --shadow-brutal-xs: 1px 1px 0 var(--blue); - --shadow-brutal-sm: 3px 3px 0 var(--blue); - --shadow-brutal: 5px 5px 0 var(--blue); - --shadow-brutal-md: 7px 7px 0 var(--blue); - --shadow-brutal-lg: 8px 8px 0 var(--blue); - --shadow-brutal-xl: 10px 10px 0 var(--blue); - --shadow-brutal-2xl: 12px 12px 0 var(--blue); + /* Muted offset shadow — quieter than the old hard-blue brutalist stamp. + * Softer alpha + tighter offsets keep the offset-shadow feel, just lighter. */ + --shadow-color: rgba(4, 28, 243, 0.25); + + /* === OFFSET SHADOWS === */ + --shadow-brutal-xs: 1px 1px 0 var(--shadow-color); + --shadow-brutal-sm: 2px 2px 0 var(--shadow-color); + --shadow-brutal: 3px 3px 0 var(--shadow-color); + --shadow-brutal-md: 3px 3px 0 var(--shadow-color); + --shadow-brutal-lg: 4px 4px 0 var(--shadow-color); + --shadow-brutal-xl: 5px 5px 0 var(--shadow-color); + --shadow-brutal-2xl: 6px 6px 0 var(--shadow-color); /* === GRID === */ --grid-gap: var(--space-3); @@ -237,9 +241,46 @@ } } -/* Button elevation transition — only transform animates; shadow snaps instantly */ -.btn-transition { - transition: transform 0.13s var(--ease-micro); +/* Button elevation transition — transform + shadow animate together over 130ms + * on the micro easing curve; the spring feel comes from the curve, no keyframes. */ +@utility btn-transition { + transition: transform 130ms var(--ease-micro); +} + +/* Offset "shadow" as a static pseudo-block instead of box-shadow. box-shadow + * swims because it repaints on the main thread while the button's translate + * runs on the compositor — the two desync per frame. Here both the button and + * its ::before are transforms, so they stay frame-synced. The ::before counter- + * translates to hold a fixed 3px world offset while the button lifts/presses, + * so the shadow appears frozen and only the button moves. */ +.btn-shadow { + position: relative; + z-index: 0; + transition: transform 130ms var(--ease-micro); +} +.btn-shadow::before { + content: ""; + position: absolute; + /* ponytail: inset:0 tracks the padding box, ~1.5px shy of the border box; + * imperceptible on a hard offset block. Widen with negative inset if it shows. */ + inset: 0; + z-index: -1; + border-radius: inherit; + background: var(--shadow-color); + transform: translate(3px, 3px); + transition: transform 130ms var(--ease-micro); +} +.btn-shadow:hover { + transform: translate(-2px, -2px); +} +.btn-shadow:hover::before { + transform: translate(5px, 5px); +} +.btn-shadow:active { + transform: translate(2px, 2px); +} +.btn-shadow:active::before { + transform: translate(1px, 1px); } /* Brutalist utility classes */ @@ -254,6 +295,7 @@ } @utility brutal-border { border: var(--border-width) solid var(--blue); + border-radius: var(--radius); } @utility brutal-border-top { border-top: var(--border-width) solid var(--blue); @@ -272,6 +314,7 @@ * ancestor must not have overflow:hidden or the outline gets clipped. */ @utility brutal-outline { outline: var(--border-width) solid var(--blue); + border-radius: var(--radius); } /* Tiled blue dot pattern — applied to body::before (page-wide) and reusable * on any surface that should share the same paper-grain texture. The SVG @@ -479,6 +522,9 @@ dialog.lightbox { box-sizing: content-box; max-width: calc(100vw - 2rem); max-height: calc(100vh - 10rem); + /* Match the wrapper's brutal-outline radius so image corners sit flush + * inside the rounded outline instead of poking past it. */ + border-radius: var(--radius); } /* Disable transition animation for Firefox diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx index c3a8103..d5a71ee 100644 --- a/src/shared/ui/Button/ui/Button.tsx +++ b/src/shared/ui/Button/ui/Button.tsx @@ -41,14 +41,12 @@ function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor { } const VARIANTS = { - primary: - 'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]', - secondary: - 'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs', + primary: 'brutal-border bg-blue text-cream btn-shadow', + secondary: 'brutal-border bg-blue text-cream btn-shadow', outline: 'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream', ghost: - 'brutal-border bg-transparent text-blue hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5', + 'brutal-border bg-transparent text-blue btn-transition hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5', } as const satisfies Record; const SIZES = { @@ -57,9 +55,9 @@ const SIZES = { lg: 'px-8 py-4 text-lg', } as const satisfies Record; -/* box-shadow excluded from transition intentionally — snaps instantly so the - * eye follows the 130ms button movement, not the shadow change. */ -const BASE = 'cursor-pointer btn-transition uppercase tracking-wider'; +/* Elevation lives per-variant: primary/secondary use btn-shadow (static offset + * block, button moves), ghost uses btn-transition + translate. */ +const BASE = 'cursor-pointer uppercase tracking-wider'; /** * Brutalist button with variants and sizes. diff --git a/src/shared/ui/TechStack/ui/TechStack.tsx b/src/shared/ui/TechStack/ui/TechStack.tsx index ea5de63..1dcd0e4 100644 --- a/src/shared/ui/TechStack/ui/TechStack.tsx +++ b/src/shared/ui/TechStack/ui/TechStack.tsx @@ -19,7 +19,7 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
From 1bd76d7061106046baf22e8073acb13eedf35396 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:35:23 +0300 Subject: [PATCH 3/9] fix(theme): squircle list markers, keep CMS li text inline --- src/shared/styles/theme.css | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 54e87e7..e5e90f4 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -378,16 +378,28 @@ margin-top: 0; } +/* Some CMS output wraps li text in a block

(

  • ), which drops + * the text below the marker. Keep it inline so marker and text share a line. */ +.rich-text ul li > p { + display: inline; + margin: 0; +} + .rich-text ul li::before { - content: "◆"; + content: ""; display: inline-block; - width: calc(1.5em / 0.55); - /* reset inherited text-indent so glyph isn't shifted inside the ::before box */ + /* 0.5em glyph + 1em gap = 1.5em advance, filling the hanging indent */ + width: 0.5em; + height: 0.5em; + margin-right: 1em; + /* reset inherited text-indent so the box isn't shifted */ text-indent: 0; - color: var(--blue); - font-size: 0.55em; - /* line-height matches parent so diamond centers within the first line box */ - line-height: calc(var(--line-height-relaxed) / 0.55); + background: var(--blue); + /* squircle: corner-shape is progressive enhancement, ignored where unsupported */ + border-radius: 30%; + /* biome-ignore lint/correctness/noUnknownProperty: corner-shape is valid CSS, newer than Biome's property list */ + corner-shape: squircle; + vertical-align: middle; } /* Cross-section view transition (navigation between sections) */ From 59304f4ae3f5a00f6181da87bbb5b99e14103c07 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:35:35 +0300 Subject: [PATCH 4/9] feat(theme): golden-ratio width cap for tag groups --- .../experience/ui/ExperienceCard/ExperienceCard.tsx | 3 ++- src/entities/project/ui/ProjectCard/ProjectCard.tsx | 3 ++- src/shared/styles/theme.css | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx b/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx index 1208d4d..a7fc021 100644 --- a/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx +++ b/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx @@ -41,7 +41,8 @@ export function ExperienceCard({ title, company, period, description, stack, cla

    {period}

    {company}

    {stack.length > 0 && ( -
    + /* Golden-ratio cap for the stacked (top) tablet layout; full width on mobile, and lg sidebar is already width-constrained. */ +
    {stack.map((tech) => ( {tech} diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.tsx index 6a1cd63..efc5e99 100644 --- a/src/entities/project/ui/ProjectCard/ProjectCard.tsx +++ b/src/entities/project/ui/ProjectCard/ProjectCard.tsx @@ -47,7 +47,8 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl, pri

    {year}

    {tags.length > 0 && ( -
    + /* Golden-ratio cap for the stacked (top) tablet layout; full width on mobile, and lg sidebar is already width-constrained. */ +
    {tags.map((tag) => ( {tag} diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index e5e90f4..aa13707 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -293,6 +293,13 @@ .brutal-shadow-lg { box-shadow: var(--shadow-brutal-lg); } +/* Golden-ratio (1/φ ≈ 0.618) cap for badge/tag groups so they form a tidy + * block instead of spanning the full width. Apply with a breakpoint prefix + * (e.g. sm:) — on mobile the group should use the full available width. */ +@utility max-w-golden { + max-width: 61.8%; +} + @utility brutal-border { border: var(--border-width) solid var(--blue); border-radius: var(--radius); From 7c6da0ca7369c44290ab9868bb8da110d7bdc00b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:35:49 +0300 Subject: [PATCH 5/9] feat(Stagger): staggered slide-in for list items --- src/shared/styles/theme.css | 24 +++++++++ src/shared/types/react-css.d.ts | 10 ++++ src/shared/ui/Stagger/index.ts | 1 + src/shared/ui/Stagger/ui/Stagger.stories.tsx | 30 +++++++++++ src/shared/ui/Stagger/ui/Stagger.test.tsx | 53 +++++++++++++++++++ src/shared/ui/Stagger/ui/Stagger.tsx | 39 ++++++++++++++ src/shared/ui/index.ts | 1 + .../ExperienceSection/ExperienceSection.tsx | 20 +++---- .../ui/ProjectsSection/ProjectsSection.tsx | 22 ++++---- .../ui/SkillsSection/SkillsSection.tsx | 12 +++-- 10 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 src/shared/types/react-css.d.ts create mode 100644 src/shared/ui/Stagger/index.ts create mode 100644 src/shared/ui/Stagger/ui/Stagger.stories.tsx create mode 100644 src/shared/ui/Stagger/ui/Stagger.test.tsx create mode 100644 src/shared/ui/Stagger/ui/Stagger.tsx diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index aa13707..f9fed13 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -483,6 +483,30 @@ } } +/* Per-item slide-in reusing the section-body distance/easing. Stagger via + * --stagger-index set inline by the consumer. */ +.stagger-in { + animation: stagger-in var(--duration-spring) var(--ease-spring) both; + animation-delay: calc(var(--stagger-index, 0) * var(--stagger-step, 50ms)); +} + +@keyframes stagger-in { + from { + opacity: 0; + transform: translateX(var(--slide-section-body-in)); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .stagger-in { + animation: none; + } +} + /* Page-load entry animation for the footer. Previously also carried * `view-transition-name: site-footer` to layer above section-body's slide * group, but the section-body group now has `animation: none` (purely diff --git a/src/shared/types/react-css.d.ts b/src/shared/types/react-css.d.ts new file mode 100644 index 0000000..e7ac750 --- /dev/null +++ b/src/shared/types/react-css.d.ts @@ -0,0 +1,10 @@ +import 'react'; + +/** + * Allow CSS custom properties in `style` objects without casts. + */ +declare module 'react' { + interface CSSProperties { + [key: `--${string}`]: string | number | undefined; + } +} diff --git a/src/shared/ui/Stagger/index.ts b/src/shared/ui/Stagger/index.ts new file mode 100644 index 0000000..3a7c4d7 --- /dev/null +++ b/src/shared/ui/Stagger/index.ts @@ -0,0 +1 @@ +export { Stagger } from './ui/Stagger'; diff --git a/src/shared/ui/Stagger/ui/Stagger.stories.tsx b/src/shared/ui/Stagger/ui/Stagger.stories.tsx new file mode 100644 index 0000000..c01fbab --- /dev/null +++ b/src/shared/ui/Stagger/ui/Stagger.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Badge } from '../../Badge'; +import { Stagger } from './Stagger'; + +const meta: Meta = { + title: 'Shared/Stagger', + component: Stagger, +}; + +export default meta; + +type Story = StoryObj; + +const ITEMS = ['React', 'TypeScript', 'Next.js', 'Node', 'CSS', 'Vitest']; + +/** + * Each item slides in from the right with a 50ms-per-index delay. + * Refresh the story to replay the entry animation. + */ +export const Staggered: Story = { + render: () => ( +
    + {ITEMS.map((item, i) => ( + + {item} + + ))} +
    + ), +}; diff --git a/src/shared/ui/Stagger/ui/Stagger.test.tsx b/src/shared/ui/Stagger/ui/Stagger.test.tsx new file mode 100644 index 0000000..54b3b28 --- /dev/null +++ b/src/shared/ui/Stagger/ui/Stagger.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import { Stagger } from './Stagger'; + +describe('Stagger', () => { + describe('rendering', () => { + it('renders children', () => { + render(Item); + expect(screen.getByText('Item')).toBeInTheDocument(); + }); + + it('applies the stagger-in class', () => { + render(Item); + expect(screen.getByText('Item')).toHaveClass('stagger-in'); + }); + + it('merges custom className', () => { + render( + + Item + , + ); + expect(screen.getByText('Item')).toHaveClass('stagger-in', 'mt-4'); + }); + }); + + describe('stagger index', () => { + it('sets the --stagger-index custom property', () => { + render(Item); + expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('3'); + }); + + it('sets index 0 for the first item', () => { + render(Item); + expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('0'); + }); + }); + + describe('step', () => { + it('omits --stagger-step by default', () => { + render(Item); + expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe(''); + }); + + it('sets --stagger-step in ms when provided', () => { + render( + + Item + , + ); + expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe('120ms'); + }); + }); +}); diff --git a/src/shared/ui/Stagger/ui/Stagger.tsx b/src/shared/ui/Stagger/ui/Stagger.tsx new file mode 100644 index 0000000..c4f5b8e --- /dev/null +++ b/src/shared/ui/Stagger/ui/Stagger.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react'; +import { cn } from '$shared/lib'; + +interface Props { + /** + * Position in the list — drives the per-item animation-delay via the + * --stagger-index custom property consumed by the .stagger-in keyframe. + */ + index: number; + /** + * Item to animate in. + */ + children: ReactNode; + /** + * Per-item delay in ms. Bump it for few/large items (cards) so the + * stagger reads as sequential rather than simultaneous. + * @default 50 + */ + step?: number; + /** + * Additional CSS classes merged onto the wrapper. + */ + className?: string; +} + +/** + * Wraps a list item so it slides in from the right with a per-index delay. + * Reuses the section-body slide distance/easing; respects reduced-motion. + */ +export function Stagger({ index, children, step, className }: Props) { + return ( +
    + {children} +
    + ); +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index b8ec55c..63d5a65 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -13,5 +13,6 @@ export { Modal, type ModalHandle } from './Modal'; export { RichText } from './RichText'; export type { ContainerSize, SectionBackground } from './Section'; export { Container, Section } from './Section'; +export { Stagger } from './Stagger'; export { TechStackBrick, TechStackGrid } from './TechStack'; export { ViewTransitionWrapper } from './ViewTransitionWrapper'; diff --git a/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx index 3becc5f..45f2287 100644 --- a/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx +++ b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx @@ -2,6 +2,7 @@ import { ExperienceCard } from '$entities/experience'; import type { ExperienceRecord } from '$shared/api'; import { getCollection } from '$shared/api'; import { formatMonthYearRange } from '$shared/lib'; +import { Stagger } from '$shared/ui'; /** * Experience section component. @@ -15,15 +16,16 @@ export default async function ExperienceSection() { return (
    - {items.map((exp) => ( - + {items.map((exp, i) => ( + + + ))}
    ); diff --git a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx index 97da991..47fdaea 100644 --- a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx +++ b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx @@ -2,6 +2,7 @@ import { ProjectCard } from '$entities/project'; import type { ProjectRecord } from '$shared/api'; import { getCollection } from '$shared/api'; import { buildFileUrl } from '$shared/lib'; +import { Stagger } from '$shared/ui'; /** * Projects section component. @@ -21,16 +22,17 @@ export default async function ProjectsSection() { return (
    {items.map((project, index) => ( - + + + ))}
    ); diff --git a/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx b/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx index 9c729c5..a704d35 100644 --- a/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx +++ b/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx @@ -2,7 +2,7 @@ import { notFound } from 'next/navigation'; import type { SkillRecord } from '$shared/api'; import { getCollection } from '$shared/api'; import { groupByKey } from '$shared/lib'; -import { Badge } from '$shared/ui'; +import { Badge, Stagger } from '$shared/ui'; /** * Skills section component. @@ -24,10 +24,12 @@ export default async function SkillsSection() {
    {Object.entries(categories).map(([category, items]) => (
    -

    {category}

    -
    - {items.map((skill) => ( - {skill.name} +

    {category}

    +
    + {items.map((skill, i) => ( + + {skill.name} + ))}
    From fd153741347bb4b68717a16b5d13618136fa42be Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:35:57 +0300 Subject: [PATCH 6/9] feat(Link): tertiary variant with reversed hover --- src/shared/ui/Link/ui/Link/Link.test.tsx | 13 +++++++++++++ src/shared/ui/Link/ui/Link/Link.tsx | 4 +++- src/widgets/Footer/ui/Footer/Footer.tsx | 4 ++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/shared/ui/Link/ui/Link/Link.test.tsx b/src/shared/ui/Link/ui/Link/Link.test.tsx index 5fce10a..49db5b4 100644 --- a/src/shared/ui/Link/ui/Link/Link.test.tsx +++ b/src/shared/ui/Link/ui/Link/Link.test.tsx @@ -12,6 +12,7 @@ import { Link } from './Link'; const PRIMARY = 'underline underline-offset-2 hover:opacity-60 transition-opacity'; const SECONDARY = 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity'; +const TERTIARY = 'no-underline opacity-100 hover:opacity-60 sm:brutal-border-bottom transition-opacity'; describe('internal link', () => { it('renders an anchor element', () => { @@ -92,6 +93,18 @@ describe('variant', () => { } }); + it('tertiary applies reversed-hover classes', () => { + render( + + About + , + ); + const link = screen.getByRole('link', { name: 'About' }); + for (const cls of TERTIARY.split(' ')) { + expect(link).toHaveClass(cls); + } + }); + it('secondary does not apply underline', () => { render( diff --git a/src/shared/ui/Link/ui/Link/Link.tsx b/src/shared/ui/Link/ui/Link/Link.tsx index b1327fa..2e29983 100644 --- a/src/shared/ui/Link/ui/Link/Link.tsx +++ b/src/shared/ui/Link/ui/Link/Link.tsx @@ -2,7 +2,7 @@ import NextLink from 'next/link'; import type { ReactNode } from 'react'; import { cn } from '$shared/lib'; -export type LinkVariant = 'primary' | 'secondary'; +export type LinkVariant = 'primary' | 'secondary' | 'tertiary'; /** * Props for Link. @@ -24,6 +24,7 @@ interface Props { * Visual variant. * primary — text-decoration underline. * secondary — border-bottom at sm+, no underline on mobile (for icon+label links). + * tertiary — like secondary but reversed: bright by default, dims on hover. * @default 'primary' */ variant?: LinkVariant; @@ -37,6 +38,7 @@ interface Props { const VARIANTS = { primary: 'underline underline-offset-2 hover:opacity-60 transition-opacity', secondary: 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity', + tertiary: 'no-underline opacity-100 hover:opacity-60 sm:brutal-border-bottom transition-opacity', } as const satisfies Record; /** diff --git a/src/widgets/Footer/ui/Footer/Footer.tsx b/src/widgets/Footer/ui/Footer/Footer.tsx index 6f78a05..c32b790 100644 --- a/src/widgets/Footer/ui/Footer/Footer.tsx +++ b/src/widgets/Footer/ui/Footer/Footer.tsx @@ -23,7 +23,7 @@ export async function Footer() {
    {email && ( - + {email.icon && } {email.label} @@ -33,7 +33,7 @@ export async function Footer() { key={social.id} href={social.url} external - variant="secondary" + variant="tertiary" className="flex items-center gap-1.5 text-sm" > {social.icon && } From f8d13193eaf8b17fee5f51615eb94b11784e0417 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:36:08 +0300 Subject: [PATCH 7/9] refactor(Badge): forward native span props via spread --- src/shared/ui/Badge/ui/Badge.tsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/shared/ui/Badge/ui/Badge.tsx b/src/shared/ui/Badge/ui/Badge.tsx index 9150fd5..0f79939 100644 --- a/src/shared/ui/Badge/ui/Badge.tsx +++ b/src/shared/ui/Badge/ui/Badge.tsx @@ -1,14 +1,10 @@ -import type { ReactNode } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; import { cn } from '$shared/lib'; export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline'; export type BadgeSize = 'xs' | 'sm' | 'md'; -interface Props { - /** - * Badge content - */ - children: ReactNode; +interface Props extends Pick, 'children' | 'className' | 'style'> { /** * Visual variant * @default 'default' @@ -19,10 +15,6 @@ interface Props { * @default 'sm' */ size?: BadgeSize; - /** - * Additional CSS classes - */ - className?: string; } const VARIANTS: Record = { @@ -41,9 +33,17 @@ const SIZES: Record = { /** * Small label for categorization or status. */ -export function Badge({ children, variant = 'default', size = 'sm', className }: Props) { +export function Badge({ children, variant = 'default', size = 'sm', className, ...rest }: Props) { return ( - + {children} ); From 03c51e2e8aae847115c2d8624aed0b8e3408138f Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:36:11 +0300 Subject: [PATCH 8/9] docs: rewrite README for real stack and rendering model --- README.md | 73 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e215bc4..18c025b 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,67 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# portfolio -## Getting Started +Personal portfolio site. Next.js App Router frontend, PocketBase as the CMS, +rendered with on-demand ISR — content is served static and revalidated by +webhook when the CMS changes. -First, run the development server: +## Stack + +- **Next.js 16** (App Router, React 19, standalone output) +- **TypeScript** (strict) +- **Tailwind CSS v4** +- **PocketBase** — headless CMS, fetched at build/runtime via a typed client +- **Biome** — lint + format +- **Vitest** + Testing Library — unit/component tests +- **Storybook** (a11y + Vitest addons) — component workshop +- **Yarn 4** + +Architecture follows [Feature-Sliced Design](https://feature-sliced.design/) +adapted to Next.js (`src/{shared,entities,widgets}` + `app/`). + +## Rendering + +Pages are statically generated and served through ISR. There is no fixed TTL — +PocketBase calls the `/api/revalidate` webhook after any content mutation, +which purges the matching cache tag (`revalidateTag`). See +[`app/api/revalidate/route.ts`](app/api/revalidate/route.ts). + +## Getting started ```bash -npm run dev -# or +yarn install yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open http://localhost:3000. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Without a reachable PocketBase, the app falls back to fixtures in +[`app/api/collections/[collection]/records/route.ts`](app/api/collections/%5Bcollection%5D/records/route.ts). -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Environment -## Learn More +``` +PB_URL # internal PocketBase URL (server-side fetches) +PB_PUBLIC_URL # public PocketBase URL (image optimizer allowlist, build-time) +REVALIDATE_SECRET # shared secret for the /api/revalidate webhook +``` -To learn more about Next.js, take a look at the following resources: +`PB_PUBLIC_URL` must be present at `next build` time — `images.remotePatterns` +is frozen into the build. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Scripts -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +| Command | Description | +|---|---| +| `yarn dev` | Dev server | +| `yarn build` | Production build (standalone) | +| `yarn start` | Serve the build | +| `yarn test` | Run tests (Vitest) | +| `yarn check` | Lint + format (Biome, writes fixes) | +| `yarn check:ci` | Lint + format check (no writes) | +| `yarn storybook` | Storybook on :6006 | -## Deploy on Vercel +## Deployment -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Built as a standalone Docker image; PocketBase reached over the internal +Docker network. Content updates trigger the revalidation webhook — no rebuild +needed. From e66114ce23cb45e6b9b035c4b556075f2eb41607 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:36:14 +0300 Subject: [PATCH 9/9] chore: pin turbopack root --- next.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/next.config.ts b/next.config.ts index a395085..b95431b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,6 +21,9 @@ const nextConfig: NextConfig = { experimental: { viewTransition: true, }, + turbopack: { + root: __dirname, + }, }; export default nextConfig;