{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"graphite","type":"registry:page","title":"Graphite","description":"A graphite-and-cream sketchbook portfolio for a design engineer: hand-drawn underlines and margin notes, an editorial about, a pinned selected-work grid and a pencil self-portrait. Every string on the site lives in one content file — swap the palette and the words and it is someone else's.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"app/graphite/layout.tsx","target":"app/graphite/layout.tsx","content":"import type { Metadata } from \"next\"\n\nimport { SiteFooter } from \"./components/site-footer\"\nimport { SiteHeader } from \"./components/site-header\"\nimport { hero, profile } from \"./data/content\"\n\nimport \"./graphite.css\"\n\nexport const metadata: Metadata = {\n  title: {\n    default: `${profile.name} — ${profile.role}`,\n    template: `%s — ${profile.name}`,\n  },\n  description: hero.lede,\n}\n\n/**\n * The template shell.\n *\n * A nested layout, so it renders no `<html>` or `<body>` — the app's root layout\n * owns those. `.grSite` is the single wrapper every style in `graphite.css`\n * hangs off, which is what keeps this sheet from touching anything else.\n *\n * The `<noscript>` block matters: scroll reveals start at `opacity: 0` in CSS so\n * they cannot flash in before hydration, and the hand-drawn marks start\n * un-inked. Without JavaScript the observer never runs, so this turns them all\n * on for that reader instead of showing an empty, unmarked page.\n */\nexport default function GraphiteLayout({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"grSite\" id=\"top\">\n      <noscript>\n        <style\n          dangerouslySetInnerHTML={{\n            __html:\n              \".grReveal{opacity:1;transform:none}.grStroke{stroke-dashoffset:0}\",\n          }}\n        />\n      </noscript>\n      <SiteHeader />\n      <main>{children}</main>\n      <SiteFooter />\n    </div>\n  )\n}\n","type":"registry:page"},{"path":"app/graphite/graphite.css","target":"app/graphite/graphite.css","content":"/*\n * graphite — an artistic, hand-drawn developer portfolio built to feel like a\n * designer's sketchbook: warm paper, graphite type, one red pencil accent, and\n * marks that draw themselves in as you scroll.\n *\n * Nothing here can leak. Every class name carries a `gr` prefix and every\n * bare-element rule is scoped under `.grSite`. A template that ships names like\n * `.hero`, `.card` or `.rule` into a consumer's global stylesheet is a collision\n * waiting to happen; a prefix plus one wrapper element makes the whole sheet\n * inert outside itself.\n *\n * The two things most people will want to change are at the top: the palette\n * and the display scale. Nothing below hard-codes a value that is a variable up\n * here. The paper grain and ruled lines are pure CSS/SVG — no image weight, and\n * nothing to look dated.\n */\n.grSite {\n  /* Palette — warm off-white paper, graphite ink, one red-pencil accent. */\n  --paper: #f4f1ea;\n  --paper-deep: #efe9dd;\n  --paper-panel: #f7f4ee;\n  --ink: #2b2b2b;\n  --ink-soft: #4a4744;\n  --ink-dim: #6b6660;\n  --ink-faint: #948d83;\n  --line: rgba(43, 43, 43, 0.14);\n  --line-strong: rgba(43, 43, 43, 0.32);\n  --accent: #c0392b;\n  --accent-soft: rgba(192, 57, 43, 0.14);\n  --rule-blue: rgba(70, 90, 120, 0.16);\n  --tape: rgba(214, 202, 176, 0.62);\n\n  /*\n   * Display scale. The viewport unit is what makes the headline expressive on a\n   * desktop and still legible on a phone; the clamp stops it doing either badly.\n   */\n  --display: clamp(3rem, 9vw, 7.75rem);\n  --display-sm: clamp(2.2rem, 5vw, 4rem);\n  --heading: clamp(1.75rem, 3.2vw, 3rem);\n  --body: clamp(1.0625rem, 1.05vw, 1.1875rem);\n\n  /* Rhythm. Sections breathe — the paper space is part of the composition. */\n  --gutter: clamp(1.25rem, 4.5vw, 5.5rem);\n  --section: clamp(4.5rem, 10vh, 8.5rem);\n  --measure: 34rem;\n\n  /*\n   * Type. An editorial serif for display and headings, a clean sans for body,\n   * the serif's italic for the handwritten notes. All system-safe — the nicer\n   * faces are used when present, Georgia/Times carry everyone else.\n   */\n  --font-serif: \"Iowan Old Style\", \"Palatino Linotype\", Palatino, \"Book Antiqua\", Georgia, \"Times New Roman\", serif;\n  --font-sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n\n  position: relative;\n  min-height: 100vh;\n  background-color: var(--paper);\n  color: var(--ink);\n  font-family: var(--font-sans);\n  font-size: var(--body);\n  line-height: 1.65;\n  isolation: isolate;\n  /*\n   * `clip` not `hidden`: `overflow-x: hidden` forces the computed `overflow-y`\n   * to `auto`, quietly turning this element into a scroll container (which also\n   * breaks the sticky header inside it). `clip` trims any stray horizontal\n   * bleed without either side effect. There is no real overflow here — this is\n   * belt-and-braces against a future wide child.\n   */\n  overflow-x: clip;\n}\n\n/*\n * Paper grain. An SVG turbulence baked into a data URI, dropped to a whisper by\n * a colour matrix on the alpha channel and pinned behind everything. `fixed`\n * so it reads as the sheet the whole site is printed on, not a tiled swatch.\n * Two soft washes over it give the paper an uneven, hand-made warmth.\n */\n.grSite::before {\n  content: \"\";\n  position: fixed;\n  inset: 0;\n  z-index: -1;\n  pointer-events: none;\n  background-image:\n    radial-gradient(120% 90% at 12% 8%, rgba(255, 255, 255, 0.55), transparent 55%),\n    radial-gradient(120% 100% at 88% 100%, rgba(214, 202, 176, 0.4), transparent 60%),\n    url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\");\n  background-size: cover, cover, 180px 180px;\n  opacity: 0.5;\n  mix-blend-mode: multiply;\n}\n\n.grSite *,\n.grSite *::before,\n.grSite *::after { box-sizing: border-box; }\n\n/*\n * `:where()` contributes no specificity, which is the point: `.grSite a` would\n * beat every single-class rule below it, so a solid button would inherit the\n * wrong colour. This keeps the reset at (0,1,0) so any later class rule wins.\n */\n.grSite :where(a) { color: inherit; text-decoration: none; }\n.grSite :where(button, input, textarea, select) { font: inherit; color: inherit; }\n.grSite ::selection { background: var(--accent-soft); color: var(--ink); }\n\n/* One visible focus style for the whole template. */\n.grSite :focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 3px;\n  border-radius: 1px;\n}\n\n/* ---------------------------------------------------------------- primitives */\n\n.grWrap {\n  width: 100%;\n  max-width: 78rem;\n  margin: 0 auto;\n  padding-inline: var(--gutter);\n}\n\n/* The hairline that separates sections — a fine ruled-paper line. */\n.grRule {\n  height: 1px;\n  background: var(--line);\n  border: 0;\n  margin: 0;\n}\n\n.grSection { padding-block: var(--section); }\n.grSectionTight { padding-top: 0; }\n\n/* Wide-tracked micro-label, set in the serif italic like a caption. */\n.grLabel {\n  margin: 0;\n  font-family: var(--font-serif);\n  font-style: italic;\n  font-size: 0.9375rem;\n  letter-spacing: 0.02em;\n  color: var(--accent);\n}\n\n/* A tiny hand-annotation, tilted a hair off true. */\n.grNote {\n  font-family: var(--font-serif);\n  font-style: italic;\n  font-size: 1rem;\n  line-height: 1.4;\n  color: var(--ink-dim);\n}\n\n.grHeading {\n  margin: 0;\n  font-family: var(--font-serif);\n  font-size: var(--heading);\n  font-weight: 600;\n  line-height: 1.06;\n  letter-spacing: -0.015em;\n  text-wrap: balance;\n}\n\n.grLede {\n  max-width: var(--measure);\n  margin: 0;\n  font-size: clamp(1.125rem, 1.5vw, 1.4375rem);\n  line-height: 1.55;\n  color: var(--ink-soft);\n  text-wrap: pretty;\n}\n\n.grSectionHead {\n  display: grid;\n  gap: 0.875rem;\n  max-width: 40rem;\n  margin-bottom: clamp(2.25rem, 5vw, 3.5rem);\n}\n\n/* ------------------------------------------------------------------- buttons */\n\n.grBtn {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.8125rem 1.5rem;\n  border: 1.5px solid var(--line-strong);\n  border-radius: 999px;\n  background: transparent;\n  font-family: var(--font-serif);\n  font-size: 1.0625rem;\n  cursor: pointer;\n  transition:\n    background-color 0.35s cubic-bezier(0.22, 1, 0.36, 1),\n    color 0.35s cubic-bezier(0.22, 1, 0.36, 1),\n    border-color 0.35s cubic-bezier(0.22, 1, 0.36, 1),\n    transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.grBtn:hover {\n  background: var(--ink);\n  border-color: var(--ink);\n  color: var(--paper);\n  transform: translateY(-1px);\n}\n\n.grBtnSolid {\n  background: var(--ink);\n  border-color: var(--ink);\n  color: var(--paper);\n}\n\n.grBtnSolid:hover {\n  background: var(--accent);\n  border-color: var(--accent);\n  color: var(--paper);\n}\n\n/* The arrow that leans forward on hover. Inline SVG, no icon library. */\n.grBtn .grArrow { transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); }\n.grBtn:hover .grArrow { transform: translate(2px, -2px); }\n\n/* -------------------------------------------------------------------- header */\n\n.grHeader {\n  position: sticky;\n  top: 0;\n  z-index: 20;\n  background: color-mix(in oklab, var(--paper) 80%, transparent);\n  backdrop-filter: blur(8px) saturate(120%);\n  border-bottom: 1px solid var(--line);\n}\n\n.grHeaderRow {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 1.5rem;\n  height: 4.5rem;\n}\n\n.grBrand { display: grid; gap: 0.05rem; line-height: 1.1; }\n\n.grBrandName {\n  font-family: var(--font-serif);\n  font-size: 1.375rem;\n  font-weight: 600;\n  letter-spacing: -0.01em;\n}\n\n.grBrandRole {\n  font-family: var(--font-serif);\n  font-style: italic;\n  font-size: 0.8125rem;\n  color: var(--ink-dim);\n}\n\n.grNav {\n  display: none;\n  align-items: center;\n  gap: 2rem;\n}\n\n.grNavLink {\n  position: relative;\n  font-family: var(--font-serif);\n  font-size: 1.125rem;\n  color: var(--ink-soft);\n  transition: color 0.25s ease;\n}\n\n.grNavLink:hover { color: var(--ink); }\n\n/* A hand-inked underline that sweeps in from the left on hover. */\n.grNavLink::after {\n  content: \"\";\n  position: absolute;\n  left: -0.1rem;\n  right: -0.1rem;\n  bottom: -0.28rem;\n  height: 2px;\n  background: var(--accent);\n  border-radius: 2px;\n  transform: scaleX(0);\n  transform-origin: left;\n  transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.grNavLink:hover::after { transform: scaleX(1); }\n\n/* Mobile nav is a native <details> — no state, no focus trap to get wrong. */\n.grMenu { position: relative; }\n\n.grMenuSummary {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.5rem 0.875rem;\n  border: 1.5px solid var(--line-strong);\n  border-radius: 999px;\n  font-family: var(--font-serif);\n  font-style: italic;\n  font-size: 0.9375rem;\n  color: var(--ink-soft);\n  cursor: pointer;\n  list-style: none;\n}\n\n.grMenuSummary::-webkit-details-marker { display: none; }\n.grMenu[open] .grMenuSummary { color: var(--ink); border-color: var(--ink); }\n\n.grMenuPanel {\n  position: absolute;\n  right: 0;\n  top: calc(100% + 0.6rem);\n  z-index: 30;\n  display: grid;\n  gap: 0.25rem;\n  min-width: 11rem;\n  padding: 0.625rem;\n  border: 1px solid var(--line);\n  border-radius: 0.5rem;\n  background: var(--paper-panel);\n  box-shadow: 0 18px 40px -22px rgba(43, 43, 43, 0.5);\n}\n\n.grMenuLink {\n  padding: 0.5rem 0.625rem;\n  border-radius: 0.375rem;\n  font-family: var(--font-serif);\n  font-size: 1.0625rem;\n  color: var(--ink-soft);\n}\n\n.grMenuLink:hover { background: var(--accent-soft); color: var(--ink); }\n\n@media (min-width: 52rem) {\n  .grNav { display: flex; }\n  .grHeaderRow { height: 5.25rem; }\n  .grMenu { display: none; }\n}\n\n/* ---------------------------------------------------------------------- hero */\n\n.grHero {\n  display: grid;\n  gap: clamp(2.5rem, 6vw, 4rem);\n  padding-top: clamp(2.5rem, 7vh, 5rem);\n  padding-bottom: clamp(2.5rem, 6vh, 4.5rem);\n  align-items: center;\n}\n\n.grHeroText { display: grid; gap: 1.75rem; align-content: center; }\n\n.grKicker {\n  font-family: var(--font-serif);\n  font-style: italic;\n  font-size: clamp(1.0625rem, 1.4vw, 1.25rem);\n  color: var(--accent);\n}\n\n.grDisplay {\n  margin: 0;\n  font-family: var(--font-serif);\n  font-size: var(--display);\n  font-weight: 600;\n  line-height: 0.98;\n  letter-spacing: -0.03em;\n}\n\n.grDisplayLine { display: block; }\n\n/* The word that carries the drawn-in underline sits over its own mark. */\n.grMarkWord {\n  position: relative;\n  display: inline-block;\n  white-space: nowrap;\n}\n\n.grMarkWord > .grUnderline {\n  position: absolute;\n  left: -2%;\n  bottom: -0.16em;\n  width: 104%;\n  height: 0.34em;\n  overflow: visible;\n  color: var(--accent);\n  pointer-events: none;\n}\n\n.grHeroActions {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.875rem;\n  align-items: center;\n  margin-top: 0.5rem;\n}\n\n/*\n * The portrait, pinned to the paper like a sketch: a cream mat with a hairline\n * frame, a soft lifted shadow, a couple of degrees of rotation, and two strips\n * of tape across the top corners. It straightens itself when you hover it.\n */\n.grPortrait {\n  justify-self: center;\n  width: min(100%, 24rem);\n  margin: 0;\n}\n\n.grPortraitFrame {\n  position: relative;\n  padding: 0.75rem 0.75rem 0;\n  background: var(--paper-panel);\n  border: 1px solid var(--line);\n  border-radius: 2px;\n  box-shadow:\n    0 1px 0 rgba(255, 255, 255, 0.6) inset,\n    0 26px 50px -26px rgba(43, 43, 43, 0.55),\n    0 6px 14px -10px rgba(43, 43, 43, 0.4);\n  transform: rotate(-2.2deg);\n  transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.grPortrait:hover .grPortraitFrame { transform: rotate(0deg); }\n\n.grPortraitImg {\n  display: block;\n  width: 100%;\n  height: auto;\n  border-radius: 1px;\n  /* Nudges the cream scan toward the paper so it sits in the page, not on it. */\n  filter: grayscale(0.12) contrast(1.03);\n  mix-blend-mode: multiply;\n}\n\n/* Two strips of low-tack tape holding it to the sheet. */\n.grTape {\n  position: absolute;\n  top: -0.7rem;\n  width: 4.5rem;\n  height: 1.5rem;\n  background: var(--tape);\n  border: 1px solid rgba(43, 43, 43, 0.06);\n  box-shadow: 0 2px 6px -3px rgba(43, 43, 43, 0.35);\n}\n\n.grTape::after {\n  /* A faint highlight down the tape so it reads as slightly glossy. */\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  background: linear-gradient(105deg, rgba(255, 255, 255, 0.35), transparent 45%);\n}\n\n.grTapeLeft { left: 1.25rem; transform: rotate(-6deg); }\n.grTapeRight { right: 1.25rem; transform: rotate(5deg); }\n\n.grPortraitCaption {\n  margin: 0.85rem 0.25rem 0.6rem;\n  font-family: var(--font-serif);\n  font-style: italic;\n  font-size: 0.9375rem;\n  color: var(--ink-dim);\n  text-align: center;\n}\n\n@media (min-width: 60rem) {\n  .grHero {\n    grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);\n    gap: clamp(3rem, 6vw, 6rem);\n  }\n  .grPortrait { justify-self: end; width: min(100%, 27rem); }\n}\n\n/* --------------------------------------------------------------------- about */\n\n.grAbout { display: grid; gap: clamp(2rem, 5vw, 3.5rem); }\n\n.grAboutHead { display: grid; gap: 0.875rem; align-content: start; }\n\n.grAboutBody { display: grid; gap: 1.375rem; max-width: 60rem; }\n\n.grAboutBody p {\n  margin: 0;\n  font-size: clamp(1.0625rem, 1.15vw, 1.1875rem);\n  line-height: 1.72;\n  color: var(--ink-soft);\n  text-wrap: pretty;\n}\n\n/* A drop cap on the opening paragraph, set in the display serif. */\n.grAboutBody p:first-of-type::first-letter {\n  float: left;\n  margin: 0.06em 0.14em 0 0;\n  font-family: var(--font-serif);\n  font-size: 3.6em;\n  line-height: 0.74;\n  font-weight: 600;\n  color: var(--ink);\n}\n\n/* The one line big enough to stop on — a hanging pull quote with a red rule. */\n.grPull {\n  margin: 0.5rem 0;\n  padding-left: 1.25rem;\n  border-left: 2px solid var(--accent);\n  font-family: var(--font-serif);\n  font-size: clamp(1.375rem, 2.4vw, 1.9375rem);\n  font-style: italic;\n  line-height: 1.28;\n  color: var(--ink);\n  text-wrap: balance;\n}\n\n/* The margin annotation, tilted like it was scribbled in later. */\n.grAboutNote {\n  display: inline-flex;\n  align-items: flex-start;\n  gap: 0.5rem;\n  transform: rotate(-1.5deg);\n  color: var(--accent);\n}\n\n.grAboutNote .grArrowNote { flex: none; margin-top: 0.15rem; color: var(--accent); }\n.grAboutNote span { font-family: var(--font-serif); font-style: italic; font-size: 1.0625rem; color: var(--ink-dim); }\n\n@media (min-width: 60rem) {\n  .grAbout { grid-template-columns: 15rem minmax(0, 1fr); align-items: start; gap: 4rem; }\n  .grAboutHead { position: sticky; top: 7rem; }\n  /* Set the essay in two columns of ruled prose on a wide screen. */\n  .grAboutBody { column-count: 2; column-gap: 3rem; display: block; max-width: none; }\n  .grAboutBody > * { break-inside: avoid; }\n  .grPull { column-span: all; margin-block: 1.25rem; }\n}\n\n/* -------------------------------------------------------------------- skills */\n\n.grSkills {\n  display: grid;\n  gap: 0;\n  border-top: 1px solid var(--line);\n}\n\n.grSkillGroup {\n  display: grid;\n  gap: 1rem;\n  padding-block: clamp(1.5rem, 3.5vw, 2.25rem);\n  border-bottom: 1px solid var(--line);\n}\n\n.grSkillTitle {\n  margin: 0;\n  font-family: var(--font-serif);\n  font-size: 1.375rem;\n  font-weight: 600;\n  letter-spacing: -0.01em;\n}\n\n.grSkillList {\n  display: grid;\n  gap: 0.625rem 1.5rem;\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n.grSkillItem {\n  display: grid;\n  grid-template-columns: 1.1rem minmax(0, 1fr);\n  gap: 0.625rem;\n  align-items: baseline;\n  color: var(--ink-soft);\n}\n\n/* A little hand-drawn tick instead of a bullet. */\n.grSkillItem .grTick { color: var(--accent); transform: translateY(0.1rem); }\n\n@media (min-width: 44rem) {\n  .grSkillList { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n}\n\n@media (min-width: 60rem) {\n  .grSkillGroup { grid-template-columns: 13rem minmax(0, 1fr); gap: 2.5rem; align-items: start; }\n}\n\n/* ---------------------------------------------------------------------- work */\n\n.grWorkGrid {\n  display: grid;\n  gap: clamp(1.5rem, 3vw, 2.25rem);\n}\n\n/*\n * Each card is an index card pinned to the page: warm panel stock, a red margin\n * rule down the left, faint blue horizontal rules behind the copy, a hairline\n * border and a soft shadow. Odd and even cards tilt opposite ways and settle\n * straight when you hover.\n */\n.grCard {\n  position: relative;\n  display: grid;\n  gap: 1rem;\n  padding: clamp(1.5rem, 3vw, 2.25rem);\n  padding-left: clamp(2.25rem, 4vw, 3rem);\n  background:\n    linear-gradient(var(--paper-panel), var(--paper-panel)) padding-box,\n    repeating-linear-gradient(var(--paper-panel), var(--paper-panel) 1.85rem, var(--rule-blue) 1.85rem, var(--rule-blue) calc(1.85rem + 1px));\n  border: 1px solid var(--line);\n  border-radius: 3px;\n  box-shadow: 0 20px 40px -30px rgba(43, 43, 43, 0.55);\n  overflow: hidden;\n  transition:\n    transform 0.45s cubic-bezier(0.22, 1, 0.36, 1),\n    box-shadow 0.45s cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n/* The red margin line, like the one down a school jotter. */\n.grCard::before {\n  content: \"\";\n  position: absolute;\n  top: 0;\n  bottom: 0;\n  left: clamp(1.25rem, 2.5vw, 1.75rem);\n  width: 1.5px;\n  background: var(--accent);\n  opacity: 0.55;\n}\n\n.grCard:hover {\n  transform: translateY(-4px) rotate(0deg);\n  box-shadow: 0 32px 55px -32px rgba(43, 43, 43, 0.6);\n}\n\n.grCardHead {\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: 1rem;\n}\n\n.grCardIndex {\n  font-family: var(--font-serif);\n  font-style: italic;\n  font-size: 1.0625rem;\n  color: var(--accent);\n}\n\n.grCardMeta {\n  font-family: var(--font-serif);\n  font-style: italic;\n  font-size: 0.9375rem;\n  color: var(--ink-dim);\n}\n\n.grCardTitle {\n  margin: 0;\n  font-family: var(--font-serif);\n  font-size: clamp(1.625rem, 2.6vw, 2.125rem);\n  font-weight: 600;\n  line-height: 1.05;\n  letter-spacing: -0.015em;\n}\n\n.grCardBody { margin: 0; color: var(--ink-soft); text-wrap: pretty; }\n\n.grCardTags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.5rem;\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n.grCardTag {\n  padding: 0.25rem 0.6875rem;\n  border: 1px solid var(--line-strong);\n  border-radius: 999px;\n  font-size: 0.8125rem;\n  color: var(--ink-dim);\n}\n\n.grCardLink {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.4rem;\n  margin-top: 0.25rem;\n  font-family: var(--font-serif);\n  font-size: 1.0625rem;\n  color: var(--ink);\n  border-bottom: 1.5px solid var(--accent);\n  padding-bottom: 1px;\n  width: fit-content;\n  transition: gap 0.3s cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.grCardLink:hover { gap: 0.7rem; }\n.grCardLink .grArrow { color: var(--accent); }\n\n@media (min-width: 48rem) {\n  .grWorkGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n  .grCardOdd { transform: rotate(-1deg); }\n  .grCardEven { transform: rotate(0.9deg); }\n}\n\n/* ------------------------------------------------------------------- contact */\n\n.grContact {\n  display: grid;\n  gap: 1.75rem;\n  justify-items: center;\n  text-align: center;\n  padding-block: clamp(1rem, 3vw, 2rem);\n}\n\n.grContactHeading {\n  margin: 0;\n  max-width: 20ch;\n  font-family: var(--font-serif);\n  font-size: var(--display-sm);\n  font-weight: 600;\n  line-height: 1.05;\n  letter-spacing: -0.02em;\n  text-wrap: balance;\n}\n\n/* The circled word — the SVG ellipse is drawn around it on reveal. */\n.grCircleWord {\n  position: relative;\n  display: inline-block;\n  color: var(--accent);\n  padding-inline: 0.15em;\n}\n\n.grCircleWord > .grCircle {\n  position: absolute;\n  left: -12%;\n  top: -22%;\n  width: 124%;\n  height: 148%;\n  overflow: visible;\n  color: var(--accent);\n  pointer-events: none;\n}\n\n.grContactBody { max-width: 38rem; color: var(--ink-soft); text-wrap: pretty; }\n\n.grContactActions {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 1.25rem;\n}\n\n.grSocials {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: center;\n  gap: 0.75rem 1.75rem;\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n.grSocial {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.5rem;\n  color: var(--ink-soft);\n  transition: color 0.25s ease;\n}\n\n.grSocial:hover { color: var(--accent); }\n.grSocial svg { flex: none; }\n.grSocialHandle { font-family: var(--font-serif); font-style: italic; font-size: 1rem; }\n\n/* -------------------------------------------------------------------- footer */\n\n.grFooter { border-top: 1px solid var(--line); padding-top: clamp(3rem, 6vw, 4.5rem); padding-bottom: clamp(2rem, 4vw, 3rem); }\n\n.grFooterTop {\n  display: grid;\n  gap: 2rem;\n  padding-bottom: 2.5rem;\n}\n\n.grFooterBlurb {\n  margin: 0;\n  max-width: 30rem;\n  font-size: 0.9375rem;\n  line-height: 1.6;\n  color: var(--ink-dim);\n  text-wrap: pretty;\n}\n\n.grFooterLinks {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.75rem 1.75rem;\n  align-items: start;\n}\n\n.grFooterLink {\n  font-family: var(--font-serif);\n  font-size: 1.0625rem;\n  color: var(--ink-soft);\n  transition: color 0.25s ease;\n}\n\n.grFooterLink:hover { color: var(--accent); }\n\n.grFooterBottom {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.75rem 2rem;\n  justify-content: space-between;\n  align-items: baseline;\n  padding-top: 1.75rem;\n  border-top: 1px solid var(--line);\n}\n\n.grFooterCopy { font-size: 0.875rem; color: var(--ink-faint); }\n.grFooterNote { font-family: var(--font-serif); font-style: italic; font-size: 1rem; color: var(--ink-dim); }\n\n/*\n * The wordmark that closes the page, set huge in outline and allowed to be\n * clipped by the wrapper — that is the effect, not a bug. It reads like a name\n * signed lightly in pencil across the bottom of the sheet.\n */\n.grFooterWord {\n  margin: 0;\n  padding-top: clamp(1.5rem, 5vw, 3rem);\n  font-family: var(--font-serif);\n  font-size: clamp(3.5rem, 15vw, 12rem);\n  font-weight: 600;\n  line-height: 0.82;\n  letter-spacing: -0.03em;\n  color: transparent;\n  -webkit-text-stroke: 1px var(--line-strong);\n  text-stroke: 1px var(--line-strong);\n  user-select: none;\n}\n\n@media (min-width: 52rem) {\n  .grFooterTop { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 4rem; }\n}\n\n/* ------------------------------------------------------- hand-drawn marks */\n\n/*\n * Every SVG mark draws itself with `stroke-dashoffset`. The stroke is normalised\n * to a path length of 1 (`pathLength=\"1\"` in the markup), so the offset is just\n * a fraction and no per-path measuring is needed. The mark starts fully\n * \"un-drawn\" and animates to drawn when an ancestor `.grReveal` flips\n * `data-shown=\"true\"` — so the ink appears to be laid down as the section\n * arrives. Marks not inside a Reveal are drawn from the start.\n */\n.grStroke {\n  fill: none;\n  stroke: currentColor;\n  stroke-width: 2.25;\n  stroke-linecap: round;\n  stroke-linejoin: round;\n  stroke-dasharray: 1;\n  stroke-dashoffset: 1;\n}\n\n.grReveal .grStroke {\n  transition: stroke-dashoffset 0.9s cubic-bezier(0.65, 0, 0.35, 1);\n  transition-delay: calc(var(--gr-delay, 0ms) + 120ms);\n}\n\n.grReveal[data-shown=\"true\"] .grStroke { stroke-dashoffset: 0; }\n\n/* -------------------------------------------------------------- scroll reveal */\n\n/*\n * Reveal on scroll, driven by one IntersectionObserver in `components/reveal.tsx`\n * that flips `data-shown`. Two rules matter for correctness:\n *   - The hidden state is set here, not in JS, so nothing flashes in before the\n *     observer attaches.\n *   - Under `prefers-reduced-motion` the element is simply visible, with no\n *     transform and no draw-in — a translate is exactly what triggers vestibular\n *     discomfort, and the marks are simply shown already inked.\n */\n.grReveal {\n  opacity: 0;\n  transform: translate3d(0, 1.5rem, 0);\n  transition:\n    opacity 0.8s cubic-bezier(0.22, 1, 0.36, 1),\n    transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);\n  transition-delay: var(--gr-delay, 0ms);\n}\n\n.grReveal[data-shown=\"true\"] {\n  opacity: 1;\n  transform: translate3d(0, 0, 0);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .grReveal,\n  .grReveal[data-shown=\"true\"] {\n    opacity: 1;\n    transform: none;\n    transition: none;\n  }\n\n  .grStroke,\n  .grReveal .grStroke,\n  .grReveal[data-shown=\"true\"] .grStroke {\n    stroke-dashoffset: 0;\n    transition: none;\n  }\n\n  .grSite *,\n  .grSite *::before,\n  .grSite *::after {\n    transition-duration: 0.01ms !important;\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    scroll-behavior: auto !important;\n  }\n}\n","type":"registry:style"},{"path":"app/graphite/page.tsx","target":"app/graphite/page.tsx","content":"import { Reveal } from \"./components/reveal\"\nimport { Arrow, ArrowNote, Circle, Tick, Underline } from \"./components/sketch-marks\"\nimport { about, contact, hero, skills, work } from \"./data/content\"\n\nconst socialIcon: Record<string, React.ReactNode> = {\n  github: <GitHubIcon />,\n  linkedin: <LinkedInIcon />,\n  mail: <MailIcon />,\n}\n\n/**\n * The graphite portfolio: one long scroll on warm paper.\n *\n * The order is the argument — who I am, the work, the essay behind it, the\n * toolkit, and a quiet way to get in touch. Every string comes from\n * `data/content.ts`; the marks come from `components/sketch-marks.tsx`.\n */\nexport default function GraphiteHome() {\n  return (\n    <>\n      {/* ---------------------------------------------------------------- hero */}\n      <section className=\"grWrap grHero\">\n        <Reveal className=\"grHeroText\">\n          <p className=\"grKicker\">{hero.kicker}</p>\n          <h1 className=\"grDisplay\">\n            {hero.title.map((line) => (\n              <span key={line.text} className=\"grDisplayLine\">\n                {line.mark ? (\n                  <span className=\"grMarkWord\">\n                    {line.text}\n                    <Underline className=\"grUnderline\" />\n                  </span>\n                ) : (\n                  line.text\n                )}\n              </span>\n            ))}\n          </h1>\n          <p className=\"grLede\">{hero.lede}</p>\n          <div className=\"grHeroActions\">\n            <a className=\"grBtn grBtnSolid\" href={hero.primary.href}>\n              {hero.primary.label}\n              <Arrow className=\"grArrow\" />\n            </a>\n            <a className=\"grBtn\" href={hero.secondary.href}>\n              {hero.secondary.label}\n            </a>\n          </div>\n        </Reveal>\n\n        <Reveal as=\"figure\" className=\"grPortrait\" delay={120}>\n          <div className=\"grPortraitFrame\">\n            <span className=\"grTape grTapeLeft\" aria-hidden=\"true\" />\n            <span className=\"grTape grTapeRight\" aria-hidden=\"true\" />\n            <img\n              className=\"grPortraitImg\"\n              src={hero.portrait.src}\n              width={1024}\n              height={1024}\n              alt={hero.portrait.alt}\n            />\n          </div>\n          <figcaption className=\"grPortraitCaption\">{hero.portrait.caption}</figcaption>\n        </Reveal>\n      </section>\n\n      <div className=\"grWrap\">\n        <hr className=\"grRule\" />\n      </div>\n\n      {/* ---------------------------------------------------------------- work */}\n      <section id=\"work\" className=\"grSection grWrap\">\n        <Reveal className=\"grSectionHead\">\n          <p className=\"grLabel\">{work.label}</p>\n          <h2 className=\"grHeading\">{work.heading}</h2>\n        </Reveal>\n\n        <div className=\"grWorkGrid\">\n          {work.projects.map((project, index) => (\n            <Reveal\n              key={project.index}\n              as=\"article\"\n              className={`grCard ${index % 2 === 0 ? \"grCardOdd\" : \"grCardEven\"}`}\n              delay={(index % 2) * 90}\n            >\n              <div className=\"grCardHead\">\n                <span className=\"grCardIndex\">No. {project.index}</span>\n                <span className=\"grCardMeta\">\n                  {project.kind} · {project.year}\n                </span>\n              </div>\n              <h3 className=\"grCardTitle\">{project.title}</h3>\n              <p className=\"grCardBody\">{project.body}</p>\n              <ul className=\"grCardTags\">\n                {project.tags.map((tag) => (\n                  <li key={tag} className=\"grCardTag\">\n                    {tag}\n                  </li>\n                ))}\n              </ul>\n              <a\n                className=\"grCardLink\"\n                href={project.link.href}\n                target=\"_blank\"\n                rel=\"noreferrer\"\n              >\n                {project.link.label}\n                <Arrow className=\"grArrow\" size={16} />\n              </a>\n            </Reveal>\n          ))}\n        </div>\n      </section>\n\n      <div className=\"grWrap\">\n        <hr className=\"grRule\" />\n      </div>\n\n      {/* --------------------------------------------------------------- about */}\n      <section id=\"about\" className=\"grSection grWrap\">\n        <div className=\"grAbout\">\n          <Reveal className=\"grAboutHead\">\n            <p className=\"grLabel\">{about.label}</p>\n            <h2 className=\"grHeading\">{about.lead}</h2>\n            <span className=\"grAboutNote\">\n              <ArrowNote className=\"grArrowNote\" />\n              <span>{about.note}</span>\n            </span>\n          </Reveal>\n\n          <Reveal className=\"grAboutBody\" delay={90}>\n            {about.paragraphs.map((paragraph, index) => (\n              <p key={index}>{paragraph}</p>\n            ))}\n            <p className=\"grPull\">{about.pull}</p>\n          </Reveal>\n        </div>\n      </section>\n\n      <div className=\"grWrap\">\n        <hr className=\"grRule\" />\n      </div>\n\n      {/* -------------------------------------------------------------- skills */}\n      <section className=\"grSection grWrap\">\n        <Reveal className=\"grSectionHead\">\n          <p className=\"grLabel\">{skills.label}</p>\n          <h2 className=\"grHeading\">{skills.heading}</h2>\n        </Reveal>\n\n        <div className=\"grSkills\">\n          {skills.groups.map((group, index) => (\n            <Reveal key={group.title} className=\"grSkillGroup\" delay={index * 70}>\n              <h3 className=\"grSkillTitle\">{group.title}</h3>\n              <ul className=\"grSkillList\">\n                {group.items.map((item) => (\n                  <li key={item} className=\"grSkillItem\">\n                    <Tick className=\"grTick\" />\n                    <span>{item}</span>\n                  </li>\n                ))}\n              </ul>\n            </Reveal>\n          ))}\n        </div>\n      </section>\n\n      <div className=\"grWrap\">\n        <hr className=\"grRule\" />\n      </div>\n\n      {/* ------------------------------------------------------------- contact */}\n      <section id=\"contact\" className=\"grSection grWrap\">\n        <Reveal className=\"grContact\">\n          <p className=\"grLabel\">{contact.label}</p>\n          <h2 className=\"grContactHeading\">\n            {contact.heading.lead}{\" \"}\n            <span className=\"grCircleWord\">\n              {contact.heading.accent}\n              <Circle className=\"grCircle\" />\n            </span>\n            {contact.heading.tail}\n          </h2>\n          <p className=\"grContactBody\">{contact.body}</p>\n          <div className=\"grContactActions\">\n            <a className=\"grBtn grBtnSolid\" href={contact.cta.href}>\n              {contact.cta.label}\n              <Arrow className=\"grArrow\" />\n            </a>\n            <ul className=\"grSocials\">\n              {contact.socials.map((social) => (\n                <li key={social.label}>\n                  <a className=\"grSocial\" href={social.href} target=\"_blank\" rel=\"noreferrer\">\n                    {socialIcon[social.icon]}\n                    <span className=\"grSocialHandle\">{social.handle}</span>\n                  </a>\n                </li>\n              ))}\n            </ul>\n            <p className=\"grNote\">{contact.note}</p>\n          </div>\n        </Reveal>\n      </section>\n    </>\n  )\n}\n\n/* Inline social glyphs — small enough that a dependency would be overkill. */\n\nfunction GitHubIcon() {\n  return (\n    <svg width={18} height={18} viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n      <path d=\"M12 1.5a10.5 10.5 0 0 0-3.32 20.47c.53.1.72-.23.72-.5v-1.75c-2.92.63-3.54-1.4-3.54-1.4-.48-1.22-1.17-1.55-1.17-1.55-.96-.65.07-.64.07-.64 1.06.08 1.62 1.09 1.62 1.09.94 1.62 2.47 1.15 3.07.88.1-.68.37-1.15.67-1.42-2.33-.27-4.78-1.17-4.78-5.19 0-1.15.41-2.08 1.09-2.82-.11-.27-.47-1.34.1-2.79 0 0 .89-.28 2.9 1.08a10.1 10.1 0 0 1 5.28 0c2.01-1.36 2.9-1.08 2.9-1.08.57 1.45.21 2.52.1 2.79.68.74 1.09 1.67 1.09 2.82 0 4.03-2.46 4.92-4.8 5.18.38.33.71.97.71 1.96v2.9c0 .28.19.61.73.5A10.5 10.5 0 0 0 12 1.5Z\" />\n    </svg>\n  )\n}\n\nfunction LinkedInIcon() {\n  return (\n    <svg width={18} height={18} viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n      <path d=\"M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.42v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28ZM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13ZM7.12 20.45H3.55V9h3.57v11.45ZM22.22 0H1.77C.8 0 0 .78 0 1.75v20.5C0 23.22.8 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.75V1.75C24 .78 23.2 0 22.22 0Z\" />\n    </svg>\n  )\n}\n\nfunction MailIcon() {\n  return (\n    <svg\n      width={18}\n      height={18}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.75}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <rect x={2.5} y={4.5} width={19} height={15} rx={2} />\n      <path d=\"m3 6 9 6 9-6\" />\n    </svg>\n  )\n}\n","type":"registry:page"},{"path":"app/graphite/data/content.ts","target":"app/graphite/data/content.ts","content":"/**\n * Everything the template says, in one file.\n *\n * The page and its components read from here and hold no copy of their own, so\n * re-voicing the whole portfolio for a different person is one file to edit and\n * no JSX to touch. Swap the palette in `graphite.css` and the words here and\n * nothing else needs to change.\n *\n * The demo persona is a design engineer who trained as an illustrator, because\n * the graphite/sketchbook aesthetic wants someone who genuinely works by hand.\n * Replace it with yours — keep it specific, and it will keep feeling real.\n */\n\n/** Where the template is mounted. Change this and every internal link follows. */\nexport const BASE = \"/graphite\"\n\nexport const profile = {\n  name: \"Rowan Ashcroft\",\n  /** Sits under the name in the header and drives the metadata title. */\n  role: \"Design engineer & illustrator\",\n  location: \"Edinburgh\",\n  year: 2026,\n  email: \"rowan@ashcroft.studio\",\n} as const\n\n/** In-page anchors — this is one long scroll, not a set of routes. */\nexport const nav = [\n  { label: \"Work\", href: \"#work\" },\n  { label: \"About\", href: \"#about\" },\n  { label: \"Contact\", href: \"#contact\" },\n] as const\n\n/**\n * The opening. `title` is set line by line at display size, so keep each line\n * short — the line breaks are the composition. The line flagged `mark` gets the\n * hand-drawn underline drawn under it.\n */\nexport const hero = {\n  kicker: \"Design engineer, illustrator — Edinburgh\",\n  title: [\n    { text: \"Drawn first,\", mark: false },\n    { text: \"then built.\", mark: true },\n  ] as const,\n  lede:\n    \"I'm Rowan — a design engineer who still reaches for a 2B pencil before a text editor. I make interfaces that feel considered, tactile, and quietly alive.\",\n  primary: { label: \"See the work\", href: \"#work\" },\n  secondary: { label: \"Say hello\", href: \"#contact\" },\n  portrait: {\n    src: `${BASE}/portrait.png`,\n    alt: \"Graphite pencil self-portrait of Rowan Ashcroft on cream sketchbook paper.\",\n    caption: \"Self-portrait — 2B on cartridge paper\",\n  },\n} as const\n\n/**\n * The essay-like About. `note` is the handwritten margin annotation; `pull` is\n * the one line big enough to stop on. Paragraphs read as one continuous voice,\n * set in editorial columns on a wide screen.\n */\nexport const about = {\n  label: \"About\",\n  lead: \"The short version, in my own hand.\",\n  pull: \"A pencil is the most honest prototyping tool I own.\",\n  note: \"still drawing strangers on the train, badly and happily\",\n  paragraphs: [\n    \"For ten years I've worked in the seam between design and engineering — the place where a flat mockup becomes something you can actually click, and where most of the good decisions quietly get made or lost.\",\n    \"I trained as an illustrator long before I wrote a line of code, and it shows. I still sketch every interface on paper first, because a pencil is faster than any prototyping tool and far more honest about what isn't working yet.\",\n    \"These days I build design systems and the front-of-the-frontend for studios and small product teams. I like problems that need both halves of the brain: a type scale that has to survive real content, an animation that has to feel inevitable rather than decorative.\",\n    \"The work I care about looks calm and reads slow, but it's held together by an obsessive amount of small craft — the easing on a menu, the weight of a rule, the half-second before a page settles into place.\",\n  ] as const,\n} as const\n\n/** A tidy, understated grid. Four groups, four items each. */\nexport const skills = {\n  label: \"Toolkit\",\n  heading: \"What I reach for\",\n  note: \"no framework loyalty, only taste\",\n  groups: [\n    {\n      title: \"Interface & interaction\",\n      items: [\"React & TypeScript\", \"Motion & micro-interaction\", \"Accessibility first\", \"Progressive enhancement\"],\n    },\n    {\n      title: \"Type & layout\",\n      items: [\"Editorial typography\", \"Design systems\", \"Variable fonts\", \"CSS architecture\"],\n    },\n    {\n      title: \"By hand\",\n      items: [\"Illustration\", \"Hand-lettering\", \"Figma & prototyping\", \"Storyboarding\"],\n    },\n    {\n      title: \"Foundations\",\n      items: [\"Next.js\", \"Node & APIs\", \"Web performance\", \"Design tokens\"],\n    },\n  ],\n} as const\n\n/**\n * Selected work. Each project is a sketchbook entry — an index, a kind, a year,\n * a title, a couple of lines, its stack, and one link out. Three or four reads\n * best; the cards are meant to feel pinned to the page.\n */\nexport const work = {\n  label: \"Selected work\",\n  heading: \"A few things I've drawn and built\",\n  projects: [\n    {\n      index: \"01\",\n      kind: \"Product\",\n      year: \"2025\",\n      title: \"Marginalia\",\n      body:\n        \"A reading app that turns your highlights and marginal notes into a searchable personal index. I built the reading surface, the annotation model, and offline-first sync.\",\n      tags: [\"React\", \"TypeScript\", \"IndexedDB\", \"PWA\"],\n      link: { label: \"Read the notes\", href: \"https://github.com\" },\n    },\n    {\n      index: \"02\",\n      kind: \"Tool\",\n      year: \"2024\",\n      title: \"Silverpoint\",\n      body:\n        \"A browser drawing tool with pressure-sensitive vector strokes and a real paper-grain canvas. I wrote the stroke engine and a WASM smoothing pipeline that keeps lines honest.\",\n      tags: [\"Canvas\", \"WebGL\", \"Rust · WASM\", \"Pointer Events\"],\n      link: { label: \"See it move\", href: \"https://github.com\" },\n    },\n    {\n      index: \"03\",\n      kind: \"System\",\n      year: \"2024\",\n      title: \"Foundry\",\n      body:\n        \"An editorial design system for a books publisher — 200+ tokens, a variable type scale, and a Figma-to-code pipeline that kept design and engineering telling the same story.\",\n      tags: [\"Design tokens\", \"Style Dictionary\", \"Figma API\", \"Storybook\"],\n      link: { label: \"Open the case study\", href: \"https://github.com\" },\n    },\n    {\n      index: \"04\",\n      kind: \"Concept\",\n      year: \"2023\",\n      title: \"Nightpost\",\n      body:\n        \"A slow reader for long-form writing. I designed the calm typographic reading experience and a quiet notification model that respects your evening instead of interrupting it.\",\n      tags: [\"Next.js\", \"Postgres\", \"Typography\", \"Motion\"],\n      link: { label: \"Read the notes\", href: \"https://github.com\" },\n    },\n  ],\n} as const\n\n/** The quiet close. `heading` is split so one word can be circled by hand. */\nexport const contact = {\n  label: \"Contact\",\n  heading: { lead: \"Let's make something\", accent: \"considered\", tail: \".\" },\n  body:\n    \"I take on a small number of studio and product projects each year — design systems, tricky front-end, the occasional illustration. If that sounds like your kind of thing, say hello.\",\n  note: \"usually a reply within a day or two\",\n  cta: { label: \"Email me\", href: `mailto:${profile.email}` },\n  socials: [\n    { label: \"GitHub\", handle: \"rowanashcroft\", href: \"https://github.com\", icon: \"github\" },\n    { label: \"LinkedIn\", handle: \"in/rowanashcroft\", href: \"https://linkedin.com\", icon: \"linkedin\" },\n    { label: \"Email\", handle: profile.email, href: `mailto:${profile.email}`, icon: \"mail\" },\n  ],\n} as const\n\nexport const footer = {\n  blurb:\n    \"Design engineer and illustrator, drawn and coded in Edinburgh. This is a demo of the graphite portfolio template — every word on it lives in one file.\",\n  links: [\n    { label: \"Work\", href: \"#work\" },\n    { label: \"About\", href: \"#about\" },\n    { label: \"Contact\", href: \"#contact\" },\n    { label: \"Email\", href: `mailto:${profile.email}` },\n  ],\n  note: \"Drawn & built in Edinburgh\",\n} as const\n","type":"registry:lib"},{"path":"app/graphite/components/site-header.tsx","target":"app/graphite/components/site-header.tsx","content":"\"use client\"\n\nimport { useRef } from \"react\"\n\nimport { nav, profile } from \"../data/content\"\n\n/**\n * The header. A client component only so the mobile `<details>` menu can close\n * itself when a link is tapped — this is a one-page scroll, so the links are\n * in-page anchors and there is no route change to key off.\n *\n * The menu is a native `<details>` rather than React state: nothing to get\n * wrong about focus or `aria-expanded`, and it still works if hydration never\n * happens.\n */\nexport function SiteHeader() {\n  const menu = useRef<HTMLDetailsElement>(null)\n\n  const closeMenu = () => {\n    if (menu.current) menu.current.open = false\n  }\n\n  return (\n    <header className=\"grHeader\">\n      <div className=\"grWrap grHeaderRow\">\n        <a className=\"grBrand\" href=\"#top\" aria-label={`${profile.name}, home`}>\n          <span className=\"grBrandName\">{profile.name}</span>\n          <span className=\"grBrandRole\">{profile.role}</span>\n        </a>\n\n        <nav className=\"grNav\" aria-label=\"Primary\">\n          {nav.map((link) => (\n            <a key={link.href} href={link.href} className=\"grNavLink\">\n              {link.label}\n            </a>\n          ))}\n        </nav>\n\n        <details className=\"grMenu\" ref={menu}>\n          <summary className=\"grMenuSummary\">Menu</summary>\n          <nav className=\"grMenuPanel\" aria-label=\"Primary\">\n            {nav.map((link) => (\n              <a key={link.href} href={link.href} className=\"grMenuLink\" onClick={closeMenu}>\n                {link.label}\n              </a>\n            ))}\n          </nav>\n        </details>\n      </div>\n    </header>\n  )\n}\n","type":"registry:component"},{"path":"app/graphite/components/site-footer.tsx","target":"app/graphite/components/site-footer.tsx","content":"import { footer, profile } from \"../data/content\"\n\n/**\n * The footer, closing on an oversized outline wordmark meant to be clipped by\n * the page edge — a name signed lightly across the bottom of the sheet. A\n * server component; there is nothing here to hydrate.\n */\nexport function SiteFooter() {\n  return (\n    <footer className=\"grFooter\">\n      <div className=\"grWrap\">\n        <div className=\"grFooterTop\">\n          <p className=\"grFooterBlurb\">{footer.blurb}</p>\n          <nav className=\"grFooterLinks\" aria-label=\"Footer\">\n            {footer.links.map((link) => (\n              <a key={link.label} href={link.href} className=\"grFooterLink\">\n                {link.label}\n              </a>\n            ))}\n          </nav>\n        </div>\n\n        <div className=\"grFooterBottom\">\n          <span className=\"grFooterCopy\">\n            © {profile.year} {profile.name} — {profile.location}\n          </span>\n          <span className=\"grFooterNote\">{footer.note}</span>\n        </div>\n\n        <p className=\"grFooterWord\" aria-hidden=\"true\">\n          {profile.name}\n        </p>\n      </div>\n    </footer>\n  )\n}\n","type":"registry:component"},{"path":"app/graphite/components/reveal.tsx","target":"app/graphite/components/reveal.tsx","content":"\"use client\"\n\nimport {\n  createElement,\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ElementType,\n  type ReactNode,\n} from \"react\"\n\ntype RevealProps = {\n  children: ReactNode\n  /** Merged after `grReveal`, so this is where the layout class goes. */\n  className?: string\n  /** Stagger, in milliseconds. Feeds `--gr-delay` in the stylesheet. */\n  delay?: number\n  /** The element to render. Defaults to a `<div>`; pass `\"figure\"`, `\"li\"`, etc. */\n  as?: ElementType\n}\n\n/**\n * Fades and lifts its children in when they first scroll into view, and — via a\n * `data-shown` attribute the stylesheet reads — triggers any hand-drawn SVG mark\n * inside it to draw its stroke on.\n *\n * It renders the element you ask for and merges `className`, so the revealed\n * element *is* the layout element: `<Reveal as=\"article\" className=\"grCard\">` is\n * a real grid child, not a wrapper that breaks the grid.\n *\n * Three details are deliberate, and copied from the house pattern:\n *   - The observer disconnects on first intersection. Re-hiding content the\n *     reader has passed is motion for its own sake, and a long page then keeps\n *     zero observers alive by the time you reach the footer.\n *   - The hidden state lives in CSS, so nothing flashes in before hydration and\n *     `prefers-reduced-motion` can switch it all off without this component\n *     knowing.\n *   - If `IntersectionObserver` is missing, the content shows immediately.\n *     Failing open is the only acceptable direction for a fade-in.\n */\nexport function Reveal({ children, className, delay = 0, as = \"div\" }: RevealProps) {\n  const ref = useRef<HTMLElement>(null)\n  const [shown, setShown] = useState(false)\n\n  useEffect(() => {\n    const node = ref.current\n    if (!node || shown) return\n\n    if (typeof IntersectionObserver === \"undefined\") {\n      setShown(true)\n      return\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (!entries.some((entry) => entry.isIntersecting)) return\n        setShown(true)\n        observer.disconnect()\n      },\n      /* Held back from the bottom edge so it reads as a reveal, not a pop. */\n      { rootMargin: \"0px 0px -10% 0px\", threshold: 0.05 },\n    )\n\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [shown])\n\n  // Rendered via `createElement` rather than `<Tag>` JSX: a polymorphic\n  // `ElementType` tag makes the JSX prop inference collapse to `never` under\n  // this TypeScript (bare `tsc` tolerates it; `next build` does not), so the\n  // explicit call is the reliable way to render the chosen tag.\n  return createElement(\n    as,\n    {\n      ref,\n      className: className ? `grReveal ${className}` : \"grReveal\",\n      \"data-shown\": shown ? \"true\" : \"false\",\n      style: delay ? ({ \"--gr-delay\": `${delay}ms` } as CSSProperties) : undefined,\n    },\n    children,\n  )\n}\n","type":"registry:component"},{"path":"app/graphite/components/sketch-marks.tsx","target":"app/graphite/components/sketch-marks.tsx","content":"/**\n * Hand-drawn marks, as inline SVG.\n *\n * Every stroke sets `pathLength={1}`, so the stylesheet can animate it with a\n * `stroke-dasharray: 1; stroke-dashoffset: 1 → 0` and never has to measure the\n * real path. When a mark sits inside a `<Reveal>`, `.grReveal[data-shown]` drives\n * the draw-in; anywhere else it simply renders already inked (the CSS default\n * transitions only under a revealed ancestor).\n *\n * The paths are deliberately a little wobbly — a perfectly straight line or a\n * true ellipse would read as a border, not as something drawn by a hand.\n */\n\n/** The sweeping underline drawn beneath a headline word. Stretched by CSS. */\nexport function Underline({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 300 24\"\n      preserveAspectRatio=\"none\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        className=\"grStroke\"\n        pathLength={1}\n        d=\"M4 15 C 60 8, 120 20, 180 12 S 260 6, 296 14\"\n      />\n      <path\n        className=\"grStroke\"\n        pathLength={1}\n        style={{ transitionDelay: \"calc(var(--gr-delay, 0ms) + 320ms)\" } as React.CSSProperties}\n        d=\"M10 20 C 80 15, 150 23, 220 17 S 270 16, 290 19\"\n        opacity={0.5}\n      />\n    </svg>\n  )\n}\n\n/** The rough ellipse circled around an annotated word. Stretched by CSS. */\nexport function Circle({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 240 90\"\n      preserveAspectRatio=\"none\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        className=\"grStroke\"\n        pathLength={1}\n        d=\"M139 6 C 66 1, 12 18, 10 44 C 8 71, 78 85, 140 84 C 205 83, 236 66, 231 42 C 227 21, 190 9, 120 8\"\n      />\n    </svg>\n  )\n}\n\n/** A fixed-size forward arrow for buttons and links. */\nexport function Arrow({ className, size = 18 }: { className?: string; size?: number }) {\n  return (\n    <svg\n      className={className}\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"M5 12h13\" />\n      <path d=\"M13 6l6 6-6 6\" />\n    </svg>\n  )\n}\n\n/** A curved margin arrow that points down-left at a note. */\nexport function ArrowNote({ className, size = 26 }: { className?: string; size?: number }) {\n  return (\n    <svg\n      className={className}\n      width={size}\n      height={size}\n      viewBox=\"0 0 32 32\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path className=\"grStroke\" pathLength={1} d=\"M25 5 C 26 15, 20 24, 9 26\" />\n      <path className=\"grStroke\" pathLength={1} d=\"M14 21 L 8 27 L 16 28\" />\n    </svg>\n  )\n}\n\n/** A small hand-drawn tick, used in place of a bullet. */\nexport function Tick({ className, size = 15 }: { className?: string; size?: number }) {\n  return (\n    <svg\n      className={className}\n      width={size}\n      height={size}\n      viewBox=\"0 0 20 20\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.25}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"M3 11 L 8 16 L 17 4\" />\n    </svg>\n  )\n}\n","type":"registry:component"}],"assets":[{"url":"https://ui.artbloom.tech/r/asset/templates/graphite/assets/portrait.png","target":"public/graphite/portrait.png","bytes":2795978}],"meta":{"kind":"templates","categories":["portfolio","personal"],"docs":"https://ui.artbloom.tech/artbloom/templates/graphite"}}