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/shared/styles/theme.css b/src/shared/styles/theme.css
index f9fed13..215b4b2 100644
--- a/src/shared/styles/theme.css
+++ b/src/shared/styles/theme.css
@@ -20,8 +20,9 @@
--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);
+ /* 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;
@@ -72,8 +73,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 === */
@@ -127,9 +127,13 @@
--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);
--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);
@@ -166,6 +170,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 +180,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
@@ -211,7 +217,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);
@@ -241,46 +250,28 @@
}
}
-/* 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. */
+/* 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 */
@@ -323,30 +314,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);
-}
-@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;
@@ -395,9 +372,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;
@@ -406,7 +382,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) */
@@ -507,14 +486,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 {
@@ -555,12 +540,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);
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: () => (