From b63e34413d2970ea4c58377cf0f6fccde095a88b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 15:58:51 +0300 Subject: [PATCH 01/13] fix(theme): clip overflow-x at root, not body A scroll-container body makes Firefox clip descendant composited transforms that briefly overshoot the viewport. --- src/shared/styles/theme.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index f9fed13..7cee16b 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -166,6 +166,9 @@ /* Reserve scrollbar gutter so locking body scroll (e.g. when a modal * opens) doesn't widen the viewport and shift fixed elements. */ scrollbar-gutter: stable; + /* Clip at the root, not body: a scroll-container body makes Firefox clip + * descendant composited transforms that briefly overshoot the viewport. */ + overflow-x: hidden; } body { @@ -173,7 +176,6 @@ 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. z-index -1 drops it behind body's content so -- 2.54.0 From 03533159539538d07bc68b2e041f28f6b998a0b2 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 15:59:05 +0300 Subject: [PATCH 02/13] fix(theme): clamp h3 so it never overtakes the nav h1 --- src/shared/styles/theme.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 7cee16b..1ce1b34 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -213,7 +213,10 @@ font-size: var(--text-3xl); } h3 { - font-size: var(--text-2xl); + /* 5vw is tuned to stay under the nav h1 across the whole range: it only + * reaches the nav's 32px exactly at the 640px breakpoint where the nav + * itself steps up, so h3 never overtakes it. */ + font-size: clamp(1.5rem, 5vw, var(--text-2xl)); } h4 { font-size: var(--text-xl); -- 2.54.0 From 21c771cf24aa0b71703039682febddef94797ccc Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 15:59:19 +0300 Subject: [PATCH 03/13] fix(theme): center list bullets on text optical centre --- src/shared/styles/theme.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 1ce1b34..3d548d5 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -400,9 +400,8 @@ .rich-text ul li::before { content: ""; display: inline-block; - /* 0.5em glyph + 1em gap = 1.5em advance, filling the hanging indent */ - width: 0.5em; - height: 0.5em; + width: 0.4em; + height: 0.4em; margin-right: 1em; /* reset inherited text-indent so the box isn't shifted */ text-indent: 0; @@ -411,7 +410,10 @@ border-radius: 30%; /* biome-ignore lint/correctness/noUnknownProperty: corner-shape is valid CSS, newer than Biome's property list */ corner-shape: squircle; + /* Nudge up off the x-height midpoint so the bullet sits on the text's + * optical centre; em-relative so it scales with the text. */ vertical-align: middle; + transform: translateY(-0.1em); } /* Cross-section view transition (navigation between sections) */ -- 2.54.0 From d95840e12758ba0b36746928b35e85589f64424a Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 15:59:32 +0300 Subject: [PATCH 04/13] fix(theme): give footer its own view-transition group Without a named group the footer folds into the root snapshot, so every section-body transition paints the section cards over it until the transition ends. --- src/shared/styles/theme.css | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 3d548d5..bf0b644 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -514,14 +514,20 @@ } } -/* 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 - * horizontal slide of OLD/NEW snapshots), so the footer can live in the root - * snapshot during transitions — which also lets the lightbox dialog group - * stack cleanly above it. */ +/* Page-load entry animation for the footer. */ .footer-vt { animation: footer-enter var(--duration-slow) var(--ease-spring) both; + /* Own view-transition group so the footer is snapshotted separately instead + * of folding into the root group. Without it, every `section-body` transition + * paints the section (a named group) above the root — and thus above the + * footer — until the transition ends, flashing the cards over the footer. */ + view-transition-name: site-footer; +} + +/* Keep the footer group above section-body during transitions, below the + * lightbox groups (z=20/30) so the lightbox still opens over it. */ +::view-transition-group(site-footer) { + z-index: 10; } @keyframes footer-enter { -- 2.54.0 From e09fc467ba52367c4d6c730518b77f1f656fba62 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 15:59:48 +0300 Subject: [PATCH 05/13] docs(theme): trim verbose CSS comments to their point --- src/shared/styles/theme.css | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index bf0b644..09f6425 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -20,7 +20,6 @@ --font-weight-body: 600; --font-weight-normal: 400; - /* Fluid section title: scales from 2rem at ~267px to 8rem at ~1707px */ --text-section-title: clamp(2rem, 7.5vw, 8rem); /* === LINE HEIGHT === */ @@ -72,8 +71,7 @@ --border-width: 1.5px; --radius: 5px; - /* Muted offset shadow — quieter than the old hard-blue brutalist stamp. - * Softer alpha + tighter offsets keep the offset-shadow feel, just lighter. */ + /* Low-alpha blue so the offset shadow reads as a muted stamp, not a hard block. */ --shadow-color: rgba(4, 28, 243, 0.25); /* === OFFSET SHADOWS === */ @@ -246,18 +244,14 @@ } } -/* Button elevation transition — transform + shadow animate together over 130ms - * on the micro easing curve; the spring feel comes from the curve, no keyframes. */ +/* Spring feel comes from the easing curve, not 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. */ +/* Offset "shadow" as a pseudo-block, not box-shadow: box-shadow repaints on + * the main thread while the button's translate runs on the compositor, so the + * two desync per frame. Animating both as transforms keeps them frame-synced. */ .btn-shadow { position: relative; z-index: 0; @@ -328,20 +322,16 @@ 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 - * tile is rasterized once and composited cheaply via repeating background. */ +/* Shared paper-grain texture: a tiled blue dot for any surface that needs it. */ @utility grain-pattern { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E"); } -/* Apply Fraunces variable axes to non-heading elements using the heading font */ .font-wonk { font-variation-settings: "WONK" var(--fraunces-wonk), "SOFT" var(--fraunces-soft); } -/* Sidebar divider: bottom border on mobile, right border on desktop */ .brutal-border-sidebar { border-bottom: var(--border-width) solid var(--blue); } @@ -568,12 +558,9 @@ dialog.lightbox { z-index: 30; } -/* Lightbox image sizing — leaves vertical headroom for the fixed close button - * (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering, - * so the button never overlaps the image. - * box-sizing: content-box so max-w/max-h apply to the image pixels and the - * 3px brutal-border sits outside them — avoids subpixel clipping of the - * border by the dialog's overflow:hidden when both have border-box. */ +/* Lightbox image sizing — max-height leaves vertical headroom so the fixed + * close button never overlaps the image. content-box keeps the border outside + * the image pixels, avoiding subpixel clipping by the dialog's overflow:hidden. */ @utility lightbox-image { box-sizing: content-box; max-width: calc(100vw - 2rem); -- 2.54.0 From c0aab42e6c0cc9d3bd2abd63c6e44ad48b8752b9 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 16:19:22 +0300 Subject: [PATCH 06/13] refactor(Card): drive sidebar border off lg: utilities not a media query --- .../ui/ExperienceCard/ExperienceCard.test.tsx | 12 ++++++------ .../project/ui/ProjectCard/ProjectCard.test.tsx | 12 ++++++------ src/shared/styles/theme.css | 10 ---------- src/shared/ui/Card/ui/Card.test.tsx | 4 ++-- src/shared/ui/Card/ui/Card.tsx | 7 ++++++- 5 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/entities/experience/ui/ExperienceCard/ExperienceCard.test.tsx b/src/entities/experience/ui/ExperienceCard/ExperienceCard.test.tsx index 8b5158d..9bdad95 100644 --- a/src/entities/experience/ui/ExperienceCard/ExperienceCard.test.tsx +++ b/src/entities/experience/ui/ExperienceCard/ExperienceCard.test.tsx @@ -36,25 +36,25 @@ describe('ExperienceCard', () => { it('period badge is inside the sidebar column', () => { render(); const badge = screen.getByText('2021 – 2024'); - expect(badge.closest('.brutal-border-sidebar')).toBeInTheDocument(); + expect(badge.closest('[data-slot="card-sidebar"]')).toBeInTheDocument(); }); it('company name is inside the sidebar column', () => { render(); const company = screen.getByText('Acme Corp'); - expect(company.closest('.brutal-border-sidebar')).toBeInTheDocument(); + expect(company.closest('[data-slot="card-sidebar"]')).toBeInTheDocument(); }); it('title is outside the sidebar column', () => { render(); const title = screen.getByText('Senior Developer'); - expect(title.closest('.brutal-border-sidebar')).toBeNull(); + expect(title.closest('[data-slot="card-sidebar"]')).toBeNull(); }); it('description is outside the sidebar column', () => { render(); const desc = screen.getByText('Built scalable frontend systems.'); - expect(desc.closest('.brutal-border-sidebar')).toBeNull(); + expect(desc.closest('[data-slot="card-sidebar"]')).toBeNull(); }); }); @@ -88,8 +88,8 @@ describe('ExperienceCard', () => { render(); const react = screen.getByText('React'); const ts = screen.getByText('TypeScript'); - expect(react.closest('.brutal-border-sidebar')).toBeInTheDocument(); - expect(ts.closest('.brutal-border-sidebar')).toBeInTheDocument(); + expect(react.closest('[data-slot="card-sidebar"]')).toBeInTheDocument(); + expect(ts.closest('[data-slot="card-sidebar"]')).toBeInTheDocument(); expect(react).toHaveClass('brutal-border', 'bg-transparent', 'px-2'); }); diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx index 9d3db95..d9aff04 100644 --- a/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx +++ b/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx @@ -51,29 +51,29 @@ describe('ProjectCard', () => { describe('layout', () => { it('year is inside the sidebar column', () => { render(); - expect(screen.getByText('2024').closest('.brutal-border-sidebar')).toBeInTheDocument(); + expect(screen.getByText('2024').closest('[data-slot="card-sidebar"]')).toBeInTheDocument(); }); it('tags are inside the sidebar column', () => { render(); - expect(screen.getByText('React').closest('.brutal-border-sidebar')).toBeInTheDocument(); - expect(screen.getByText('Node').closest('.brutal-border-sidebar')).toBeInTheDocument(); + expect(screen.getByText('React').closest('[data-slot="card-sidebar"]')).toBeInTheDocument(); + expect(screen.getByText('Node').closest('[data-slot="card-sidebar"]')).toBeInTheDocument(); }); it('View Project button is inside the sidebar column', () => { render(); const btn = screen.getByRole('link', { name: /view project/i }); - expect(btn.closest('.brutal-border-sidebar')).toBeInTheDocument(); + expect(btn.closest('[data-slot="card-sidebar"]')).toBeInTheDocument(); }); it('title is outside the sidebar column', () => { render(); - expect(screen.getByText('My Project').closest('.brutal-border-sidebar')).toBeNull(); + expect(screen.getByText('My Project').closest('[data-slot="card-sidebar"]')).toBeNull(); }); it('description is outside the sidebar column', () => { render(); - expect(screen.getByText('A cool project description').closest('.brutal-border-sidebar')).toBeNull(); + expect(screen.getByText('A cool project description').closest('[data-slot="card-sidebar"]')).toBeNull(); }); }); diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 09f6425..6208b3c 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -332,16 +332,6 @@ "SOFT" var(--fraunces-soft); } -.brutal-border-sidebar { - border-bottom: var(--border-width) solid var(--blue); -} -@media (min-width: 1024px) { - .brutal-border-sidebar { - border-bottom: none; - border-right: var(--border-width) solid var(--blue); - } -} - /* Editorial rich-text typography */ .rich-text { max-width: 65ch; diff --git a/src/shared/ui/Card/ui/Card.test.tsx b/src/shared/ui/Card/ui/Card.test.tsx index 4f48ced..4c4bbed 100644 --- a/src/shared/ui/Card/ui/Card.test.tsx +++ b/src/shared/ui/Card/ui/Card.test.tsx @@ -91,10 +91,10 @@ describe('CardSidebar', () => { expect(container.firstChild).toHaveClass('flex'); }); - it('sidebar column has brutal-border-sidebar class', () => { + it('marks the sidebar column with a data-slot hook', () => { render(Sidebar}>Main); const sidebar = screen.getByText('Sidebar').parentElement; - expect(sidebar).toHaveClass('brutal-border-sidebar'); + expect(sidebar).toHaveAttribute('data-slot', 'card-sidebar'); }); it('sidebar column has fixed width on lg', () => { diff --git a/src/shared/ui/Card/ui/Card.tsx b/src/shared/ui/Card/ui/Card.tsx index 8e73a82..2a2a3a2 100644 --- a/src/shared/ui/Card/ui/Card.tsx +++ b/src/shared/ui/Card/ui/Card.tsx @@ -109,7 +109,12 @@ interface CardSidebarProps { export function CardSidebar({ sidebar, children, className }: CardSidebarProps) { return (
-
{sidebar}
+
+ {sidebar} +
{children}
); -- 2.54.0 From c3e8e38a58baa3b2d3f2a4d1b14ebc92c791c8b7 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 16:59:48 +0300 Subject: [PATCH 07/13] fix(theme): fluid nav-title size and section rhythm off clamp tokens --- .../Section/ui/SectionAccordion/SectionAccordion.tsx | 12 +++++++----- src/shared/styles/theme.css | 5 +++++ .../ui/SectionsAccordion/SectionsAccordion.tsx | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx b/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx index c6bd2dd..d02d582 100644 --- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx +++ b/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx @@ -38,10 +38,12 @@ export function SectionAccordion({ number, title, id, isActive, href, children } return (
{isActive ? ( -
+
-
-

{heading}

+
+

+ {heading} +

@@ -52,9 +54,9 @@ export function SectionAccordion({ number, title, id, isActive, href, children } - + {heading} diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 6208b3c..8fde364 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -21,6 +21,8 @@ --font-weight-normal: 400; --text-section-title: clamp(2rem, 7.5vw, 8rem); + /* Half of the active title so the active/inactive size ratio holds at every width */ + --text-nav-title: clamp(1.25rem, 3.75vw, 4rem); /* === LINE HEIGHT === */ --line-height-tight: 1.2; @@ -127,7 +129,10 @@ --container-section: var(--section-content-width); --spacing-footer: 5rem; --spacing-footer-wide: 4rem; + /* Active-section vertical rhythm — fluid so it tracks the clamp() title instead of jumping at sm */ + --spacing-section-gap: clamp(1.5rem, 5vw, 3.25rem); --text-section-title: var(--text-section-title); + --text-nav-title: var(--text-nav-title); --shadow-brutal-xs: var(--shadow-brutal-xs); --shadow-brutal-sm: var(--shadow-brutal-sm); diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx index 106523a..73cffcd 100644 --- a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx +++ b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx @@ -28,7 +28,7 @@ export function SectionsAccordion({ sections, activeSlug, children }: Props) { const slots = Children.toArray(children); return ( -
+
{sections.map((section, i) => ( Date: Tue, 14 Jul 2026 17:05:31 +0300 Subject: [PATCH 08/13] refactor: rename section item to SectionPanel, list to SectionAccordion --- app/[[...slug]]/page.tsx | 6 ++-- .../Section/ui/SectionAccordion/index.ts | 1 - .../SectionPanel.stories.tsx} | 10 +++--- .../SectionPanel.test.tsx} | 20 ++++++------ .../SectionPanel.tsx} | 4 +-- src/entities/Section/ui/SectionPanel/index.ts | 1 + src/entities/Section/ui/index.ts | 2 +- src/widgets/SectionAccordion/index.ts | 1 + .../SectionAccordion.test.tsx} | 32 +++++++++---------- .../ui/SectionAccordion/SectionAccordion.tsx} | 8 ++--- src/widgets/SectionsAccordion/index.ts | 1 - 11 files changed, 43 insertions(+), 43 deletions(-) delete mode 100644 src/entities/Section/ui/SectionAccordion/index.ts rename src/entities/Section/ui/{SectionAccordion/SectionAccordion.stories.tsx => SectionPanel/SectionPanel.stories.tsx} (75%) rename src/entities/Section/ui/{SectionAccordion/SectionAccordion.test.tsx => SectionPanel/SectionPanel.test.tsx} (73%) rename src/entities/Section/ui/{SectionAccordion/SectionAccordion.tsx => SectionPanel/SectionPanel.tsx} (92%) create mode 100644 src/entities/Section/ui/SectionPanel/index.ts create mode 100644 src/widgets/SectionAccordion/index.ts rename src/widgets/{SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx => SectionAccordion/ui/SectionAccordion/SectionAccordion.test.tsx} (77%) rename src/widgets/{SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx => SectionAccordion/ui/SectionAccordion/SectionAccordion.tsx} (85%) delete mode 100644 src/widgets/SectionsAccordion/index.ts diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx index 8bb725a..8c54b61 100644 --- a/app/[[...slug]]/page.tsx +++ b/app/[[...slug]]/page.tsx @@ -1,8 +1,8 @@ import { notFound } from 'next/navigation'; import type { SectionRecord } from '$entities/Section'; import { getCollection } from '$shared/api'; +import { SectionAccordion } from '$widgets/SectionAccordion'; import { SectionFactory } from '$widgets/SectionFactory'; -import { SectionsAccordion } from '$widgets/SectionsAccordion'; /** * Optional catchall: `/` → first section, `/:slug` → that section. @@ -46,11 +46,11 @@ export default async function SectionPage({ params }: Props) { return (
- + {sections.map((s) => ( ))} - +
); } diff --git a/src/entities/Section/ui/SectionAccordion/index.ts b/src/entities/Section/ui/SectionAccordion/index.ts deleted file mode 100644 index 25000d2..0000000 --- a/src/entities/Section/ui/SectionAccordion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './SectionAccordion'; diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.stories.tsx b/src/entities/Section/ui/SectionPanel/SectionPanel.stories.tsx similarity index 75% rename from src/entities/Section/ui/SectionAccordion/SectionAccordion.stories.tsx rename to src/entities/Section/ui/SectionPanel/SectionPanel.stories.tsx index 3523df2..70b2185 100644 --- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.stories.tsx +++ b/src/entities/Section/ui/SectionPanel/SectionPanel.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'; -import { SectionAccordion } from './SectionAccordion'; +import { SectionPanel } from './SectionPanel'; -const meta: Meta = { - title: 'Shared/SectionAccordion', - component: SectionAccordion, +const meta: Meta = { + title: 'Shared/SectionPanel', + component: SectionPanel, decorators: [ (Story) => (
@@ -15,7 +15,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Active: Story = { args: { diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx b/src/entities/Section/ui/SectionPanel/SectionPanel.test.tsx similarity index 73% rename from src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx rename to src/entities/Section/ui/SectionPanel/SectionPanel.test.tsx index 6c5d182..2de2a63 100644 --- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx +++ b/src/entities/Section/ui/SectionPanel/SectionPanel.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { SectionAccordion } from './SectionAccordion'; +import { SectionPanel } from './SectionPanel'; const defaultProps = { number: '01', @@ -10,30 +10,30 @@ const defaultProps = { children:

Content here

, }; -describe('SectionAccordion', () => { +describe('SectionPanel', () => { describe('collapsed state (isActive=false)', () => { it('renders a section element with the given id', () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector('section#about')).toBeInTheDocument(); }); it('renders a link with number and title', () => { - render(); + render(); expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument(); }); it('link points to the correct href', () => { - render(); + render(); expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about'); }); it('does not render children', () => { - render(); + render(); expect(screen.queryByText('Content here')).not.toBeInTheDocument(); }); it('does not render a button', () => { - render(); + render(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); }); @@ -42,17 +42,17 @@ describe('SectionAccordion', () => { const activeProps = { ...defaultProps, isActive: true }; it('renders an h1 with number and title', () => { - render(); + render(); expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument(); }); it('renders children', () => { - render(); + render(); expect(screen.getByText('Content here')).toBeInTheDocument(); }); it('does not render a link', () => { - render(); + render(); expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); }); diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx b/src/entities/Section/ui/SectionPanel/SectionPanel.tsx similarity index 92% rename from src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx rename to src/entities/Section/ui/SectionPanel/SectionPanel.tsx index d02d582..31c6495 100644 --- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx +++ b/src/entities/Section/ui/SectionPanel/SectionPanel.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; import { ViewTransitionWrapper } from '$shared/ui'; -interface SectionAccordionProps { +interface SectionPanelProps { /** * Display number prefix (e.g. "01") */ @@ -32,7 +32,7 @@ interface SectionAccordionProps { /** * Accordion-style section that collapses to a navigation link when inactive. */ -export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) { +export function SectionPanel({ number, title, id, isActive, href, children }: SectionPanelProps) { const heading = `${number}. ${title}`; return ( diff --git a/src/entities/Section/ui/SectionPanel/index.ts b/src/entities/Section/ui/SectionPanel/index.ts new file mode 100644 index 0000000..6281730 --- /dev/null +++ b/src/entities/Section/ui/SectionPanel/index.ts @@ -0,0 +1 @@ +export * from './SectionPanel'; diff --git a/src/entities/Section/ui/index.ts b/src/entities/Section/ui/index.ts index 25000d2..6281730 100644 --- a/src/entities/Section/ui/index.ts +++ b/src/entities/Section/ui/index.ts @@ -1 +1 @@ -export * from './SectionAccordion'; +export * from './SectionPanel'; diff --git a/src/widgets/SectionAccordion/index.ts b/src/widgets/SectionAccordion/index.ts new file mode 100644 index 0000000..e999d50 --- /dev/null +++ b/src/widgets/SectionAccordion/index.ts @@ -0,0 +1 @@ +export { SectionAccordion } from './ui/SectionAccordion/SectionAccordion'; diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx b/src/widgets/SectionAccordion/ui/SectionAccordion/SectionAccordion.test.tsx similarity index 77% rename from src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx rename to src/widgets/SectionAccordion/ui/SectionAccordion/SectionAccordion.test.tsx index 918c46b..0a52144 100644 --- a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx +++ b/src/widgets/SectionAccordion/ui/SectionAccordion/SectionAccordion.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import type { SectionRecord } from '$entities/Section'; -import { SectionsAccordion } from './SectionsAccordion'; +import { SectionAccordion } from './SectionAccordion'; const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' }; @@ -10,26 +10,26 @@ const sections: SectionRecord[] = [ { ...baseRecord, id: '3', slug: 'skills', title: 'Skills', order: 3 }, ]; -describe('SectionsAccordion', () => { +describe('SectionAccordion', () => { describe('active section rendering', () => { it('renders the active section as an h1', () => { render( - +
Intro content
Bio content
Skills content
-
, +
, ); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro'); }); it('renders inactive sections as links', () => { render( - +
Intro content
Bio content
Skills content
-
, +
, ); const links = screen.getAllByRole('link'); expect(links).toHaveLength(2); @@ -37,11 +37,11 @@ describe('SectionsAccordion', () => { it('inactive section links point to correct hrefs', () => { render( - +
Intro content
Bio content
Skills content
-
, +
, ); expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio'); expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills'); @@ -49,22 +49,22 @@ describe('SectionsAccordion', () => { it('renders the correct active section for a given activeSlug', () => { render( - +
Intro content
Bio content
Skills content
-
, +
, ); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio'); }); it('only one section is active at a time', () => { render( - +
Intro content
Bio content
Skills content
-
, +
, ); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); }); @@ -73,22 +73,22 @@ describe('SectionsAccordion', () => { describe('content slots', () => { it('shows active section content', () => { render( - +
Intro content
Bio content
Skills content
-
, +
, ); expect(screen.getByText('Intro content')).toBeInTheDocument(); }); it('does not show inactive section content', () => { render( - +
Intro content
Bio content
Skills content
-
, +
, ); expect(screen.queryByText('Bio content')).not.toBeInTheDocument(); expect(screen.queryByText('Skills content')).not.toBeInTheDocument(); diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx b/src/widgets/SectionAccordion/ui/SectionAccordion/SectionAccordion.tsx similarity index 85% rename from src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx rename to src/widgets/SectionAccordion/ui/SectionAccordion/SectionAccordion.tsx index 73cffcd..56df0eb 100644 --- a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx +++ b/src/widgets/SectionAccordion/ui/SectionAccordion/SectionAccordion.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { Children } from 'react'; import type { SectionRecord } from '$entities/Section'; -import { SectionAccordion } from '$entities/Section'; +import { SectionPanel } from '$entities/Section'; export interface Props { /** @@ -24,13 +24,13 @@ export interface Props { * Active section is determined by the URL (activeSlug prop); inactive sections * render as navigation links so the browser handles routing. */ -export function SectionsAccordion({ sections, activeSlug, children }: Props) { +export function SectionAccordion({ sections, activeSlug, children }: Props) { const slots = Children.toArray(children); return (
{sections.map((section, i) => ( - {slots[i]} - + ))}
); diff --git a/src/widgets/SectionsAccordion/index.ts b/src/widgets/SectionsAccordion/index.ts deleted file mode 100644 index 4a468c4..0000000 --- a/src/widgets/SectionsAccordion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SectionsAccordion } from './ui/SectionsAccordion/SectionsAccordion'; -- 2.54.0 From 3131f846b7268d66d568302e925313823a0979a3 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 17:33:44 +0300 Subject: [PATCH 09/13] fix(theme): shrink mobile footer to match its compact icon row --- src/shared/styles/theme.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 8fde364..c8650e8 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -127,7 +127,8 @@ --radius-md: var(--radius); --radius-lg: var(--radius); --container-section: var(--section-content-width); - --spacing-footer: 5rem; + /* Mobile shows compact 2rem icons, so it sits shorter than the desktop label row */ + --spacing-footer: 3.5rem; --spacing-footer-wide: 4rem; /* Active-section vertical rhythm — fluid so it tracks the clamp() title instead of jumping at sm */ --spacing-section-gap: clamp(1.5rem, 5vw, 3.25rem); -- 2.54.0 From f1d488ae4fd9864cb46b44761477a23749870cb6 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 17:33:55 +0300 Subject: [PATCH 10/13] feat(Footer): icon-only CV download below sm --- src/shared/assets/icons/FileDownIcon.tsx | 32 ++++++++++++++++++++ src/shared/assets/icons/index.ts | 1 + src/widgets/Footer/ui/Footer/Footer.test.tsx | 6 ++++ src/widgets/Footer/ui/Footer/Footer.tsx | 13 ++++++-- 4 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/shared/assets/icons/FileDownIcon.tsx diff --git a/src/shared/assets/icons/FileDownIcon.tsx b/src/shared/assets/icons/FileDownIcon.tsx new file mode 100644 index 0000000..5319dd7 --- /dev/null +++ b/src/shared/assets/icons/FileDownIcon.tsx @@ -0,0 +1,32 @@ +export interface Props { + /** + * CSS classes on the svg element + */ + className?: string; +} + +/** + * File with downward arrow — download icon (Lucide file-down). + */ +export function FileDownIcon({ className }: Props) { + return ( + + + + + + + ); +} diff --git a/src/shared/assets/icons/index.ts b/src/shared/assets/icons/index.ts index 21698bf..a720a05 100644 --- a/src/shared/assets/icons/index.ts +++ b/src/shared/assets/icons/index.ts @@ -1,2 +1,3 @@ export { CloseIcon } from './CloseIcon'; +export { FileDownIcon } from './FileDownIcon'; export { MagnifyIcon } from './MagnifyIcon'; diff --git a/src/widgets/Footer/ui/Footer/Footer.test.tsx b/src/widgets/Footer/ui/Footer/Footer.test.tsx index 0ea17cb..256ab25 100644 --- a/src/widgets/Footer/ui/Footer/Footer.test.tsx +++ b/src/widgets/Footer/ui/Footer/Footer.test.tsx @@ -155,6 +155,12 @@ describe('Footer', () => { expect(link).toHaveClass('brutal-border', 'uppercase'); }); + it('CV link carries the download icon for the narrow-viewport fallback', async () => { + render(await Footer()); + const link = screen.getByRole('link', { name: /download cv/i }); + expect(link.querySelector('svg')).toBeInTheDocument(); + }); + it('does not render CV link when no cv field', async () => { vi.mocked(getFirstRecord).mockResolvedValue({ ...mockSettings, cv: '' }); render(await Footer()); diff --git a/src/widgets/Footer/ui/Footer/Footer.tsx b/src/widgets/Footer/ui/Footer/Footer.tsx index c32b790..dde883f 100644 --- a/src/widgets/Footer/ui/Footer/Footer.tsx +++ b/src/widgets/Footer/ui/Footer/Footer.tsx @@ -1,5 +1,6 @@ import type { SiteSettingsRecord } from '$shared/api'; import { getFirstRecord } from '$shared/api'; +import { FileDownIcon } from '$shared/assets/icons'; import { buildFileUrl } from '$shared/lib'; import { Button, InlineSvg, Link } from '$shared/ui'; @@ -42,8 +43,16 @@ export async function Footer() { ))}
{cvUrl && ( - )}
-- 2.54.0 From d479f3aabf3eee4713c4e8187847a11aa34b7500 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 18:02:10 +0300 Subject: [PATCH 11/13] fix(theme): render btn-shadow via box-shadow so light fills aren't tinted --- src/shared/styles/theme.css | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index c8650e8..215b4b2 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -255,37 +255,23 @@ transition: transform 130ms var(--ease-micro); } -/* Offset "shadow" as a pseudo-block, not box-shadow: box-shadow repaints on - * the main thread while the button's translate runs on the compositor, so the - * two desync per frame. Animating both as transforms keeps them frame-synced. */ +/* Hard offset block via box-shadow, so it sits outside the border box and never + * tints a light fill. Shadow rides the element's own transform, so it can't + * desync from the button's translate the way a separate pseudo-element could. + * Offsets are net of the translate: shadow lands at translate + shadow-offset. */ .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); + box-shadow: 3px 3px 0 var(--shadow-color); + transition: + transform 130ms var(--ease-micro), + box-shadow 130ms var(--ease-micro); } .btn-shadow:hover { transform: translate(-2px, -2px); -} -.btn-shadow:hover::before { - transform: translate(5px, 5px); + box-shadow: 7px 7px 0 var(--shadow-color); } .btn-shadow:active { transform: translate(2px, 2px); -} -.btn-shadow:active::before { - transform: translate(1px, 1px); + box-shadow: -1px -1px 0 var(--shadow-color); } /* Brutalist utility classes */ -- 2.54.0 From 611a9c1a5a4b5a0c05bf4122beda2fffa7fa25ce Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 18:02:21 +0300 Subject: [PATCH 12/13] refactor(Button): distinct solid/outline variants, drop redundant secondary --- .../project/ui/ProjectCard/ProjectCard.tsx | 2 +- src/shared/ui/Button/ui/Button.stories.tsx | 15 ++++++--------- src/shared/ui/Button/ui/Button.test.tsx | 8 ++------ src/shared/ui/Button/ui/Button.tsx | 17 ++++++++--------- 4 files changed, 17 insertions(+), 25 deletions(-) diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.tsx index efc5e99..748dfac 100644 --- a/src/entities/project/ui/ProjectCard/ProjectCard.tsx +++ b/src/entities/project/ui/ProjectCard/ProjectCard.tsx @@ -56,7 +56,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl, pri ))}
)} -
diff --git a/src/shared/ui/Button/ui/Button.stories.tsx b/src/shared/ui/Button/ui/Button.stories.tsx index 26225f2..5ed6393 100644 --- a/src/shared/ui/Button/ui/Button.stories.tsx +++ b/src/shared/ui/Button/ui/Button.stories.tsx @@ -13,11 +13,8 @@ type Story = StoryObj; export const AllVariants: Story = { render: () => (
- - - -
@@ -47,7 +44,7 @@ export const Sizes: Story = { export const Disabled: Story = { args: { - variant: 'primary', + variant: 'solid', disabled: true, children: 'Disabled', }, diff --git a/src/shared/ui/Button/ui/Button.test.tsx b/src/shared/ui/Button/ui/Button.test.tsx index 78ee56b..26e99f7 100644 --- a/src/shared/ui/Button/ui/Button.test.tsx +++ b/src/shared/ui/Button/ui/Button.test.tsx @@ -14,14 +14,10 @@ describe('Button', () => { }); }); describe('variants', () => { - it('applies primary variant by default', () => { + it('applies solid variant by default', () => { render(); expect(screen.getByRole('button')).toHaveClass('bg-blue'); }); - it('applies secondary variant', () => { - render(); - expect(screen.getByRole('button')).toHaveClass('bg-blue'); - }); it('applies outline variant', () => { render(); expect(screen.getByRole('button')).toHaveClass('bg-cream'); @@ -82,7 +78,7 @@ describe('Button', () => { }); it('applies the same variant and size classes as button', () => { render( - , ); diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx index d5a71ee..d2eaad0 100644 --- a/src/shared/ui/Button/ui/Button.tsx +++ b/src/shared/ui/Button/ui/Button.tsx @@ -1,13 +1,13 @@ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'; import { cn } from '$shared/lib'; -export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'; +export type ButtonVariant = 'solid' | 'outline' | 'ghost'; export type ButtonSize = 'sm' | 'md' | 'lg'; type BaseProps = { /** * Visual variant - * @default 'primary' + * @default 'solid' */ variant?: ButtonVariant; /** @@ -41,10 +41,9 @@ function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor { } const VARIANTS = { - 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', + solid: 'brutal-border bg-blue text-cream btn-shadow', + // Reversed solid: same border + offset-block animation, cream fill instead of blue + outline: 'brutal-border bg-cream text-blue btn-shadow', ghost: '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; @@ -55,15 +54,15 @@ const SIZES = { lg: 'px-8 py-4 text-lg', } as const satisfies Record; -/* Elevation lives per-variant: primary/secondary use btn-shadow (static offset - * block, button moves), ghost uses btn-transition + translate. */ +/* Elevation lives per-variant: solid uses 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. * Renders as when href is provided,
- +
+ +
); -- 2.54.0