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