01 Side project / Full-stack / 2026
BadmintonRecords

000
Tim Lacault / portfolio 2026
<script setup lang="ts">
import src from './Hero.vue?raw'
const { t } = useLang()
</script>
<template>
<Blueprint :source="src" class="hero" id="top">
<div class="hero-grid">
<h1 class="display name" data-field="name">
<span class="line">Tim</span>
<span class="line">Lacault</span>
</h1>
<a class="cue mono" href="#work" data-enter style="--d: .75s">
<span class="cue-line" aria-hidden="true" />
{{ t('Scroll to see the work', 'Défilez pour voir le travail') }}
</a>
<div class="hero-side">
<p class="lede" data-enter style="--d: .35s">{{ t('Software engineer in Bordeaux. I build websites that feel alive.', 'Ingénieur logiciel à Bordeaux. Je construis des sites qui ont du vivant.') }}</p>
<div data-enter style="--d: .55s"><Magnetic class="btn" href="#contact">{{ t('Work with me', 'Travaillons ensemble') }}</Magnetic></div>
</div>
</div>
</Blueprint>
</template>
<style scoped>
/* Name sized by both axes so it never pushes the CTA under the dock, whatever the window. */
.hero-grid {
min-height: 100dvh;
padding: clamp(72px, 13vh, 140px) var(--margin) 112px;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-template-rows: auto 1fr auto;
column-gap: 24px;
}
.name { grid-column: 1 / -1; font-size: min(13.6vw, 25vh); transition: color .8s var(--ease-out); }
.line { display: block; }
.line + .line { padding-left: .9em; }
:global(html.field-live .hero .name) { color: transparent; }
.hero-side { grid-column: 8 / -1; grid-row: 3; display: grid; gap: 24px; justify-items: start; align-self: end; }
.lede { font-size: clamp(19px, 1.6vw, 25px); line-height: 1.3; max-width: 24ch; }
.cue {
grid-column: 1 / 5; grid-row: 3; align-self: end;
display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--fg-2);
}
.cue:hover { color: var(--fg); }
.cue-line { position: relative; width: 1px; height: 44px; background: var(--line); overflow: hidden; }
.cue-line::after {
content: ''; position: absolute; left: 0; top: 0; width: 1px; height: 14px; background: var(--accent);
animation: drop 1.8s var(--ease-io) infinite;
}
@keyframes drop { from { translate: 0 -14px; } to { translate: 0 44px; } }
@media (max-width: 767px) {
.name { font-size: 15.5vw; }
.line + .line { padding-left: 0; }
.hero-side { grid-column: 1 / -1; grid-row: 2; align-self: end; }
.cue { grid-column: 1 / -1; margin-top: 40px; }
}
</style>
01 Side project / Full-stack / 2026
Match archive for my badminton club: rally-by-rally tagging, a live scoreboard over YouTube, and an ffmpeg highlight cutter.

02 Startup / Founder / 2026
My startup. It measures the carbon footprint of student trips, and began as a school project I led with a team of five at ENSEIRB-MATMECA.

Hair salons, bakeries, a boxing club, a plumber. Each one designed and built by hand, around the business's real photos, prices and reviews. They're live: open any of them.
Need one for your business?

















































<script setup lang="ts">
import src from './Work.vue?raw'
import { projects, clients } from '~/data/projects'
const { t } = useLang()
</script>
<template>
<Blueprint :source="src" class="work" id="work">
<WorkExhibit v-for="(p, i) in projects" :key="p.slug" :project="p" :index="i" />
<div class="local" id="local">
<div class="local-head">
<h3 v-reveal class="display">{{ clients.length }} {{ t('sites for local businesses', 'sites pour des commerces') }}</h3>
<p v-reveal="1">{{ t("Hair salons, bakeries, a boxing club, a plumber. Each one designed and built by hand, around the business's real photos, prices and reviews. They're live: open any of them.", 'Coiffeurs, boulangeries, un club de boxe, un plombier. Chacun dessiné et codé à la main, avec les vraies photos, les vrais prix et les vrais avis du commerce. Ils sont en ligne : ouvrez-les.') }}</p>
<NuxtLink v-reveal="2" to="/freelance" class="more">{{ t('Need one for your business?', 'Un site pour votre commerce ?') }}</NuxtLink>
</div>
<ClientWall />
</div>
</Blueprint>
</template>
<style scoped>
.local { padding: 16vh 0 18vh; display: grid; gap: 7vh; }
.local-head { padding-inline: var(--margin); display: grid; gap: 22px; max-width: 1100px; }
h3 { font-size: clamp(40px, 5.6vw, 96px); }
.local-head p { font-size: 18px; max-width: 52ch; color: var(--fg-2); }
.more { justify-self: start; text-decoration: none; border-bottom: 1px solid var(--accent); padding-bottom: 2px; }
.more:hover { color: var(--accent); }
</style>
01 Side project / Full-stack / 2026
Match archive for my badminton club: rally-by-rally tagging, a live scoreboard over YouTube, and an ffmpeg highlight cutter.

