New
Monolith LaunchLanding Pages
A seven-page torn-paper portfolio: hero collage, case studies, a printable capabilities page and a draggable sketchbook. Ships the six original PNGs — the hero collage and five logo marks — with the rest of the artwork drawn in SVG.
import type { Metadata } from "next"
import { SiteFooter } from "./components/site-footer"
import { SiteHeader } from "./components/site-header"
import { profile } from "./data/projects"
import "./paper-portfolio.css"
export const metadata: Metadata = {
title: {
default: `${profile.name} ${profile.surname} — ${profile.role}`,
template: `%s — ${profile.name} ${profile.surname}`,
},
description: `Portfolio of ${profile.name} ${profile.surname} — AI engineer, developer and builder creating AI products, web experiences and experimental software.`,
}
/**
* The template shell.
*
* This is a nested layout, so it renders no `<html>` or `<body>` — the app's root
* layout owns those. Two wrappers instead: `.paperDesk` paints the surface the
* sheet sits on, `.paperSite` is the sheet and the scope for every style in
* `paper-portfolio.css`.
*/
export default function PaperPortfolioLayout({ children }: { children: React.ReactNode }) {
return (
<div className="paperDesk">
<div className="paperSite">
<div className="paperNoise" aria-hidden="true" />
<SiteHeader />
<main>{children}</main>
<SiteFooter />
</div>
</div>
)
}/*
* paper-portfolio — a torn-paper scrapbook portfolio.
*
* Every selector is scoped under `.paperSite` on purpose. A template that ships
* two hundred names like `.projectCard`, `.brand` and `.eyebrow` into a
* consumer's global stylesheet is a collision waiting to happen; the wrapper
* costs one div and makes the whole sheet inert outside it.
*
* The palette is the only thing most people will want to change. It is at the
* top, and nothing below hard-codes a colour that is already a variable.
*/
/*
* The desk the sheet sits on. The original painted this colour on `body`; a
* template has no business doing that, so it becomes one wrapper element.
*/
.paperDesk {
background: #d8d1c5;
overflow-x: hidden;
}
.paperSite {
--paper: #f3eee3;
--paper-light: #faf6ed;
--paper-deep: #e9dfce;
--ink: #11100d;
--muted: #5b554d;
--line: #bbae96;
--yellow: #f1cf58;
--sticky: #ead17a;
--orange: #e8622b;
--shadow: rgba(57, 45, 29, 0.16);
/* Swap these two to re-voice the whole site. */
--font-hand: var(--font-paper-hand, "Segoe Print", "Bradley Hand", "Comic Sans MS", cursive);
--font-body: Arial, Helvetica, sans-serif;
position: relative;
width: min(1648px, 100%);
min-height: 100vh;
margin: 0 auto;
background: var(--paper);
color: var(--ink);
font-family: var(--font-hand);
box-shadow: 0 26px 80px rgba(0, 0, 0, 0.14);
overflow: hidden;
/* Contains the grain's multiply blend so it cannot reach the host page. */
isolation: isolate;
}
.paperSite *,
.paperSite *::before,
.paperSite *::after { box-sizing: border-box; }
.paperSite a { color: inherit; text-decoration: none; }
.paperSite button,
.paperSite input,
.paperSite textarea { font: inherit; color: inherit; }
.paperSite button,
.paperSite a { -webkit-tap-highlight-color: transparent; }
.paperSite img { display: block; max-width: 100%; }
.paperSite ::selection { background: var(--yellow); color: #111; }
.paperSite main { min-height: 70vh; }
/* The grain. Two dot grids and a wash, multiplied over everything — it is what
* keeps large flat areas from reading as flat colour. Absolute, not fixed, so it
* scrolls with the page instead of shimmering against it. */
.paperSite .paperNoise {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.48;
z-index: 100;
background-image:
radial-gradient(#342d2313 0.72px, transparent 0.85px),
radial-gradient(#fff8 1px, transparent 1.3px),
linear-gradient(90deg, #ffffff0e, #9b87660b 46%, #fff0);
background-size: 5px 5px, 9px 9px, 100% 100%;
mix-blend-mode: multiply;
}
/* ---------- header ---------- */
.paperSite .siteHeader {
height: 86px;
padding: 17px 48px 10px;
display: grid;
grid-template-columns: 450px 1fr auto;
align-items: center;
position: relative;
z-index: 50;
}
.paperSite .brand {
width: max-content;
display: flex;
align-items: center;
gap: 11px;
font-size: 24px;
font-weight: 800;
transform: rotate(-1deg);
position: relative;
}
.paperSite .brandStar { font: 38px Georgia, serif; line-height: 1; }
.paperSite .brandUnderline {
position: absolute;
width: 140px;
border-bottom: 2px solid var(--ink);
left: 54px;
bottom: -5px;
transform: rotate(-2deg);
}
.paperSite .mainNav { display: flex; justify-content: center; gap: 46px; font-size: 17px; font-weight: 650; }
.paperSite .mainNav a { position: relative; padding: 8px 2px; }
.paperSite .mainNav a::after {
content: "";
position: absolute;
height: 4px;
left: -8px;
right: -8px;
bottom: 0;
background: transparent;
border-radius: 50%;
transform: rotate(-2deg);
}
.paperSite .mainNav a:hover::after,
.paperSite .mainNav a.active::after { background: var(--ink); }
.paperSite .talkButton {
color: #fff;
background: #070707;
padding: 12px 16px;
transform: rotate(-1deg);
box-shadow: 5px 4px 0 rgba(0, 0, 0, 0.12);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.paperSite .talkButton:hover { transform: rotate(1deg) translateY(-2px); box-shadow: 7px 7px 0 rgba(0, 0, 0, 0.12); }
.paperSite .talkButton b { font-size: 24px; margin-left: 7px; }
.paperSite .talkBubble { margin-right: 7px; }
.paperSite .menuButton { display: none; }
/* ---------- home ---------- */
.paperSite .homeHero { position: relative; min-height: 545px; margin: 0 48px; }
.paperSite .heroCopy { position: absolute; left: 30px; top: 25px; width: 515px; z-index: 8; }
.paperSite .helloLine { font-size: 21px; transform: rotate(-3deg); width: max-content; }
.paperSite .helloLine span { font-size: 35px; display: inline-block; transform: rotate(-20deg) translateY(4px); }
.paperSite .heroCrown { position: absolute; left: 365px; top: -4px; font: 66px Georgia, serif; transform: rotate(-13deg); }
.paperSite .heroCopy h1,
.paperSite .pageIntro h1,
.paperSite .caseHero h1,
.paperSite .resumePage h1 {
margin: 2px 0 0;
font-size: 70px;
line-height: 0.94;
letter-spacing: -2.2px;
font-weight: 850;
transform: rotate(-1deg);
}
.paperSite .heroCopy h1 mark,
.paperSite .pageIntro h1 mark,
.paperSite .resumePage h1 mark {
background: linear-gradient(transparent 18%, var(--yellow) 18%, var(--yellow) 89%, transparent 89%);
color: inherit;
padding: 0 10px 4px 2px;
}
.paperSite .heroCopy h2 { margin: 10px 0 0 68px; font-size: 21px; font-weight: 500; }
.paperSite .orangeUnderline { display: block; width: 240px; border-top: 4px solid var(--orange); margin: 4px 0 0 130px; transform: rotate(-3deg); }
.paperSite .heroStatement { margin: 18px 0 10px; font-size: 42px; line-height: 1.08; letter-spacing: 0.5px; font-weight: 650; }
.paperSite .heroStatement em {
font-style: normal;
background: linear-gradient(transparent 32%, #eb6b34 32%, #eb6b34 88%, transparent 88%);
padding: 0 6px;
}
.paperSite .heroIntro { max-width: 472px; font-size: 14.5px; line-height: 1.5; color: #29261f; }
.paperSite .heroActions { display: flex; align-items: center; gap: 34px; margin-top: 16px; }
.paperSite .inkButton {
display: inline-flex;
align-items: center;
gap: 12px;
border: 0;
background: #090909;
color: #fff;
padding: 13px 18px;
box-shadow: 4px 5px 0 #b9ad9a;
transform: rotate(-1deg);
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.paperSite .inkButton:hover { transform: rotate(1deg) translateY(-3px); box-shadow: 7px 8px 0 #b9ad9a; }
.paperSite .underLink { border-bottom: 2px solid var(--ink); padding-bottom: 5px; }
/* The torn bottom edge is a clip-path, not an image: it scales, recolours with
* the panel, and costs nothing to download. */
.paperSite .identityNote {
position: absolute;
left: 555px;
top: 35px;
width: 228px;
height: 292px;
padding: 42px 26px 26px;
background: var(--paper-light);
border: 1px solid #c9bda9;
box-shadow: 0 8px 14px var(--shadow);
transform: rotate(-2deg);
z-index: 10;
clip-path: polygon(2% 2%, 98% 0, 97% 96%, 88% 98%, 78% 96%, 70% 99%, 60% 96%, 50% 99%, 42% 96%, 32% 99%, 22% 96%, 12% 99%, 2% 96%);
}
.paperSite .topTape {
position: absolute;
left: 50%;
top: -13px;
width: 80px;
height: 30px;
background: rgba(212, 190, 151, 0.63);
transform: translateX(-50%) rotate(4deg);
}
.paperSite .identityNote div { display: flex; gap: 13px; align-items: flex-start; margin-bottom: 18px; font-size: 13.5px; line-height: 1.45; }
.paperSite .identityNote b { font-family: var(--font-body); font-size: 23px; line-height: 1; }
.paperSite .identityNote a { text-decoration: underline; text-underline-offset: 4px; }
.paperSite .heroMotion {
position: absolute;
left: 760px;
top: -4px;
width: 565px;
height: 520px;
transform: translate(var(--hero-x, 0), var(--hero-y, 0));
transition: transform 0.12s linear;
z-index: 4;
}
.paperSite .heroArtwork { position: relative; width: 100%; height: 100%; }
.paperSite .heroArtwork svg { width: 100%; height: 100%; display: block; }
.paperSite .artTape { position: absolute; z-index: 4; width: 80px; height: 28px; background: rgba(215, 194, 157, 0.67); }
.paperSite .artTapeOne { left: 78px; top: 8px; transform: rotate(8deg); }
.paperSite .artTapeTwo { right: 62px; top: 26px; transform: rotate(-7deg); }
.paperSite .stickyNote { background: var(--sticky); box-shadow: 0 8px 12px var(--shadow); }
.paperSite .whatIDo {
position: absolute;
right: 26px;
top: 58px;
width: 275px;
min-height: 266px;
padding: 30px 31px 24px;
transform: rotate(3deg);
clip-path: polygon(0 3%, 97% 0, 100% 96%, 3% 100%);
z-index: 12;
}
.paperSite .whatIDo h3 { margin: 0 0 15px; text-align: center; padding-bottom: 7px; border-bottom: 2px solid var(--ink); font-size: 20px; }
.paperSite .whatIDo ul { list-style: none; padding: 0; margin: 0; line-height: 2; font-size: 16px; }
.paperSite .whatIDo li::before { content: "★"; font-size: 11px; margin-right: 12px; }
.paperSite .rocketDoodle { position: absolute; right: 14px; bottom: 8px; font-size: 34px; transform: rotate(-30deg); }
.paperSite .aboutSnippet { position: absolute; right: 72px; top: 420px; width: 330px; z-index: 15; }
.paperSite .curvedArrow { position: absolute; left: -66px; top: 6px; font-size: 54px; transform: rotate(33deg); }
.paperSite .aboutSnippet h3 { margin: 0 0 12px; padding-bottom: 7px; border-bottom: 2px solid var(--ink); font-size: 17px; letter-spacing: 1px; }
.paperSite .aboutSnippet p { margin: 0 0 17px; font-size: 13px; line-height: 1.55; }
.paperSite .blackNote {
display: inline-block;
background: #080808;
color: #fff;
padding: 9px 15px;
font-size: 12.5px;
transform: rotate(2deg);
box-shadow: 4px 4px 0 rgba(54, 43, 30, 0.19);
}
.paperSite .blackNote span { color: var(--orange); font-size: 21px; margin-left: 8px; }
.paperSite .featuredSection { padding: 4px 78px 0; }
.paperSite .sectionTitleLine { display: flex; align-items: end; gap: 14px; margin-bottom: 12px; }
.paperSite .sectionTitleLine h2 { margin: 0; font-size: 17px; letter-spacing: 0.5px; border-bottom: 2px solid var(--ink); padding-bottom: 6px; min-width: 176px; }
.paperSite .sectionTitleLine > span { font-size: 25px; }
.paperSite .sectionTitleLine > a { margin-left: auto; transform: rotate(1deg); border-bottom: 1px dashed var(--ink); }
.paperSite .homeProjectGrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 17px; }
.paperSite .projectCard {
min-height: 210px;
position: relative;
background: var(--paper-light);
border: 1px solid #c7baa5;
box-shadow: 0 5px 10px var(--shadow);
padding: 20px;
display: grid;
grid-template-columns: 84px 1fr 28px;
gap: 15px;
align-items: start;
transform: rotate(var(--tilt, 0deg));
clip-path: polygon(0 2%, 99% 0, 100% 95%, 95% 97%, 89% 95%, 84% 98%, 77% 95%, 70% 98%, 63% 95%, 56% 98%, 48% 95%, 40% 98%, 32% 95%, 24% 98%, 16% 95%, 8% 98%, 0 95%);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.paperSite .projectCard:hover { transform: translateY(-7px) rotate(0deg); box-shadow: 0 15px 24px rgba(57, 45, 29, 0.22); }
.paperSite .projectMark svg { width: 100%; height: 100%; display: block; }
.paperSite .projectCard .projectMark { width: 82px; height: 82px; border-radius: 16px; overflow: hidden; background: #111; }
.paperSite .projectCardCopy h3 { margin: 4px 0 5px; font-size: 21px; }
.paperSite .projectCardCopy p { margin: 0; font-family: var(--font-body); line-height: 1.45; font-size: 13px; color: #4a463f; }
.paperSite .projectMeta { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #6c645b; }
.paperSite .projectArrow { align-self: end; justify-self: end; font-size: 28px; }
.paperSite .projectCard.compact { min-height: 145px; grid-template-columns: 68px 1fr 26px; padding: 16px 13px; }
.paperSite .projectCard.compact .projectMark { width: 66px; height: 66px; border-radius: 14px; }
.paperSite .projectCard.compact .projectCardCopy h3 { font-size: 16px; margin: 2px 0 4px; }
.paperSite .projectCard.compact .projectMeta { display: none; }
.paperSite .projectCard.compact .projectCardCopy p { font-size: 12px; }
.paperSite .homeBottomRow { margin: 25px 78px 42px; display: grid; grid-template-columns: 1.3fr 0.6fr 190px; gap: 36px; align-items: start; }
.paperSite .toolsBlock h2,
.paperSite .connectBlock h2 { font-size: 17px; margin: 0; padding-bottom: 7px; border-bottom: 2px solid var(--line); }
.paperSite .toolChips { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; }
.paperSite .toolChips span { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border: 1px solid #c8bda8; background: #f8f3e9; font-size: 13px; }
.paperSite .toolChips b {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: #0b0b0b;
color: #fff;
border-radius: 8px;
font: 700 12px var(--font-body);
}
.paperSite .socialRow { display: flex; gap: 16px; margin-top: 18px; }
.paperSite .socialRow a {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 50%;
background: #090909;
color: #fff;
font: 700 13px var(--font-body);
transition: transform 0.18s ease;
}
.paperSite .socialRow a:hover { transform: rotate(-8deg) translateY(-4px); }
.paperSite .conversationNote {
padding: 18px 15px;
background: #eee2c8;
border: 1px solid #d3c5a5;
transform: rotate(-3deg);
font-size: 13px;
line-height: 1.35;
box-shadow: 0 6px 10px var(--shadow);
position: relative;
}
.paperSite .conversationNote::before {
content: "";
position: absolute;
right: 10px;
top: -12px;
width: 58px;
height: 23px;
background: rgba(214, 196, 156, 0.7);
transform: rotate(10deg);
}
/* ---------- shared interior page ---------- */
.paperSite .pageIntro { position: relative; margin: 50px 78px 45px; max-width: 1100px; min-height: 205px; }
.paperSite .eyebrow { display: inline-block; font-family: var(--font-body); font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 13px; }
.paperSite .pageIntro h1 { font-size: 74px; max-width: 930px; }
.paperSite .pageIntro p { max-width: 700px; font-size: 17px; line-height: 1.6; margin: 24px 0 0; color: #413c35; }
.paperSite .introDoodle { position: absolute; left: 870px; top: 65px; font-size: 75px; transform: rotate(14deg); }
.paperSite .paperPanel { background: var(--paper-light); border: 1px solid #c9bca6; box-shadow: 8px 9px 0 #ded2bd; position: relative; }
.paperSite .workEditorial { margin: 0 78px 60px; display: grid; grid-template-columns: 260px 1fr; gap: 34px; align-items: start; }
.paperSite .workLeadNote { position: sticky; top: 24px; background: var(--sticky); padding: 36px 24px 28px; transform: rotate(-2deg); box-shadow: 0 8px 15px var(--shadow); }
.paperSite .workLeadNote .topTape { top: -14px; }
.paperSite .workLeadNote strong { font-size: 22px; display: block; margin-bottom: 10px; }
.paperSite .workLeadNote p { font-family: var(--font-body); font-size: 14px; line-height: 1.6; }
.paperSite .scribble { display: block; margin-top: 35px; transform: rotate(-4deg); }
.paperSite .fullProjectGrid,
.paperSite .projectLibrary { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.paperSite .projectLibrary { margin: 0 78px 70px; }
.paperSite .pageCTA {
margin: 30px 78px 70px;
padding: 35px;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 2px solid var(--line);
border-bottom: 2px solid var(--line);
}
.paperSite .pageCTA p { font-size: 28px; margin: 0; }
/* ---------- about ---------- */
.paperSite .aboutPageGrid { margin: 0 78px 70px; display: grid; grid-template-columns: 470px 1fr; gap: 75px; align-items: center; }
.paperSite .aboutPortrait { padding: 18px; transform: rotate(-1deg); }
.paperSite .aboutPortrait svg { width: 100%; height: auto; display: block; }
.paperSite .handCaption { position: absolute; right: 15px; bottom: 5px; transform: rotate(-5deg); background: var(--paper-light); padding: 5px 8px; }
.paperSite .aboutStory h2 { font-size: 42px; margin: 0 0 18px; }
.paperSite .aboutStory > p { font-family: var(--font-body); font-size: 16px; line-height: 1.75; color: #403b34; }
.paperSite .principleNotes { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 28px; }
.paperSite .principleNotes span { border: 1px solid var(--line); padding: 14px; background: var(--paper-light); transform: rotate(-0.6deg); }
.paperSite .principleNotes span:nth-child(even) { transform: rotate(0.7deg); }
.paperSite .timelineSection { margin: 0 78px 70px; }
.paperSite .timelineList { border-top: 1px solid var(--line); }
.paperSite .timelineList article { display: grid; grid-template-columns: 70px 260px 1fr; gap: 22px; align-items: center; padding: 20px 5px; border-bottom: 1px solid var(--line); }
.paperSite .timelineList article > span { font-family: var(--font-body); font-size: 11px; }
.paperSite .timelineList h3 { margin: 0; font-size: 20px; }
.paperSite .timelineList p { margin: 0; font-family: var(--font-body); font-size: 14px; color: #514b43; }
/* ---------- case study ---------- */
.paperSite .caseStudy { margin: 50px 78px 80px; }
.paperSite .backLink { border-bottom: 1px solid var(--ink); }
.paperSite .caseHero { margin-top: 38px; display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 70px; align-items: center; }
.paperSite .caseHero h1 { font-size: 84px; margin: 12px 0; }
.paperSite .caseHero > div:first-child > p { max-width: 650px; font-size: 22px; line-height: 1.55; }
.paperSite .caseStatus { margin-top: 28px; font: 11px var(--font-body); letter-spacing: 1.6px; }
.paperSite .caseStatus b { margin-left: 12px; font-family: inherit; background: var(--yellow); padding: 8px 10px; }
.paperSite .caseArtwork { min-height: 390px; padding: 50px; display: grid; place-items: center; transform: rotate(1.2deg); }
.paperSite .caseArtwork .projectMark { width: 230px; height: 230px; border-radius: 40px; overflow: hidden; background: #111; }
.paperSite .caseDoodle { position: absolute; bottom: 18px; right: 22px; transform: rotate(-5deg); }
.paperSite .caseBody { margin-top: 70px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 25px; }
.paperSite .caseBody article {
min-height: 245px;
padding: 27px;
background: var(--paper-light);
border: 1px solid var(--line);
box-shadow: 6px 7px 0 #dfd3bf;
transform: rotate(-0.6deg);
}
.paperSite .caseBody article:nth-child(2) { transform: rotate(0.5deg); }
.paperSite .caseBody article:nth-child(3) { transform: rotate(-0.2deg); }
.paperSite .caseBody article > span { font: 11px var(--font-body); }
.paperSite .caseBody h2 { font-size: 28px; margin: 13px 0; }
.paperSite .caseBody p { font-family: var(--font-body); line-height: 1.65; color: #48433b; }
.paperSite .caseTags { display: flex; flex-wrap: wrap; gap: 9px; }
.paperSite .caseTags b { font: 600 12px var(--font-body); padding: 9px 10px; border: 1px solid var(--ink); }
.paperSite .caseFooter { margin-top: 55px; display: flex; align-items: center; gap: 35px; }
/* ---------- contact ---------- */
.paperSite .contactPageGrid { margin: 0 78px 70px; display: grid; grid-template-columns: 1fr 390px; gap: 60px; align-items: start; }
.paperSite .contactFormWrap { background: var(--paper-light); border: 1px solid var(--line); box-shadow: 9px 10px 0 #ddd0bb; padding: 32px; transform: rotate(-0.3deg); }
.paperSite .contactForm { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
.paperSite .contactForm label { display: grid; gap: 9px; }
.paperSite .contactForm label span { font-size: 14px; }
.paperSite .contactForm input,
.paperSite .contactForm textarea {
width: 100%;
border: 0;
border-bottom: 2px solid var(--ink);
background: transparent;
padding: 10px 4px;
outline: none;
resize: vertical;
font-family: var(--font-body);
}
.paperSite .contactForm input:focus,
.paperSite .contactForm textarea:focus { border-bottom-color: var(--orange); }
.paperSite .fullField { grid-column: 1 / -1; }
.paperSite .paperButton { border: 0; border-bottom: 2px solid var(--ink); background: transparent; cursor: pointer; width: max-content; }
.paperSite .formHint { margin: 20px 0 0; background: var(--yellow); width: max-content; max-width: 100%; padding: 8px 10px; }
/* The hint is a live region, so it stays in the DOM and takes no room until it
* actually has something to announce. */
.paperSite .formHint:empty { display: none; }
.paperSite .contactSidebar { display: grid; gap: 28px; }
.paperSite .contactSticky { padding: 38px 25px 28px; transform: rotate(2deg); position: relative; }
.paperSite .contactSticky h2 { margin: 0 0 18px; font-size: 28px; }
.paperSite .contactSticky a { text-decoration: underline; text-underline-offset: 4px; }
.paperSite .contactSticky p { margin: 28px 0 0; line-height: 1.6; }
.paperSite .contactLinks { display: grid; padding: 20px; transform: rotate(-1deg); }
.paperSite .contactLinks a { display: flex; justify-content: space-between; padding: 13px 0; border-bottom: 1px solid var(--line); }
/* ---------- sketchbook ---------- */
.paperSite .sketchBoard {
position: relative;
height: 650px;
margin: 0 78px 80px;
border: 2px solid var(--ink);
background:
repeating-linear-gradient(0deg, transparent 0 31px, rgba(54, 49, 41, 0.08) 32px),
repeating-linear-gradient(90deg, transparent 0 31px, rgba(54, 49, 41, 0.08) 32px),
var(--paper-light);
overflow: hidden;
}
.paperSite .draggableNote {
position: absolute;
width: 285px;
min-height: 180px;
padding: 31px 22px 22px;
box-shadow: 8px 8px 0 rgba(54, 43, 30, 0.12);
touch-action: none;
user-select: none;
cursor: grab;
transform: rotate(-2deg);
}
.paperSite .draggableNote:active { cursor: grabbing; z-index: 10; }
.paperSite .draggableNote.yellow { background: var(--sticky); }
.paperSite .draggableNote.cream { background: #f7f0e2; border: 1px solid var(--line); }
.paperSite .draggableNote.orange { background: #e88955; }
.paperSite .draggableNote:nth-of-type(even) { transform: rotate(2deg); }
.paperSite .noteTape {
position: absolute;
left: 50%;
top: -11px;
width: 75px;
height: 26px;
transform: translateX(-50%) rotate(-3deg);
background: rgba(214, 196, 156, 0.74);
}
.paperSite .draggableNote small { position: absolute; right: 12px; top: 10px; font-size: 9px; font-family: var(--font-body); opacity: 0.55; }
.paperSite .draggableNote h3 { margin: 7px 0 10px; font-size: 24px; }
.paperSite .draggableNote p { margin: 0; font-family: var(--font-body); line-height: 1.5; }
.paperSite .boardDoodle { position: absolute; font-size: 40px; pointer-events: none; }
.paperSite .boardDoodleOne { right: 11%; top: 10%; }
.paperSite .boardDoodleTwo { left: 48%; bottom: 13%; font-size: 70px; transform: rotate(-30deg); }
.paperSite .boardDoodleThree { right: 7%; bottom: 6%; font-size: 20px; transform: rotate(4deg); }
/* ---------- resume ---------- */
.paperSite .resumePage { margin: 48px 78px 80px; background: var(--paper-light); border: 1px solid var(--line); box-shadow: 10px 11px 0 #d9ccb7; padding: 48px; }
.paperSite .resumePage > header { display: grid; grid-template-columns: 1fr auto; gap: 50px; padding-bottom: 32px; border-bottom: 2px solid var(--ink); }
.paperSite .resumePage h1 { font-size: 65px; }
.paperSite .resumePage header p { font-size: 18px; }
.paperSite .resumeContact { display: grid; gap: 9px; align-content: end; font-family: var(--font-body); font-size: 13px; text-align: right; }
.paperSite .resumeContact a { text-decoration: underline; }
.paperSite .resumeColumns { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; padding: 35px 0; }
.paperSite .resumeColumns h2 { font-size: 22px; border-bottom: 1px solid var(--line); padding-bottom: 8px; margin: 0 0 15px; }
.paperSite .resumeColumns p,
.paperSite .resumeColumns li { font-family: var(--font-body); line-height: 1.65; color: #3f3b34; }
.paperSite .resumeColumns ul { padding-left: 20px; margin: 0 0 28px; }
.paperSite .resumeActions { display: flex; gap: 30px; align-items: center; border-top: 1px solid var(--line); padding-top: 25px; }
.paperSite .printNote { font-size: 12px; color: var(--muted); }
/* ---------- footer ---------- */
.paperSite .siteFooter {
margin: 25px 48px 0;
border-top: 2px solid var(--ink);
padding: 30px 30px 38px;
display: flex;
justify-content: space-between;
align-items: end;
}
.paperSite .siteFooter > div:first-child { position: relative; padding-left: 40px; }
.paperSite .footerStar { position: absolute; left: 0; top: -3px; font: 30px Georgia, serif; }
.paperSite .siteFooter strong { font-size: 18px; }
.paperSite .siteFooter p { margin: 5px 0 0; font: 12px var(--font-body); color: #5d574e; }
.paperSite .footerLinks { display: flex; gap: 25px; font-size: 13px; }
.paperSite .footerLinks a:hover { text-decoration: underline; text-underline-offset: 5px; }
/* ---------- responsive ---------- */
@media (max-width: 1250px) {
.paperSite .siteHeader { grid-template-columns: 320px 1fr auto; }
.paperSite .mainNav { gap: 24px; font-size: 15px; }
.paperSite .homeHero { min-height: 700px; }
.paperSite .heroMotion { left: auto; right: 110px; top: 0; width: 520px; }
.paperSite .identityNote { left: 470px; }
.paperSite .whatIDo { right: 10px; top: 355px; }
.paperSite .aboutSnippet { top: 520px; right: 340px; }
.paperSite .homeProjectGrid { grid-template-columns: 1fr 1fr; }
.paperSite .homeBottomRow { grid-template-columns: 1fr 0.55fr; }
.paperSite .conversationNote { grid-column: 2; }
}
@media (max-width: 900px) {
.paperSite { width: 100%; box-shadow: none; }
.paperSite .siteHeader { height: auto; min-height: 76px; padding: 14px 20px; grid-template-columns: 1fr auto; }
.paperSite .brand { font-size: 20px; z-index: 70; }
.paperSite .brandStar { font-size: 30px; }
.paperSite .brandUnderline { width: 115px; left: 45px; }
.paperSite .menuButton {
display: grid;
width: 44px;
height: 42px;
place-content: center;
gap: 7px;
border: 1px solid var(--ink);
background: var(--paper-light);
cursor: pointer;
z-index: 70;
}
.paperSite .menuButton span { width: 22px; border-top: 2px solid var(--ink); }
.paperSite .talkButton { display: none; }
.paperSite .mainNav {
display: none;
position: absolute;
left: 12px;
right: 12px;
top: 68px;
background: var(--paper-light);
border: 1px solid var(--ink);
box-shadow: 7px 8px 0 #111;
padding: 25px;
grid-template-columns: 1fr 1fr;
z-index: 60;
}
.paperSite .mainNav.open { display: grid; }
.paperSite .mainNav a { padding: 12px 4px; }
.paperSite .homeHero { margin: 0 20px; min-height: auto; display: flex; flex-direction: column; gap: 24px; }
.paperSite .heroCopy,
.paperSite .identityNote,
.paperSite .heroMotion,
.paperSite .whatIDo,
.paperSite .aboutSnippet { position: relative; left: auto; right: auto; top: auto; width: 100%; height: auto; }
.paperSite .heroCopy { order: 1; }
.paperSite .heroCopy h1 { font-size: clamp(52px, 13vw, 78px); }
.paperSite .heroCrown { right: 0; left: auto; }
.paperSite .heroCopy h2 { margin-left: 0; }
.paperSite .orangeUnderline { margin-left: 40px; }
.paperSite .heroStatement { font-size: clamp(31px, 8vw, 44px); }
.paperSite .heroMotion { order: 2; transform: none; }
.paperSite .heroArtwork { height: auto; }
.paperSite .heroArtwork svg { height: auto; }
.paperSite .identityNote { order: 3; min-height: 260px; clip-path: none; }
.paperSite .whatIDo { order: 4; max-width: 350px; align-self: center; }
.paperSite .aboutSnippet { order: 5; max-width: 430px; align-self: flex-end; padding-left: 35px; }
.paperSite .featuredSection { padding: 34px 20px 0; }
.paperSite .sectionTitleLine { flex-wrap: wrap; }
.paperSite .sectionTitleLine > a { width: 100%; margin: 2px 0 0; }
.paperSite .homeProjectGrid,
.paperSite .fullProjectGrid,
.paperSite .projectLibrary { grid-template-columns: 1fr; }
.paperSite .homeBottomRow { margin: 30px 20px 45px; grid-template-columns: 1fr; }
.paperSite .conversationNote { grid-column: auto; max-width: 230px; }
.paperSite .pageIntro { margin: 45px 20px 35px; min-height: auto; }
.paperSite .pageIntro h1 { font-size: clamp(50px, 13vw, 75px); }
.paperSite .pageIntro p { font-size: 15px; }
.paperSite .introDoodle { display: none; }
.paperSite .workEditorial { margin: 0 20px 50px; grid-template-columns: 1fr; }
.paperSite .workLeadNote { position: relative; top: 0; max-width: 420px; }
.paperSite .projectLibrary { margin: 0 20px 55px; }
.paperSite .pageCTA { margin: 20px; padding: 25px 0; flex-direction: column; align-items: flex-start; gap: 18px; }
.paperSite .pageCTA p { font-size: 23px; }
.paperSite .aboutPageGrid { margin: 0 20px 55px; grid-template-columns: 1fr; gap: 35px; }
.paperSite .aboutPortrait { max-width: 520px; }
.paperSite .timelineSection { margin: 0 20px 55px; }
.paperSite .timelineList article { grid-template-columns: 50px 1fr; }
.paperSite .timelineList article p { grid-column: 2; }
.paperSite .caseStudy { margin: 40px 20px 60px; }
.paperSite .caseHero { grid-template-columns: 1fr; gap: 30px; }
.paperSite .caseHero h1 { font-size: clamp(58px, 15vw, 84px); }
.paperSite .caseHero > div:first-child > p { font-size: 18px; }
.paperSite .caseArtwork { min-height: 320px; }
.paperSite .caseBody { grid-template-columns: 1fr; }
.paperSite .caseFooter { flex-wrap: wrap; }
.paperSite .contactPageGrid { margin: 0 20px 60px; grid-template-columns: 1fr; }
.paperSite .contactForm { grid-template-columns: 1fr; }
.paperSite .fullField { grid-column: auto; }
.paperSite .sketchBoard { margin: 0 20px 60px; height: 760px; }
.paperSite .draggableNote { width: min(270px, 70vw); }
.paperSite .resumePage { margin: 30px 20px 60px; padding: 26px; }
.paperSite .resumePage > header,
.paperSite .resumeColumns { grid-template-columns: 1fr; }
.paperSite .resumeContact { text-align: left; }
.paperSite .resumePage h1 { font-size: 54px; }
.paperSite .resumeActions { flex-wrap: wrap; }
.paperSite .siteFooter { margin: 15px 20px 0; padding: 25px 4px 35px; flex-direction: column; align-items: flex-start; gap: 25px; }
.paperSite .footerLinks { flex-wrap: wrap; gap: 15px 22px; }
}
@media (max-width: 520px) {
.paperSite .heroActions { align-items: flex-start; flex-direction: column; gap: 18px; }
.paperSite .projectCard,
.paperSite .projectCard.compact { grid-template-columns: 64px 1fr 22px; padding: 14px 12px; }
.paperSite .projectCard .projectMark,
.paperSite .projectCard.compact .projectMark { width: 62px; height: 62px; }
.paperSite .principleNotes { grid-template-columns: 1fr; }
.paperSite .sketchBoard { height: 900px; }
}
/*
* Print exists for one page — /resume. Everything that is decoration on screen
* is noise on paper, so the chrome is dropped rather than restyled.
*/
@media print {
.paperSite { width: 100%; box-shadow: none; background: #fff; overflow: visible; }
.paperSite .siteHeader,
.paperSite .siteFooter,
.paperSite .paperNoise,
.paperSite .resumeActions,
.paperSite .printNote { display: none !important; }
.paperSite .resumePage { margin: 0; border: 0; box-shadow: none; padding: 25px; }
}import Link from "next/link"
import { TornCollage } from "./components/doodles"
import { HeroMotion } from "./components/hero-motion"
import { ProjectCard } from "./components/project-card"
import { featuredProjects, href, profile } from "./data/projects"
const TOOLS: Array<[string, string]> = [
["N", "Next.js"],
["⚡", "FastAPI"],
["Py", "Python"],
["JS", "TypeScript"],
["♟", "AI Agents"],
["◇", "Design Systems"],
]
export default function PaperPortfolioHome() {
return (
<>
<section className="homeHero">
<div className="heroCopy">
<div className="helloLine">
hey! I'm <span aria-hidden="true">↘</span>
</div>
<div className="heroCrown" aria-hidden="true">
♔
</div>
<h1>
{profile.name}
<br />
<mark>{profile.surname}</mark>
</h1>
<h2>{profile.role}</h2>
<span className="orangeUnderline" aria-hidden="true" />
<p className="heroStatement">
I build with code,
<br />
curiosity and <em>rebellion.</em>
</p>
<p className="heroIntro">
I'm {profile.name} {profile.surname}, an IIT student and developer from India
building AI tools, digital products, and handcrafted web experiences.
</p>
<div className="heroActions">
<Link className="inkButton" href={href("/work")}>
View My Work <span aria-hidden="true">→</span>
</Link>
<Link className="underLink" href={href("/resume")}>
View Resume <span aria-hidden="true">↘</span>
</Link>
</div>
</div>
<aside className="identityNote">
<span className="topTape" aria-hidden="true" />
<div>
<b aria-hidden="true">⌖</b>
<span>{profile.location}</span>
</div>
<div>
<b aria-hidden="true">◎</b>
<a href={profile.siteUrl} target="_blank" rel="noreferrer">
{profile.site}
</a>
</div>
<div>
<b aria-hidden="true">◉</b>
<a href={profile.repoUrl} target="_blank" rel="noreferrer">
{profile.handle}
</a>
</div>
<div>
<b aria-hidden="true">♜</b>
<span>{profile.studio}</span>
</div>
</aside>
<HeroMotion>
<div className="heroArtwork">
<span className="artTape artTapeOne" aria-hidden="true" />
<span className="artTape artTapeTwo" aria-hidden="true" />
<TornCollage />
</div>
</HeroMotion>
<aside className="whatIDo stickyNote">
<span className="topTape" aria-hidden="true" />
<h3>WHAT I DO</h3>
<ul>
<li>AI/ML Systems</li>
<li>Web Development</li>
<li>UI/UX Design</li>
<li>Automation</li>
<li>Product Building</li>
</ul>
<span className="rocketDoodle" aria-hidden="true">
♧
</span>
</aside>
<aside className="aboutSnippet">
<span className="curvedArrow" aria-hidden="true">
↪
</span>
<h3>A BIT ABOUT ME</h3>
<p>
I love building things that live at the intersection of code, design and impact. From
LLM apps to playful experiments — I ship ideas that matter.
</p>
<Link className="blackNote" href={href("/about")}>
Always learning.
<br />
Always building. <span aria-hidden="true">♡</span>
</Link>
</aside>
</section>
<section className="featuredSection">
<div className="sectionTitleLine">
<h2>FEATURED WORK</h2>
<span aria-hidden="true">☆</span>
<Link href={href("/projects")}>More coming soon! ↘</Link>
</div>
<div className="homeProjectGrid">
{featuredProjects.map((project, index) => (
<ProjectCard project={project} compact key={project.slug} index={index} />
))}
</div>
</section>
<section className="homeBottomRow">
<div className="toolsBlock">
<h2>TOOLS & SUPERPOWERS</h2>
<div className="toolChips">
{TOOLS.map(([glyph, tool]) => (
<span key={tool}>
<b aria-hidden="true">{glyph}</b>
{tool}
</span>
))}
</div>
</div>
<div className="connectBlock">
<h2>LET'S CONNECT</h2>
<div className="socialRow">
<Link href={href("/contact")} aria-label="Contact">
<span aria-hidden="true">✉</span>
</Link>
<a href={profile.repoUrl} target="_blank" rel="noreferrer" aria-label="GitHub">
<span aria-hidden="true">GH</span>
</a>
<a href={profile.siteUrl} target="_blank" rel="noreferrer" aria-label="Website">
<span aria-hidden="true">◎</span>
</a>
</div>
</div>
<Link className="conversationNote" href={href("/contact")}>
Great ideas start
<br />
with a conversation.
<br />
<b>Let's do something. →</b>
</Link>
</section>
</>
)
}import type { Metadata } from "next"
import Link from "next/link"
import { PageIntro } from "../components/page-intro"
import { ProjectCard } from "../components/project-card"
import { href, projects } from "../data/projects"
export const metadata: Metadata = { title: "Work" }
export default function WorkPage() {
return (
<>
<PageIntro eyebrow="01 / selected work" title="Things I have" accent="built.">
Projects, experiments and product systems where engineering and design meet.
</PageIntro>
<section className="workEditorial">
<div className="workLeadNote">
<span className="topTape" aria-hidden="true" />
<strong>How I choose work</strong>
<p>
Useful problem. Clear point of view. Enough technical difficulty to learn something.
</p>
<span className="scribble" aria-hidden="true">
→ make it real
</span>
</div>
<div className="fullProjectGrid">
{projects.map((project, index) => (
<ProjectCard project={project} key={project.slug} index={index} />
))}
</div>
</section>
<section className="pageCTA">
<p>Have a difficult thing worth building?</p>
<Link className="inkButton" href={href("/contact")}>
Start a conversation →
</Link>
</section>
</>
)
}import type { Metadata } from "next"
import Link from "next/link"
import { StudioPortrait } from "../components/doodles"
import { PageIntro } from "../components/page-intro"
import { href, profile } from "../data/projects"
export const metadata: Metadata = { title: "About" }
const TIMELINE: Array<[string, string]> = [
["Assembly / QBASIC", "Learning how computers think at the lowest level."],
["C / C++", "Pointers, memory, compilers and the habit of understanding systems."],
["Web Development", "Turning logic into things people can see and use."],
["Automation / n8n", "Connecting systems and removing repetitive work."],
["AI / ML", "Models, agents, multimodal products and new interaction patterns."],
[profile.studio, "Bringing experiments together under one place for building."],
]
const PRINCIPLES = [
"01 — understand before decorating",
"02 — make state visible",
"03 — prototype the hard part first",
"04 — details are product decisions",
]
export default function AboutPage() {
return (
<>
<PageIntro eyebrow="02 / about" title="A builder before a" accent="bio.">
I care about understanding how things work, then shaping that knowledge into software people
can actually use.
</PageIntro>
<section className="aboutPageGrid">
<article className="aboutPortrait paperPanel">
<span className="topTape" aria-hidden="true" />
<StudioPortrait />
<p className="handCaption">still learning → still building</p>
</article>
<article className="aboutStory">
<h2>I like the messy middle.</h2>
<p>
The interesting part is usually between the idea and the polished demo: choosing an
architecture, deleting the wrong approach, fixing the details and making the product
feel intentional.
</p>
<p>
My work moves between AI systems, developer tools, interfaces, automation and
experimental products. I'm especially interested in products where the interaction
model itself can be rethought.
</p>
<div className="principleNotes">
{PRINCIPLES.map((line) => (
<span key={line}>{line}</span>
))}
</div>
</article>
</section>
<section className="timelineSection">
<div className="sectionTitleLine">
<h2>MY BUILDING JOURNEY</h2>
<span aria-hidden="true">↘</span>
</div>
<div className="timelineList">
{TIMELINE.map(([title, copy], index) => (
<article key={title}>
<span aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
<h3>{title}</h3>
<p>{copy}</p>
</article>
))}
</div>
</section>
<section className="pageCTA">
<p>The longer version is in my resume.</p>
<Link className="inkButton" href={href("/resume")}>
Open resume →
</Link>
</section>
</>
)
}import type { Metadata } from "next"
import { PageIntro } from "../components/page-intro"
import { ProjectCard } from "../components/project-card"
import { projects } from "../data/projects"
export const metadata: Metadata = { title: "Projects" }
export default function ProjectsPage() {
return (
<>
<PageIntro eyebrow="03 / project library" title="Products, agents &" accent="experiments.">
A growing shelf of shipped ideas, active builds and explorations.
</PageIntro>
<section className="projectLibrary">
{projects.map((project, index) => (
<ProjectCard project={project} key={project.slug} index={index} />
))}
</section>
</>
)
}import type { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { ProjectMark } from "../../components/doodles"
import { getProject, href, projects } from "../../data/projects"
type Params = { slug: string }
export function generateStaticParams(): Params[] {
return projects.map((project) => ({ slug: project.slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<Params>
}): Promise<Metadata> {
const { slug } = await params
const project = getProject(slug)
return project
? { title: project.title, description: project.short }
: { title: "Project not found" }
}
export default async function ProjectPage({ params }: { params: Promise<Params> }) {
const { slug } = await params
const project = getProject(slug)
if (!project) notFound()
return (
<article className="caseStudy">
<Link className="backLink" href={href("/projects")}>
← all projects
</Link>
<header className="caseHero">
<div>
<span className="eyebrow">
{project.category} / {project.year}
</span>
<h1>{project.title}</h1>
<p>{project.statement}</p>
<div className="caseStatus">
STATUS <b>{project.status}</b>
</div>
</div>
<div className="caseArtwork paperPanel">
<span className="topTape" aria-hidden="true" />
<ProjectMark seed={project.mark} />
<span className="caseDoodle" aria-hidden="true">
selected work ✦
</span>
</div>
</header>
<section className="caseBody">
<article>
<span aria-hidden="true">01</span>
<h2>The challenge</h2>
<p>{project.challenge}</p>
</article>
<article>
<span aria-hidden="true">02</span>
<h2>The approach</h2>
<p>{project.solution}</p>
</article>
<article>
<span aria-hidden="true">03</span>
<h2>Built with</h2>
<div className="caseTags">
{project.stack.map((item) => (
<b key={item}>{item}</b>
))}
</div>
</article>
</section>
<footer className="caseFooter">
{project.external ? (
<a className="inkButton" href={project.external} target="_blank" rel="noreferrer">
Open repository ↗
</a>
) : (
<Link className="inkButton" href={href("/contact")}>
Ask about this build →
</Link>
)}
<Link className="underLink" href={href("/projects")}>
Next: project library ↗
</Link>
</footer>
</article>
)
}import type { Metadata } from "next"
import { ContactForm } from "../components/contact-form"
import { PageIntro } from "../components/page-intro"
import { profile } from "../data/projects"
export const metadata: Metadata = { title: "Contact" }
export default function ContactPage() {
return (
<>
<PageIntro eyebrow="04 / contact" title="Have an idea?" accent="Write on the page.">
For projects, collaborations, product discussions or a difficult technical problem that
sounds interesting.
</PageIntro>
<section className="contactPageGrid">
<ContactForm />
<aside className="contactSidebar">
<div className="stickyNote contactSticky">
<span className="topTape" aria-hidden="true" />
<h2>Find me online</h2>
<a href={profile.siteUrl} target="_blank" rel="noreferrer">
{profile.site} ↗
</a>
<p>{profile.location}</p>
</div>
<div className="contactLinks paperPanel">
<a href={profile.repoUrl} target="_blank" rel="noreferrer">
GitHub <span aria-hidden="true">↗</span>
</a>
<a href={profile.siteUrl} target="_blank" rel="noreferrer">
Website <span aria-hidden="true">↗</span>
</a>
</div>
</aside>
</section>
</>
)
}import type { Metadata } from "next"
import Link from "next/link"
import { PrintButton } from "../components/print-button"
import { href, profile } from "../data/projects"
export const metadata: Metadata = { title: "Resume" }
const SELECTED = [
["Bloom Chat", "multimodal AI workspace"],
["ArtBloom", "AI creative studio"],
["Pandu Pet", "animated desktop companion"],
["Bloom Browser", "AI-native browser concept"],
["Bloom Code", "terminal coding agent"],
]
const CAPABILITIES = [
"AI / ML systems",
"LLM & agent orchestration",
"Next.js / React",
"Python / FastAPI",
"Automation",
"Product & interaction design",
]
export default function ResumePage() {
return (
<article className="resumePage">
<header>
<div>
<span className="eyebrow">RESUME / 2026</span>
<h1>
{profile.name}
<br />
<mark>{profile.surname}</mark>
</h1>
<p>{profile.role}</p>
</div>
<div className="resumeContact">
<a href={profile.siteUrl}>{profile.site}</a>
<a href={profile.repoUrl}>github.com/{profile.handle}</a>
<span>{profile.location}</span>
</div>
</header>
<section className="resumeColumns">
<div>
<h2>Profile</h2>
<p>
Developer focused on AI products, agent systems, web experiences, automation and
experimental software.
</p>
<h2>Selected projects</h2>
<ul>
{SELECTED.map(([title, summary]) => (
<li key={title}>
<b>{title}</b> — {summary}
</li>
))}
</ul>
</div>
<div>
<h2>Capabilities</h2>
<ul>
{CAPABILITIES.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<h2>Current direction</h2>
<p>Building under {profile.studio} and contributing to open-source software.</p>
</div>
</section>
<div className="resumeActions">
<PrintButton />
<Link className="underLink" href={href("/contact")}>
Contact me →
</Link>
</div>
<p className="printNote">Tip: press Ctrl/Cmd + P to save this page as a PDF.</p>
</article>
)
}import type { Metadata } from "next"
import { PageIntro } from "../components/page-intro"
import { SketchBoard } from "../components/sketch-board"
export const metadata: Metadata = { title: "Sketchbook" }
export default function SketchbookPage() {
return (
<>
<PageIntro eyebrow="05 / sketchbook" title="Unfinished thoughts are" accent="allowed here.">
Notes about products, interaction, agents and whatever I am currently trying to understand.
The cards below are draggable — focus one and use the arrow keys.
</PageIntro>
<SketchBoard />
</>
)
}/**
* Content and routing for the paper-portfolio template.
*
* Everything a consumer needs to change lives here: the identity in
* {@link profile} and the case studies in {@link projects}. Both hold the real
* content of the portfolio this template was cut from — Shreyash Mishra's site
* — deliberately, so the preview and a fresh install read the same. Swap both
* objects for your own before you deploy it as yours.
*/
/**
* Where the template is mounted. The install writes it to
* `app/paper-portfolio/`, so every internal link is prefixed with this. Move the
* folder and change this one string; nothing else hard-codes a path.
*
* Mounting it at the root (`""`) works too, but then the template owns `/work`,
* `/about`, `/contact` and friends — decide that deliberately.
*/
export const BASE = "/paper-portfolio"
/** Prefix a template-internal path with {@link BASE}. */
export function href(path: string): string {
return path === "/" ? BASE || "/" : `${BASE}${path}`
}
/**
* Identity shown in the header, footer, hero and resume.
*
* These are real: a real person, a real domain, a real inbox. If you install
* this template, replace every field before you publish — otherwise you ship
* someone else's name and portrait.
*/
export const profile = {
name: "Shreyash",
surname: "Mishra",
role: "AI Engineer · Developer · Builder",
location: "India · working worldwide",
studio: "Bloom Kernel Labs",
site: "shreyashmishra.in",
siteUrl: "https://shreyashmishra.in",
email: "shreyash.aiml.dev@gmail.com",
handle: "Iitian001",
repoUrl: "https://github.com/Iitian001",
/**
* Portrait collage for the hero and the about page. Same asset contract as
* {@link Project.image}: it installs to `public/paper-portfolio/` and is
* served from the base URL, not from {@link BASE}.
*/
portrait: "/paper-portfolio/hero-collage.png",
} as const
export type Project = {
slug: string
title: string
category: string
year: string
/**
* Screenshot shown on the card and in the case-study header.
*
* The file lives at `assets/<name>` inside this template, the install copies
* it to `public/paper-portfolio/<name>`, and Next serves anything in `public`
* from the base URL — so the path written here is `/paper-portfolio/<name>`.
* It is deliberately independent of {@link BASE}: moving the routes does not
* move the images.
*/
image: string
/** Seed for the generated paper glyph on cards and case studies. */
mark: string
short: string
statement: string
challenge: string
solution: string
stack: string[]
status: string
/** Optional outbound link shown as "Open repository" on the case study. */
external?: string
}
export const projects: Project[] = [
{
slug: "bloom-chat",
title: "Bloom Chat",
category: "Multimodal AI Workspace",
year: "2026",
image: "/paper-portfolio/bloom-chat.png",
mark: "bloom-chat",
short: "A multimodal AI workspace for research, memory, creation and focused work.",
statement:
"I wanted an AI workspace to feel like a place you think in—not another empty prompt box.",
challenge:
"Bring chat, research, image creation and persistent memory into one product without burying the user in controls.",
solution:
"A modular workspace with a quiet conversational core, project context, memory surfaces and deliberate tool entry points.",
stack: ["Next.js", "React", "AI orchestration", "Memory systems", "Multimodal UX"],
status: "In development",
},
{
slug: "artbloom",
title: "ArtBloom",
category: "AI Creative Studio",
year: "2026",
image: "/paper-portfolio/artbloom.png",
mark: "artbloom",
short:
"An expressive AI image studio designed to make generation feel like a creative process.",
statement:
"The goal is to make AI image generation feel less like filling a form and more like entering a studio.",
challenge:
"Keep an advanced image workflow understandable while supporting iteration, editing and visual exploration.",
solution:
"A visual-first interface built around creation states, galleries, focused controls and an art-directed identity.",
stack: ["Next.js", "FastAPI", "FLUX", "Image pipelines", "Product design"],
status: "Active build",
external: "https://github.com/Iitian001/Art-bloom",
},
{
slug: "pandu-pet",
title: "Pandu Pet",
category: "Animated Desktop Companion",
year: "2026",
image: "/paper-portfolio/pandu.png",
mark: "pandu-pet",
short:
"A desktop companion that moves, reacts and grows into a small presence on your screen.",
statement:
"Pandu started with one question: can a desktop utility have enough character that people miss it when it is gone?",
challenge:
"Blend animation, lightweight desktop behavior and expressive states without making the companion distracting.",
solution:
"A state-driven pet system with reusable animation loops, interactions and room for future adaptive behavior.",
stack: ["Godot", "Python tooling", "Animation pipelines", "Desktop UX"],
status: "Prototype series",
external: "https://github.com/Iitian001/Pandu_pet",
},
{
slug: "bloom-browser",
title: "Bloom Browser",
category: "AI-native Browser",
year: "2026",
image: "/paper-portfolio/browser.png",
mark: "bloom-browser",
short:
"A privacy-minded browser concept that treats AI as part of the browsing workflow rather than a sidebar.",
statement:
"The browser is where most digital work already happens. Bloom asks what it becomes when intelligence is native to that space.",
challenge:
"Introduce agentic capabilities without turning browsing into a noisy dashboard or compromising user control.",
solution:
"A browser shell centered on intentional actions, contextual assistance and visible user control over AI behavior.",
stack: ["Browser architecture", "React", "Agent systems", "Privacy UX", "Product design"],
status: "In development",
external: "https://github.com/Iitian001/Bloom_browser",
},
{
slug: "bloom-code",
title: "Bloom Code",
category: "Terminal Coding Agent",
year: "2026",
image: "/paper-portfolio/star.png",
mark: "bloom-code",
short:
"A terminal-first coding agent exploring reliable multi-model execution and developer workflows.",
statement:
"Coding agents should feel fast and decisive, but also transparent enough that developers stay in control.",
challenge:
"Coordinate models, tools and repository context while keeping terminal interaction predictable.",
solution:
"A compact agent loop with explicit tool states, provider fallback concepts and benchmark-driven iteration.",
stack: ["Node.js", "LLM APIs", "Terminal UX", "Agent orchestration"],
status: "Research / build",
},
{
slug: "bloom-call",
title: "Bloom Call",
category: "Voice AI Assistant",
year: "2026",
image: "/paper-portfolio/star.png",
mark: "bloom-call",
short: "A voice assistant concept for handling incoming and outgoing conversational calls.",
statement:
"Voice systems need to feel immediate, interruptible and human-paced rather than like a phone tree with an LLM attached.",
challenge:
"Join speech, reasoning and call state while keeping latency and failures understandable.",
solution:
"A provider-flexible voice pipeline with separated speech, reasoning and call-control layers.",
stack: ["Voice AI", "LLM orchestration", "Realtime systems", "FastAPI"],
status: "Exploration",
},
]
/** The four shown on the home page. */
export const featuredProjects: Project[] = projects.slice(0, 4)
export function getProject(slug: string): Project | undefined {
return projects.find((project) => project.slug === slug)
}"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useEffect, useState } from "react"
import { BASE, href, profile } from "../data/projects"
const links: Array<[string, string]> = [
["/", "Home"],
["/work", "Work"],
["/about", "About"],
["/projects", "Projects"],
["/contact", "Contact"],
["/sketchbook", "Sketchbook"],
]
export function SiteHeader() {
const pathname = usePathname()
const [open, setOpen] = useState(false)
/*
* The mobile drawer is absolutely positioned inside the header, so a route
* change leaves it hanging open over the new page. Close it whenever the path
* changes rather than relying on every link remembering to.
*/
useEffect(() => setOpen(false), [pathname])
/* Escape closes it too — it covers the page and traps nothing. */
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false)
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open])
const home = BASE || "/"
const isActive = (path: string) =>
path === "/" ? pathname === home : pathname.startsWith(href(path))
return (
<header className="siteHeader">
<Link className="brand" href={home} aria-label={`${profile.name} ${profile.surname} home`}>
<span className="brandStar" aria-hidden="true">
✱
</span>
<span>
{profile.name} {profile.surname}
</span>
<span className="brandUnderline" aria-hidden="true" />
</Link>
<button
type="button"
className="menuButton"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label="Toggle navigation"
>
<span aria-hidden="true" />
<span aria-hidden="true" />
</button>
<nav className={open ? "mainNav open" : "mainNav"} aria-label="Primary">
{links.map(([path, label]) => (
<Link
key={path}
href={href(path)}
className={isActive(path) ? "active" : undefined}
aria-current={isActive(path) ? "page" : undefined}
>
{label}
</Link>
))}
</nav>
<Link className="talkButton" href={href("/contact")}>
<span className="talkBubble" aria-hidden="true">
◌
</span>{" "}
Let's Talk! <b aria-hidden="true">›</b>
</Link>
</header>
)
}import Link from "next/link"
import { href, profile } from "../data/projects"
export function SiteFooter() {
return (
<footer className="siteFooter">
<div>
<span className="footerStar" aria-hidden="true">
✱
</span>
<strong>
{profile.name} {profile.surname}
</strong>
<p>{profile.role}</p>
</div>
<div className="footerLinks">
<Link href={href("/projects")}>Projects</Link>
<Link href={href("/contact")}>Contact</Link>
<a href={profile.repoUrl} target="_blank" rel="noreferrer">
GitHub ↗
</a>
<a href={profile.siteUrl} target="_blank" rel="noreferrer">
{profile.site} ↗
</a>
</div>
</footer>
)
}export type PageIntroProps = {
eyebrow: string
title: string
/** Highlighted tail of the heading. */
accent?: string
children: React.ReactNode
}
export function PageIntro({ eyebrow, title, accent, children }: PageIntroProps) {
return (
<section className="pageIntro">
<span className="eyebrow">{eyebrow}</span>
<h1>
{title} {accent ? <mark>{accent}</mark> : null}
</h1>
<p>{children}</p>
<span className="introDoodle" aria-hidden="true">
↘
</span>
</section>
)
}import Link from "next/link"
import { href, type Project } from "../data/projects"
import { ProjectMark } from "./doodles"
export type ProjectCardProps = {
project: Project
/** The tighter variant used in the home-page grid. */
compact?: boolean
/** Position in the list. Only used to alternate the paper tilt. */
index?: number
}
export function ProjectCard({ project, compact = false, index = 0 }: ProjectCardProps) {
return (
<Link
href={href(`/projects/${project.slug}`)}
className={compact ? "projectCard compact" : "projectCard"}
/*
* A custom property, so the hover rule in CSS can flatten the tilt without
* needing to know which way this particular card leans.
*/
style={{ "--tilt": `${index % 2 ? 0.6 : -0.5}deg` } as React.CSSProperties}
>
<ProjectMark seed={project.mark} />
<div className="projectCardCopy">
<div className="projectMeta">
{project.category} · {project.year}
</div>
<h3>{project.title}</h3>
<p>{project.short}</p>
</div>
<span className="projectArrow" aria-hidden="true">
→
</span>
</Link>
)
}"use client"
import { useEffect, useRef } from "react"
/**
* Parallaxes the hero artwork against the pointer.
*
* Two things worth keeping if you edit this: the listener is passive (it never
* calls preventDefault, so it must not block scrolling), and it writes CSS custom
* properties instead of `transform` directly — the transform stays in the
* stylesheet where the transition lives.
*/
export function HeroMotion({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const node = ref.current
if (!node) return
/*
* Skip the effect entirely for anyone who asked for less motion, and on the
* mobile layout where the artwork is in the flow rather than floating.
*/
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)")
const narrow = window.matchMedia("(max-width: 900px)")
const move = (event: PointerEvent) => {
if (reduced.matches || narrow.matches) return
const x = (event.clientX / window.innerWidth - 0.5) * 8
const y = (event.clientY / window.innerHeight - 0.5) * 6
node.style.setProperty("--hero-x", `${x}px`)
node.style.setProperty("--hero-y", `${y}px`)
}
const reset = () => {
node.style.removeProperty("--hero-x")
node.style.removeProperty("--hero-y")
}
window.addEventListener("pointermove", move, { passive: true })
reduced.addEventListener("change", reset)
narrow.addEventListener("change", reset)
return () => {
window.removeEventListener("pointermove", move)
reduced.removeEventListener("change", reset)
narrow.removeEventListener("change", reset)
}
}, [])
return (
<div ref={ref} className="heroMotion">
{children}
</div>
)
}"use client"
import { useCallback, useEffect, useRef, useState } from "react"
type Tone = "yellow" | "cream" | "orange"
type Note = {
id: number
title: string
body: string
/** Position as a percentage of the board, so it survives a resize. */
x: number
y: number
tone: Tone
}
const INITIAL_NOTES: Note[] = [
{
id: 1,
title: "Interface note",
body: "A control should appear when it has something to do — not sit there being permanently available.",
x: 6,
y: 8,
tone: "yellow",
},
{
id: 2,
title: "Tiny rule",
body: "If a screen looks impressive before it feels useful, keep editing.",
x: 43,
y: 15,
tone: "cream",
},
{
id: 3,
title: "State, plainly",
body: "Show what the system is doing: waiting → working → checking → done. Never just a spinner.",
x: 66,
y: 46,
tone: "orange",
},
{
id: 4,
title: "Build log",
body: "Handmade does not mean decorative. It means every decision had a reason behind it.",
x: 18,
y: 54,
tone: "cream",
},
]
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
/** How far one arrow-key press moves a note, in percent of the board. */
const NUDGE = 2
/**
* The draggable note board on /sketchbook.
*
* Positions are percentages so the layout survives a resize, but the *limits*
* are measured from the real elements rather than hard-coded — a percentage
* ceiling like "78%" lets a 285px note hang off a 1200px board and get clipped.
* Arrow keys move a focused note, because a mouse-only feature is no feature for
* anyone navigating by keyboard.
*/
export function SketchBoard() {
const boardRef = useRef<HTMLDivElement>(null)
const noteRefs = useRef(new Map<number, HTMLElement>())
const dragging = useRef<{ id: number; offsetX: number; offsetY: number } | null>(null)
const [notes, setNotes] = useState<Note[]>(INITIAL_NOTES)
/** The largest left/top percentage that still keeps the note fully on the board. */
const limitsFor = useCallback((id: number) => {
const board = boardRef.current
const note = noteRefs.current.get(id)
if (!board || !note) return { maxX: 100, maxY: 100 }
const { width, height } = board.getBoundingClientRect()
return {
maxX: width > 0 ? Math.max(0, ((width - note.offsetWidth) / width) * 100) : 0,
maxY: height > 0 ? Math.max(0, ((height - note.offsetHeight) / height) * 100) : 0,
}
}, [])
const settle = useCallback(() => {
setNotes((items) =>
items.map((item) => {
const { maxX, maxY } = limitsFor(item.id)
return { ...item, x: clamp(item.x, 0, maxX), y: clamp(item.y, 0, maxY) }
}),
)
}, [limitsFor])
/* The initial positions are tuned for a wide board; pull them in on narrow ones. */
useEffect(() => {
settle()
window.addEventListener("resize", settle)
return () => window.removeEventListener("resize", settle)
}, [settle])
function move(id: number, x: number, y: number) {
const { maxX, maxY } = limitsFor(id)
setNotes((items) =>
items.map((item) =>
item.id === id ? { ...item, x: clamp(x, 0, maxX), y: clamp(y, 0, maxY) } : item,
),
)
}
function onPointerDown(event: React.PointerEvent<HTMLElement>, note: Note) {
const board = boardRef.current
if (!board || event.button !== 0) return
const rect = board.getBoundingClientRect()
dragging.current = {
id: note.id,
offsetX: event.clientX - (rect.left + (note.x / 100) * rect.width),
offsetY: event.clientY - (rect.top + (note.y / 100) * rect.height),
}
event.currentTarget.setPointerCapture(event.pointerId)
}
function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {
const drag = dragging.current
const board = boardRef.current
if (!drag || !board) return
const rect = board.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
move(
drag.id,
((event.clientX - rect.left - drag.offsetX) / rect.width) * 100,
((event.clientY - rect.top - drag.offsetY) / rect.height) * 100,
)
}
function onKeyDown(event: React.KeyboardEvent<HTMLElement>, note: Note) {
const step: Record<string, [number, number]> = {
ArrowLeft: [-NUDGE, 0],
ArrowRight: [NUDGE, 0],
ArrowUp: [0, -NUDGE],
ArrowDown: [0, NUDGE],
}
const delta = step[event.key]
if (!delta) return
event.preventDefault()
move(note.id, note.x + delta[0], note.y + delta[1])
}
return (
<div
ref={boardRef}
className="sketchBoard"
onPointerMove={onPointerMove}
onPointerUp={() => {
dragging.current = null
}}
onPointerCancel={() => {
dragging.current = null
}}
>
<span className="boardDoodle boardDoodleOne" aria-hidden="true">
✦
</span>
<span className="boardDoodle boardDoodleTwo" aria-hidden="true">
↗
</span>
<span className="boardDoodle boardDoodleThree" aria-hidden="true">
( keep making )
</span>
{notes.map((note) => (
<article
key={note.id}
ref={(node) => {
if (node) noteRefs.current.set(note.id, node)
else noteRefs.current.delete(note.id)
}}
className={`draggableNote ${note.tone}`}
style={{ left: `${note.x}%`, top: `${note.y}%` }}
onPointerDown={(event) => onPointerDown(event, note)}
onKeyDown={(event) => onKeyDown(event, note)}
tabIndex={0}
role="group"
aria-roledescription="Movable note"
aria-label={`${note.title}. Use the arrow keys to move it.`}
>
<span className="noteTape" aria-hidden="true" />
<small aria-hidden="true">drag me</small>
<h3>{note.title}</h3>
<p>{note.body}</p>
</article>
))}
</div>
)
}"use client"
import { useState } from "react"
import { profile } from "../data/projects"
type Status = "idle" | "shared" | "copied" | "mail" | "failed"
const HINTS: Record<Exclude<Status, "idle">, string> = {
shared: "Shared. Thanks — a reply usually comes within a day or two.",
copied: `Copied to your clipboard. Paste it into an email to ${profile.email}.`,
mail: "Your email app should be opening with the message ready to send.",
failed: `Could not copy automatically. Email ${profile.email} and paste the message yourself.`,
}
/**
* A contact form with no backend, on purpose.
*
* A static template cannot host a mail server, and pretending otherwise means a
* form that silently drops messages. So this hands the message to something that
* *can* send it: the native share sheet, the clipboard, or a `mailto:` link, in
* that order. Wire it to an action or an API route if you want real submissions.
*/
export function ContactForm() {
const [status, setStatus] = useState<Status>("idle")
const [copied, setCopied] = useState(false)
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const name = String(data.get("name") || "Website visitor")
const email = String(data.get("email") || "")
const message = String(data.get("message") || "")
const subject = `Enquiry for ${profile.studio}`
const text = `${subject}\n\n${message}\n\nFrom: ${name}\nEmail: ${email}`
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share({ title: subject, text })
setStatus("shared")
return
} catch (error) {
/* The user closing the sheet is not a failure — leave the form alone. */
if (error instanceof Error && error.name === "AbortError") return
}
}
try {
await navigator.clipboard.writeText(text)
setStatus("copied")
return
} catch {
/*
* Clipboard access fails on insecure origins and whenever permission is
* refused. Fall through rather than swallowing the message.
*/
}
try {
const url = `mailto:${profile.email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(text)}`
window.location.href = url
setStatus("mail")
} catch {
setStatus("failed")
}
}
async function copyEmail() {
try {
await navigator.clipboard.writeText(profile.email)
setCopied(true)
window.setTimeout(() => setCopied(false), 1800)
} catch {
setStatus("failed")
}
}
return (
<div className="contactFormWrap">
<form className="contactForm" onSubmit={submit}>
<label>
<span>Your name</span>
<input name="name" required placeholder="Write it here..." autoComplete="name" />
</label>
<label>
<span>Your email</span>
<input
name="email"
type="email"
required
placeholder="you@example.com"
autoComplete="email"
/>
</label>
<label className="fullField">
<span>What are we building?</span>
<textarea
name="message"
required
rows={7}
placeholder="Tell me about the idea, the problem, or the collaboration..."
/>
</label>
<button className="inkButton" type="submit">
Share / copy message <span aria-hidden="true">→</span>
</button>
<button className="paperButton" type="button" onClick={copyEmail}>
{copied ? "Email copied ✓" : "Copy email address"}
</button>
</form>
<p className="formHint" role="status" aria-live="polite">
{status === "idle" ? "" : HINTS[status]}
</p>
</div>
)
}"use client"
export function PrintButton() {
return (
<button type="button" className="inkButton printButton" onClick={() => window.print()}>
Print / Save as PDF <span aria-hidden="true">→</span>
</button>
)
}/**
* The photographic slots in this template: the portrait collage and the project
* marks.
*
* These were briefly redrawn as generated SVG. They are bitmaps again, because a
* photograph of a person and five product logos are not derivable from a hash.
* The files live in `assets/` beside this template; the install copies them to
* `public/paper-portfolio/`, and Next serves `public` from the base URL — so the
* URLs are `/paper-portfolio/<file>` and, unlike the routes, they do not move
* when `BASE` changes.
*
* Plain `<img>`, not `next/image`, on purpose. A template is installed into an
* app whose `next.config` it does not control, and `next/image` has two
* configuration dependencies this folder cannot satisfy from here:
*
* - `output: "export"` cannot use the default loader at all. It needs
* `images.loader: "custom"` plus a `loaderFile`, so an export build fails
* rather than degrades.
* - `images.localPatterns`, once a consumer sets it for their own images,
* turns every path outside those patterns into a 400. A consumer who has
* never touched it is fine; one who has must remember to add this folder.
*
* What is given up is small. Five of the six files are 52–73px wide and render
* at 62–230px, so the optimizer has nothing to shrink and would only upscale
* them into a larger, softer variant. The collage is the real cost — 525,048
* bytes for a 565px box — but the fix there is re-encoding the file, not making
* a consumer's build satisfy `next/image`.
*
* The stylesheet's `.paperSite img` rule already supplies `display: block` and
* `max-width: 100%`. The inline styles below add only the box-filling geometry,
* which is inline because the rules for these three slots still select `svg`.
* Once `paper-portfolio.css` selects `img` too, the `style` props can go.
*/
import { profile, projects } from "../data/projects"
/**
* Intrinsic pixels of `hero-collage.png`, read from its PNG header. Both slots
* share that one file, so there is one pair rather than a table.
*
* The marks get no such constant. Their box is a fixed CSS square and they are
* cropped to fill it, so their five different shapes (52×58 up to 73×75) cannot
* shift the layout and the browser never needs the ratio to reserve space.
*/
const COLLAGE = { width: 525, height: 500 } as const
/**
* Shown when a `seed` matches no project. `star.png` is the spare mark the data
* already gives to projects with no logo of their own, so an unknown seed lands
* on something drawn for this purpose instead of a broken image.
*/
const SPARE_MARK = "/paper-portfolio/star.png"
/** One description, two slots — it is the same photograph on both pages. */
const COLLAGE_ALT = `Portrait collage of ${profile.name} ${profile.surname}`
/**
* The hero artwork: the cut-out collage, cropped to fill its box.
*
* `.heroArtwork` is 565×520 on desktop, so `cover` trims a little off the sides
* of the 525×500 file. At 900px and under that box goes auto-height, the
* percentage height resolves against nothing, and the intrinsic ratio takes
* over.
*
* Eager and high priority: it is above the fold and the likely LCP element.
*/
export function TornCollage() {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.portrait}
alt={COLLAGE_ALT}
width={COLLAGE.width}
height={COLLAGE.height}
loading="eager"
fetchPriority="high"
decoding="async"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
)
}
/**
* The about-page portrait. Same file as the hero, fitted to the panel rather
* than cropped: the panel has no fixed height, so width drives and height
* follows.
*
* `height: auto` is what keeps it from squashing, and it always matters here —
* the panel sits in a 470px grid column on desktop and is capped at 520px below
* that, so the box is never as wide as the file's 525px.
*/
export function StudioPortrait() {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.portrait}
alt={COLLAGE_ALT}
width={COLLAGE.width}
height={COLLAGE.height}
loading="lazy"
decoding="async"
style={{ width: "100%", height: "auto" }}
/>
)
}
export type ProjectMarkProps = {
/** A project's `mark`, looked up in `projects` to find that project's logo. */
seed: string
}
/**
* The square logo mark on project cards and case-study headers.
*
* It takes a `mark` and looks the project up, rather than taking the image path,
* because the case-study page has only the `mark` to hand and this component
* cannot reach into that file. `mark` has no other purpose now that the art is
* not generated from it — passing the project straight in and deleting the field
* would be the better shape.
*
* Decorative: every mark sits next to the project title it belongs to, so an
* alt text would only repeat the adjacent heading. Hence `alt=""`.
*/
export function ProjectMark({ seed }: ProjectMarkProps) {
const src = projects.find((project) => project.mark === seed)?.image ?? SPARE_MARK
return (
<span className="projectMark" aria-hidden="true">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt=""
loading="lazy"
decoding="async"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</span>
)
}