02 Startup / Founder / 2026
My startup. It measures the carbon footprint of student trips, and began as a school project I led with a team of five at ENSEIRB-MATMECA.

Hair salons, bakeries, a boxing club, a plumber. Each one designed and built by hand, around the business's real photos, prices and reviews. They're live: open any of them.
Need one for your business?

















































How I got here age 13
Scratch
I always thought the way you can create and solve problems with simple tools was beautiful.
Algorithms and logic for the first time. Dragging blocks around until a cat did what I meant.
<!--
How I got here, as a git history you scroll through. A tall wrapper with a sticky stage (sticky, so the
Blueprint copy lays out identically): scrolling moves HEAD along the commits. On the left one huge word
per era is a Field shape, so the glyphs re-form at every commit; on the right the graph draws itself up
to HEAD, branches split off main and stay open, and the current commit opens. The last commit is now:
the word gives way to the portrait, drawn in glyphs.
-->
<script setup lang="ts">
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import src from './Path.vue?raw'
import type { Txt } from '~/composables/useLang'
type Lane = 0 | 1 | 2 // main, freelance, cio
const { t, L } = useLang()
const commits: { word: Txt, when: Txt, lane: Lane, msg: Txt, body: Txt }[] = [
{ word: { en: 'Scratch', fr: 'Scratch' }, when: { en: 'age 13', fr: '13 ans' }, lane: 0, msg: { en: 'init: Scratch', fr: 'init: Scratch' },
body: { en: 'Algorithms and logic for the first time. Dragging blocks around until a cat did what I meant.', fr: "L'algorithmique et la logique pour la première fois. Déplacer des blocs jusqu'à ce qu'un chat fasse ce que je voulais." } },
{ word: { en: 'Calc', fr: 'Calc' }, when: { en: 'high school', fr: 'lycée' }, lane: 0, msg: { en: 'feat: games on a calculator', fr: 'feat: des jeux sur calculatrice' },
body: { en: 'Coding small games while my classmates were still learning what its functions did.', fr: 'Coder des petits jeux pendant que mes camarades apprenaient encore à se servir de ses fonctions.' } },
{ word: { en: 'Web', fr: 'Web' }, when: { en: 'age 17', fr: '17 ans' }, lane: 0, msg: { en: 'feat: first website', fr: 'feat: premier site web' },
body: { en: 'For a high-school environmental project. The moment I saw you could build anything with simple tools.', fr: "Pour un projet environnemental au lycée. Le moment où j'ai compris qu'on pouvait tout construire avec des outils simples." } },
{ word: { en: 'Epitech', fr: 'Epitech' }, when: { en: '2020', fr: '2020' }, lane: 0, msg: { en: 'Epitech', fr: 'Epitech' },
body: { en: 'Two years of project-based IT: learning by shipping, in C and everything after.', fr: "Deux ans d'informatique par projets : apprendre en livrant, en C et tout ce qui a suivi." } },
{ word: { en: 'Car', fr: 'Auto' }, when: { en: '2021', fr: '2021' }, lane: 0, msg: { en: 'Internship at Lormauto, 5 months', fr: 'Stage chez Lormauto, 5 mois' },
body: { en: 'A car dashboard in JavaScript and MQTT: diagnostics, multimedia, GPS and HVAC screens, and a Node-RED sensor simulator.', fr: 'Un tableau de bord de voiture en JavaScript et MQTT : écrans diagnostic, multimédia, GPS et climatisation, et un simulateur de capteurs sous Node-RED.' } },
{ word: { en: 'B.Sc', fr: 'Licence' }, when: { en: '2022', fr: '2022' }, lane: 0, msg: { en: 'Computer Science, Université de Bordeaux', fr: 'Licence informatique, Université de Bordeaux' },
body: { en: 'Theory under the practice: algorithms, systems, languages.', fr: 'La théorie sous la pratique : algorithmes, systèmes, langages.' } },
{ word: { en: 'Clients', fr: 'Clients' }, when: { en: '2023', fr: '2023' }, lane: 1, msg: { en: 'branch freelance', fr: 'branch freelance' },
body: { en: 'My own clients: websites for businesses, designed and built by hand. Still open.', fr: 'Mes propres clients : des sites pour des commerces, dessinés et codés à la main. Toujours ouverte.' } },
{ word: { en: 'Enseirb', fr: 'Enseirb' }, when: { en: '2024', fr: '2024' }, lane: 0, msg: { en: 'Software engineering, Bordeaux INP', fr: 'École d’ingénieurs, Bordeaux INP' },
body: { en: "ENSEIRB-MATMECA. Where Mobilit'Eirb started, with a team of five I led.", fr: "ENSEIRB-MATMECA. Là où Mobilit'Eirb a commencé, avec une équipe de cinq que j'ai menée." } },
{ word: { en: 'CIO', fr: 'DSI' }, when: { en: '2024', fr: '2024' }, lane: 2, msg: { en: 'branch cio: Aquitaine Électronique Informatique', fr: 'branch dsi: Aquitaine Électronique Informatique' },
body: { en: 'Running a Debian and Docker server fleet, secrets, and mail for 30+ members, and the company site in Vue 3.', fr: 'Gérer un parc de serveurs Debian et Docker, les secrets et la messagerie de plus de 30 membres, et le site de la structure en Vue 3.' } },
{ word: { en: 'Now', fr: 'Présent' }, when: { en: '2026', fr: '2026' }, lane: 0, msg: { en: 'HEAD', fr: 'HEAD' },
body: { en: 'Looking for a 4th-year internship (June to September 2027) and my end-of-studies internship (February to September 2028).', fr: 'Je cherche un stage de 4e année (juin à septembre 2027) et mon stage de fin d’études (février à septembre 2028).' } },
]
const N = commits.length
const ROW = 64, X = [18, 54, 90] // graph geometry (px): row height, lane x
const startOf = (lane: Lane) => commits.findIndex((c) => c.lane === lane)
// Branch curves from main into their lane, then straight down to HEAD (they stay open).
const branch = (lane: Lane) => {
const i = startOf(lane), y0 = (i - 1) * ROW + ROW / 2, y1 = i * ROW + ROW / 2
return `M${X[0]} ${y0} C${X[0]} ${y0 + ROW * 0.6} ${X[lane]} ${y1 - ROW * 0.6} ${X[lane]} ${y1} L${X[lane]} ${(N - 1) * ROW + ROW / 2}`
}
const height = N * ROW
const drawn = (lane: Lane, p: number) => {
const s = startOf(lane) / N
return p < s ? 1 : 1 - (p - s) / (1 - s)
}
const wrap = ref<HTMLElement>()
const active = ref(0)
const progress = ref(0)
const last = computed(() => active.value === N - 1)
let st: ScrollTrigger | undefined
onMounted(() => {
st = ScrollTrigger.create({
trigger: wrap.value!, start: 'top top', end: 'bottom bottom',
onUpdate: (s) => { progress.value = s.progress; active.value = Math.min(N - 1, Math.floor(s.progress * N * 0.999)) },
})
})
onBeforeUnmount(() => st?.kill())
</script>
<template>
<Blueprint :source="src" class="path" id="path">
<div ref="wrap" class="wrap" :style="{ '--n': N }">
<div class="stage">
<div class="left">
<p v-reveal class="mono eyebrow">{{ t('How I got here', 'Mon parcours') }} <b>{{ L(commits[active]!.when) }}</b></p>
<div class="word-box">
<p v-if="!last" :key="L(commits[active]!.word)" class="display word" data-field="name"><span class="line">{{ L(commits[active]!.word) }}</span></p>
<div v-else class="portrait" data-field="image" data-src="/img/tim-field.webp">
<img src="/img/tim-field.webp" alt="Tim Lacault" width="900" height="854">
</div>
</div>
<p class="quote" :class="{ on: last }">{{ t('I always thought the way you can create and solve problems with simple tools was beautiful.', 'J’ai toujours trouvé beau qu’on puisse créer et résoudre des problèmes avec des outils simples.') }}</p>
</div>
<div class="log" :style="{ '--a': active, '--n': N }">
<div v-reveal="1" class="scroller">
<svg class="graph" :viewBox="`0 0 108 ${height}`" width="108" :height="height" aria-hidden="true">
<path class="rail" :d="`M${X[0]} ${ROW / 2} L${X[0]} ${height - ROW / 2}`" />
<path class="rail" :d="branch(1)" />
<path class="rail" :d="branch(2)" />
<path class="drawn" pathLength="1" :d="`M${X[0]} ${ROW / 2} L${X[0]} ${height - ROW / 2}`" :style="{ strokeDashoffset: 1 - progress }" />
<path class="drawn" pathLength="1" :d="branch(1)" :style="{ strokeDashoffset: drawn(1, progress) }" />
<path class="drawn" pathLength="1" :d="branch(2)" :style="{ strokeDashoffset: drawn(2, progress) }" />
<circle
v-for="(c, i) in commits" :key="i" :cx="X[c.lane]" :cy="i * ROW + ROW / 2" :r="i === active ? 7 : 4.5"
:class="{ done: i <= active, head: i === active }"
/>
</svg>
<ol class="rows">
<li v-for="(c, i) in commits" :key="i" :class="{ on: i === active, past: i < active }" :style="{ height: `${ROW}px`, paddingLeft: `${X[c.lane]! + 26}px` }">
<span class="mono when">{{ L(c.when) }}</span>
<span class="msg">{{ L(c.msg) }}</span>
</li>
</ol>
</div>
<p :key="active" class="body">{{ L(commits[active]!.body) }}</p>
</div>
</div>
</div>
</Blueprint>
</template>
<style scoped>
.wrap { height: calc(var(--n) * 55vh + 100dvh); }
.stage {
position: sticky; top: 0; height: 100dvh; overflow: clip;
padding: clamp(72px, 12vh, 120px) var(--margin) 120px;
display: grid; grid-template-columns: repeat(12, 1fr); column-gap: 24px; align-items: center;
}
.left { grid-column: 1 / 7; display: grid; gap: 4vh; align-content: center; }
.eyebrow { color: var(--fg-2); }
.eyebrow b { font-weight: 400; color: var(--accent); margin-left: 12px; }
.word-box { height: min(40vh, 27vw); display: grid; align-items: center; }
/* Sized so the longest era word (7 letters, expanded) stays in its six columns, clear of the timeline. */
.word { font-size: min(7.2vw, 20vh); white-space: nowrap; animation: rise .5s var(--ease-out); }
@keyframes rise { from { opacity: 0; translate: 0 10px; } }
:global(html.field-live .path .word) { color: transparent; }
.portrait { height: min(40vh, 27vw); width: auto; aspect-ratio: 900 / 854; }
.portrait img { width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity .8s var(--ease-out); }
:global(html.field-live .path .bp-surface .portrait img) { opacity: 0; }
.quote { font-size: 21px; line-height: 1.4; font-style: italic; max-width: 34ch; opacity: 0; translate: 0 12px; transition: opacity .7s var(--ease-out), translate .8s var(--ease-out); }
.quote.on { opacity: 1; translate: 0 0; }
/* The timeline sits on its own dark scrim with a halo on the text, so glyphs drifting behind never wash it out. */
.log {
grid-column: 8 / -1; display: grid; gap: 28px; align-content: center; position: relative; isolation: isolate;
text-shadow: 0 0 10px var(--ink), 0 0 3px var(--ink);
}
.log::before {
content: ''; position: absolute; inset: -12% -18% -12% -10%; z-index: -1; pointer-events: none;
background: radial-gradient(closest-side, color-mix(in oklab, var(--ink) 92%, transparent), color-mix(in oklab, var(--ink) 70%, transparent) 60%, transparent);
}
/* --v rows visible; HEAD stays in the middle row until the list runs out. */
.scroller { --v: 6; position: relative; height: calc(64px * var(--v)); overflow: clip; }
.graph, .rows { position: absolute; left: 0; top: 0; translate: 0 calc(-64px * clamp(0, var(--a) - round(down, var(--v) / 2, 1), var(--n) - var(--v))); transition: translate .7s var(--ease-io); }
.rows { right: 0; margin: 0; padding: 0; list-style: none; }
.rows li { display: flex; align-items: center; gap: 16px; color: var(--fg-2); opacity: .6; transition: opacity .4s, color .4s; }
.rows li.past { opacity: .9; }
.rows li.on { opacity: 1; color: var(--fg); }
.when { width: 64px; flex: none; font-size: 12px; }
.on .when { color: var(--accent); }
.msg { font-size: 16px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.on .msg { font-weight: 700; }
.rail { fill: none; stroke: color-mix(in oklab, var(--fg) 22%, transparent); stroke-width: 2; }
.drawn { fill: none; stroke: var(--accent); stroke-width: 2; stroke-dasharray: 1; }
circle { fill: var(--ink); stroke: var(--fg-2); stroke-width: 1.5; transition: r .3s var(--ease-out), fill .3s, stroke .3s; }
circle.done { fill: var(--accent); stroke: var(--accent); }
circle.head { filter: drop-shadow(0 0 8px var(--accent)); }
.body { font-size: 17px; line-height: 1.5; color: var(--fg-2); max-width: 46ch; min-height: 3lh; animation: rise .5s var(--ease-out); }
@media (max-width: 767px) {
.wrap { height: calc(var(--n) * 45vh + 100dvh); }
.stage { align-content: start; padding-top: 10vh; row-gap: 3vh; }
.left { grid-column: 1 / -1; gap: 2vh; }
.word-box { height: 16vh; }
.word { font-size: 12vw; }
.quote { display: none; }
.log { grid-column: 1 / -1; }
.scroller { --v: 4; }
.msg { font-size: 14px; }
}
</style>
How I got here age 13
Scratch
I always thought the way you can create and solve problems with simple tools was beautiful.
Algorithms and logic for the first time. Dragging blocks around until a cat did what I meant.
Yes, two of them. A 4th-year internship of 3 to 4 months, June to September 2027, and my end-of-studies internship of 5 to 6 months, February to September 2028.
Bordeaux, France. On-site in Bordeaux, hybrid, or remote all work for me.
Mostly Vue and Nuxt with TypeScript, Node, PostgreSQL, Supabase and Docker, with GSAP and three.js for motion. School added C, C++, Python, Java and OCaml.
<script setup lang="ts">
import emailjs from '@emailjs/browser'
import gsap from 'gsap'
import src from './Contact.vue?raw'
// ── FAQ: type a question, the closest answers surface. Nothing found → it becomes the message below.
const { t, lang } = useLang()
const faq = computed(() => (lang.value === 'fr' ? [
{ q: 'Vous cherchez un stage ?', k: 'stage alternance disponible disponibilite quand dates ete fevrier embauche emploi internship', a: 'Oui, deux. Un stage de 4e année de 3 à 4 mois, de juin à septembre 2027, et mon stage de fin d’études de 5 à 6 mois, de février à septembre 2028.' },
{ q: 'Vous êtes où ? En télétravail ?', k: 'ou lieu ville bordeaux teletravail distance hybride bureau demenager remote', a: 'À Bordeaux. Sur place à Bordeaux, en hybride ou à distance : tout me va.' },
{ q: 'Avec quoi vous travaillez ?', k: 'stack technologies langages framework vue nuxt typescript competences outils', a: 'Surtout Vue et Nuxt avec TypeScript, Node, PostgreSQL, Supabase et Docker, avec GSAP et three.js pour le mouvement. L’école a ajouté le C, le C++, Python, Java et OCaml.' },
{ q: 'Vous pouvez faire le site de mon commerce ?', k: 'site web commerce boutique salon restaurant freelance client devis prix tarif cout', a: 'Oui. Je suis freelance depuis 2023 et je construis chaque site à la main, avec les vraies photos, les prix et les avis du commerce. Parlez-moi du vôtre ci-dessous et je reviens vers vous avec un plan et un devis.' },
{ q: "C'est quoi Mobilit'Eirb ?", k: 'mobiliteirb mobilit eirb startup carbone empreinte entreprise', a: "Ma startup. Elle mesure l'empreinte carbone des mobilités étudiantes. Elle est née d'un projet d'école que j'ai mené avec une équipe de cinq à l'ENSEIRB-MATMECA." },
{ q: 'Vous parlez quelles langues ?', k: 'langue langues parler anglais francais japonais', a: 'Français (langue maternelle), anglais (C1), et le japonais, que j’apprends encore.' },
{ q: 'Où est votre CV ?', k: 'cv curriculum resume pdf telecharger', a: 'Juste ici : le lien PDF en bas de cette section.' },
{ q: 'Comment vous joindre au plus vite ?', k: 'contact joindre email mail telephone appel rendez-vous vite rapidement', a: 'Écrivez à [email protected], appelez le 06 52 30 05 10, ou réservez un appel de 30 minutes. Ou utilisez le formulaire.' },
] : [
{ q: 'Are you looking for an internship?', k: 'internship stage intern available availability when dates summer february hire job', a: 'Yes, two of them. A 4th-year internship of 3 to 4 months, June to September 2027, and my end-of-studies internship of 5 to 6 months, February to September 2028.' },
{ q: 'Where are you based? Remote?', k: 'where based location city bordeaux remote hybrid onsite office move relocate', a: 'Bordeaux, France. On-site in Bordeaux, hybrid, or remote all work for me.' },
{ q: 'What do you work with?', k: 'stack tech technologies languages framework vue nuxt typescript skills tools', a: 'Mostly Vue and Nuxt with TypeScript, Node, PostgreSQL, Supabase and Docker, with GSAP and three.js for motion. School added C, C++, Python, Java and OCaml.' },
{ q: 'Can you build a website for my business?', k: 'website site business shop salon restaurant freelance client quote price cost build', a: "Yes. I have been freelancing since 2023 and build every site by hand around the business's real photos, prices and reviews. Tell me about yours below and I will reply with a plan and a quote." },
{ q: "What is Mobilit'Eirb?", k: 'mobiliteirb mobilit eirb startup carbon footprint company', a: 'My startup. It measures the carbon footprint of student trips. It began as a school project I led with a team of five at ENSEIRB-MATMECA.' },
{ q: 'Which languages do you speak?', k: 'language languages speak english french japanese', a: 'French (native), English (C1), and Japanese, which I am still learning.' },
{ q: 'Where is your résumé?', k: 'resume cv curriculum pdf download', a: 'Right here: the PDF link at the bottom of this section.' },
{ q: 'How do I reach you fastest?', k: 'contact reach email phone call book meeting fast quickly', a: 'Email [email protected], call 06 52 30 05 10, or book a 30-minute call. Or use the form.' },
]))
const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9 ]/g, ' ')
const query = ref('')
const answers = computed(() => {
const words = norm(query.value).split(' ').filter((w) => w.length > 2)
if (!words.length) return faq.value.slice(0, 3)
return faq.value
.map((f) => ({ f, score: words.filter((w) => norm(f.q + ' ' + f.k).includes(w)).length }))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map((x) => x.f)
})
const open = ref<string | null>(null)
watch(answers, (a) => { open.value = a[0]?.q ?? null }, { immediate: true })
function askDirectly() {
form.message = query.value.trim()
document.getElementById('cf-name')?.focus()
}
// ── On send, the glyphs gather into a sealed envelope over the form; it dips, then shoots off the top.
// `fly` drives a style both renders share, so the x-ray copy flies with the surface.
const letter = ref(false)
const fly = ref(0)
function sendLetter() {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return
letter.value = true
const o = { t: 0 }
gsap.to(o, { t: 1, delay: 1.4, duration: 1.5, ease: 'back.in(2.2)', onUpdate: () => { fly.value = o.t }, onComplete: () => { letter.value = false; fly.value = 0 } })
}
onMounted(useCal)
// ── Contact form (EmailJS, same account and template as the previous site).
const form = reactive({ from_name: '', reply_to: '', message: '' })
const status = ref<'idle' | 'sending' | 'sent' | 'error'>('idle')
const errors = reactive({ from_name: false, reply_to: false, message: false })
async function send() {
if (status.value === 'sending') return
errors.from_name = !form.from_name.trim()
errors.reply_to = !/^\S+@\S+\.\S+$/.test(form.reply_to.trim())
errors.message = !form.message.trim()
if (errors.from_name || errors.reply_to || errors.message) return
status.value = 'sending'
try {
await emailjs.send('service_hxniaw4', 'template_nn6a8mb', { ...form, subject: `timlacault.dev: message from ${form.from_name} (${lang.value})` }, 'foVHr7K1XuV3pMbRN')
status.value = 'sent'
Object.assign(form, { from_name: '', reply_to: '', message: '' })
sendLetter()
} catch {
status.value = 'error'
}
}
</script>
<template>
<Blueprint :source="src" class="contact" id="contact">
<div class="contact-inner">
<h2 class="display title" data-field="name"><span class="line">{{ t('Work', 'Travaillons') }}</span> <span class="line">{{ t('with me', 'ensemble') }}</span></h2>
<div class="cols">
<section class="faq" aria-labelledby="faq-title">
<h3 id="faq-title" v-reveal>{{ t('Ask me something', 'Posez-moi une question') }}</h3>
<label class="sr-only" for="faq-q">{{ t('Your question', 'Votre question') }}</label>
<div v-reveal="1" class="ask-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
<input id="faq-q" v-model="query" class="ask" type="search" :placeholder="t('When are you available?', 'Quand êtes-vous disponible ?')" autocomplete="off">
</div>
<ul v-if="answers.length" class="answers">
<li v-for="(f, i) in answers" :key="f.q" v-reveal="i" :class="{ open: open === f.q }">
<button type="button" :aria-expanded="open === f.q" @click="open = open === f.q ? null : f.q">{{ f.q }}</button>
<div class="answer" :inert="open !== f.q"><p>{{ f.a }}</p></div>
</li>
</ul>
<div v-else class="none">
<p>{{ t('No answer for that yet.', 'Pas encore de réponse à ça.') }}</p>
<button type="button" class="link" @click="askDirectly">{{ t('Ask me directly', 'Demandez-moi directement') }}</button>
</div>
</section>
<form class="form" novalidate @submit.prevent="send">
<div
v-if="letter" class="letter" data-field="image" data-src="/img/envelope.svg" data-field-priority aria-hidden="true"
:style="{ transform: `translate(${fly * 12}vw, ${-fly * 120}vh) rotate(${fly * 8}deg) scale(${1 - fly * 0.4})` }"
/>
<h3 v-reveal>{{ t('Write to me', 'Écrivez-moi') }}</h3>
<div v-reveal="1" class="field">
<input id="cf-name" v-model="form.from_name" autocomplete="name" placeholder=" " :aria-invalid="errors.from_name">
<label for="cf-name">{{ t('Name', 'Nom') }}</label>
<p v-if="errors.from_name" class="err">{{ t('Tell me who you are.', 'Dites-moi qui vous êtes.') }}</p>
</div>
<div v-reveal="2" class="field">
<input id="cf-mail" v-model="form.reply_to" type="email" autocomplete="email" placeholder=" " :aria-invalid="errors.reply_to">
<label for="cf-mail">{{ t('Email', 'E-mail') }}</label>
<p v-if="errors.reply_to" class="err">{{ t('I need an email address to answer you.', 'Il me faut un e-mail pour vous répondre.') }}</p>
</div>
<div v-reveal="3" class="field">
<textarea id="cf-msg" v-model="form.message" rows="5" placeholder=" " :aria-invalid="errors.message" />
<label for="cf-msg">{{ t('Message', 'Message') }}</label>
<p v-if="errors.message" class="err">{{ t('Your message is empty.', 'Votre message est vide.') }}</p>
</div>
<div v-reveal="4" class="send">
<Magnetic class="btn" type="submit" :aria-disabled="status === 'sending'">{{ status === 'sending' ? t('Sending', 'Envoi') : t('Send', 'Envoyer') }}</Magnetic>
<p v-if="status === 'sent'" class="ok" role="status">{{ t("Sent. I'll answer within a couple of days.", 'Envoyé. Je réponds sous quelques jours.') }}</p>
<p v-if="status === 'error'" class="err" role="alert">{{ t("That didn't go through. Write to [email protected] instead.", "Ça n'est pas passé. Écrivez plutôt à [email protected].") }}</p>
</div>
</form>
</div>
<div v-reveal class="reach">
<a href="mailto:[email protected]">[email protected]</a>
<a href="tel:+33652300510">06 52 30 05 10</a>
<button type="button" class="cal" v-bind="calAttrs">{{ t('Book a call', 'Réserver un appel') }}</button>
<a href="https://github.com/TLacault" target="_blank" rel="noopener">GitHub</a>
<a href="https://www.linkedin.com/in/tim-lacault" target="_blank" rel="noopener">LinkedIn</a>
<a href="/resume_tim_lacault.pdf" download>{{ t('Résumé (PDF)', 'CV (PDF)') }}</a>
</div>
</div>
</Blueprint>
</template>
<style scoped>
.contact-inner { padding: 18vh var(--margin) 140px; display: grid; gap: 9vh; }
.title { font-size: min(11vw, 22vh); }
:global(html[lang="fr"] .contact .title) { font-size: min(7.4vw, 16vh); } /* TRAVAILLONS is twice as long as WORK */
.line { display: inline-block; }
:global(html.field-live .contact .title) { color: transparent; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 6vw; align-items: start; }
h3 { font-size: 15px; font-weight: 700; font-stretch: 112.5%; text-transform: uppercase; letter-spacing: .02em; margin-bottom: 20px; }
/* Fields: a lit glass well. Hover lifts it, focus rings it in blue and draws an accent line along the
bottom, the label floats up out of the way once you type. An invalid field nudges sideways. */
.ask, .field input, .field textarea {
width: 100%; padding: 26px 18px 10px; border: 1px solid rgb(255 255 255 / .1); border-radius: 14px; color: var(--fg); font: inherit; resize: none;
background:
linear-gradient(var(--accent), var(--accent)) no-repeat 50% 100% / 0% 2px,
linear-gradient(180deg, rgb(255 255 255 / .05), rgb(255 255 255 / .015)),
color-mix(in oklab, var(--ink-2) 70%, transparent);
box-shadow: inset 0 1px 0 rgb(255 255 255 / .06);
transition: border-color .25s, box-shadow .3s var(--ease-out), background-size .5s var(--ease-out), translate .3s var(--ease-out);
}
.ask { padding: 16px 18px 16px 46px; }
.ask::-webkit-search-cancel-button { appearance: none; }
.ask-wrap { position: relative; }
.ask-wrap svg { position: absolute; left: 17px; top: 50%; translate: 0 -50%; width: 17px; height: 17px; color: var(--fg-2); transition: color .25s, scale .3s var(--ease-out); pointer-events: none; }
.ask-wrap:focus-within svg { color: var(--accent); scale: 1.1; }
.ask:hover, .field input:hover, .field textarea:hover { border-color: rgb(255 255 255 / .2); translate: 0 -1px; }
.ask:focus, .field input:focus, .field textarea:focus {
outline: none; border-color: color-mix(in oklab, var(--accent) 70%, transparent); translate: 0 -1px; background-size: 100% 2px, auto, auto;
box-shadow: inset 0 1px 0 rgb(255 255 255 / .08), 0 0 0 4px rgb(77 124 255 / .16), 0 14px 40px -18px rgb(77 124 255 / .5);
}
.field textarea { resize: vertical; min-height: 150px; max-height: 360px; padding-top: 30px; }
.field { position: relative; }
.field label {
position: absolute; left: 19px; top: 19px; font-size: 15px; color: var(--fg-2); pointer-events: none; transform-origin: 0 0;
transition: translate .35s var(--ease-out), scale .35s var(--ease-out), color .25s;
}
.field :is(input, textarea):is(:focus, :not(:placeholder-shown)) + label { translate: 0 -10px; scale: .78; }
.field :is(input, textarea):focus + label { color: var(--accent); }
[aria-invalid="true"] { border-color: #ff6b6b !important; animation: nudge .4s var(--ease-out); }
@keyframes nudge { 20%, 60% { translate: -5px 0; } 40%, 80% { translate: 4px 0; } }
@media (prefers-reduced-motion: reduce) { [aria-invalid="true"] { animation: none; } }
.ask::placeholder { color: var(--fg-2); }
.answers { margin: 14px 0 0; padding: 0; list-style: none; }
.answers li { border-bottom: 1px solid var(--line); }
.answers button {
width: 100%; padding: 16px 0; border: 0; background: none; color: var(--fg); font: inherit; font-weight: 600;
text-align: left; display: flex; justify-content: space-between; gap: 16px; cursor: pointer;
}
.answers button::after { content: '+'; color: var(--accent); transition: rotate .3s var(--ease-out); }
.answers .open button::after { rotate: 45deg; }
/* Open/close glides: the answer's row animates from 0fr to 1fr, so the list below moves with it. */
.answer { display: grid; grid-template-rows: 0fr; opacity: 0; transition: grid-template-rows .45s var(--ease-io), opacity .35s var(--ease-out); }
.open .answer { grid-template-rows: 1fr; opacity: 1; }
.answer p { min-height: 0; overflow: hidden; padding: 0 0 18px; color: var(--fg-2); max-width: 56ch; }
.none { margin-top: 18px; display: flex; gap: 14px; align-items: baseline; color: var(--fg-2); }
.link { border: 0; padding: 0; background: none; color: var(--accent); font: inherit; border-bottom: 1px solid currentColor; cursor: pointer; }
.form { display: grid; gap: 18px; position: relative; }
.letter { position: absolute; left: 50%; top: 30%; width: min(400px, 80vw); aspect-ratio: 400 / 280; translate: -50% -50%; pointer-events: none; }
.form h3 { margin-bottom: 2px; }
.field { display: grid; gap: 8px; }
.err { color: #ff8a8a; font-size: 14px; }
.ok { color: var(--accent); font-size: 14px; }
.send { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.btn { border: 0; cursor: pointer; }
.btn[aria-disabled="true"] { opacity: .6; pointer-events: none; }
.reach { display: flex; flex-wrap: wrap; gap: 14px 30px; }
.reach a { text-decoration: none; color: var(--fg-2); transition: color .2s; }
.reach a:hover, .cal:hover { color: var(--fg); }
.cal { border: 0; padding: 0; background: none; font: inherit; color: var(--fg-2); cursor: pointer; transition: color .2s; }
@media (max-width: 767px) {
.cols { grid-template-columns: 1fr; gap: 64px; }
}
</style>
Yes, two of them. A 4th-year internship of 3 to 4 months, June to September 2027, and my end-of-studies internship of 5 to 6 months, February to September 2028.
Bordeaux, France. On-site in Bordeaux, hybrid, or remote all work for me.
Mostly Vue and Nuxt with TypeScript, Node, PostgreSQL, Supabase and Docker, with GSAP and three.js for motion. School added C, C++, Python, Java and OCaml.