diff --git a/scripts/__pycache__/research-figures.cpython-314.pyc b/scripts/__pycache__/research-figures.cpython-314.pyc
new file mode 100644
index 0000000..03875d9
Binary files /dev/null and b/scripts/__pycache__/research-figures.cpython-314.pyc differ
diff --git a/scripts/research-figures.py b/scripts/research-figures.py
new file mode 100644
index 0000000..2442a62
--- /dev/null
+++ b/scripts/research-figures.py
@@ -0,0 +1,158 @@
+"""Give every paper in src/data/research.json its own Chladni figure and colour field.
+
+Each paper gets `figure` (parameters for src/scripts/chladni.ts) and `field` (three colour tokens
+and their positions), seeded from the paper id so a re-run gives the same result and a new paper
+gets its own. Generation follows the rules learned while hand-tuning the first figures:
+
+- plate windows stay clear of both axes and of any coordinate where n·t and m·t are both whole
+ (the field is zero along a straight line there), plates are never mirrored (the seams read as
+ straight lines), and the
+ weights avoid a = ±b (which zeroes the diagonals u = ±v);
+- hover keeps both plate terms in play (a single sin·sin term is a straight grid);
+- source figures use three to six sources (two sources draw a straight bisector), spread over
+ enough of a wavelength that they interfere (radius × frequency >= 0.6 for five or six sources,
+ 0.35 for three or four); packed tighter they collapse into plain concentric rings;
+- one colour patch sits in the middle of the card, so no field is mostly black;
+- line density, the share of grid cells a nodal line passes through, stays within the range of
+ the hand-picked figures, so no card is near-empty or a scribble.
+
+A paper that already has a figure keeps it unless --regenerate is passed, so hand edits survive.
+Usage: python3 scripts/research-figures.py [--regenerate] [--report]
+"""
+import hashlib, json, math, random, sys
+
+PATH = 'src/data/research.json'
+ASPECT = 3 # the card canvas is 3:1
+Q = 61 # grid for the density check (the site renders at 121)
+
+COLOURS = [
+ 'logo-deep', 'logo-mid', 'logo-teal', 'logo-cyan',
+ 'field-indigo', 'field-petrol', 'field-sea', 'field-steel', 'field-dusk',
+ 'field-cobalt', 'field-ocean', 'field-glacier', 'field-aqua', 'field-jade', 'field-slate', 'field-plum',
+]
+# Light tones carry the glow; at most one per field, so every card keeps a dark side.
+LIGHT = {'logo-cyan', 'field-glacier', 'field-aqua', 'logo-teal'}
+
+
+def field_fn(f, shape=None):
+ s = {**f, **(shape or {})}
+ if f['mode'] == 'plate':
+ a, b, n, m = s['a'], s['b'], s['n'], s['m']
+ rot = s.get('rotation', 0)
+ c, si = math.cos(rot), math.sin(rot)
+ ox, oy = f['origin']
+ def z(x, y):
+ dx, dy = x - ox, y - oy
+ u, v = ox + dx * c - dy * si, oy + dx * si + dy * c
+ return a * math.sin(math.pi * n * u) * math.sin(math.pi * m * v) + b * math.sin(math.pi * m * u) * math.sin(math.pi * n * v)
+ return z
+ pts = []
+ for i in range(f['sources']):
+ t = i / f['sources'] * 2 * math.pi - math.pi / 2 + s.get('spin', 0)
+ pts.append((f['origin'][0] + s['radius'] * math.cos(t), f['origin'][1] + s['radius'] * math.sin(t)))
+ k = 2 * math.pi * s['frequency']
+ inv = 1 / s['elasticity']
+ return lambda x, y: sum(math.sin(k * (s['phase'] - inv * math.hypot(x - px, y - py))) for px, py in pts)
+
+
+def density(f, shape=None):
+ z = field_fn(f, shape)
+ h = f['length']; w = h * ASPECT
+ x0, y0 = f['origin'][0] - w / 2, f['origin'][1] - h / 2
+ g = [[z(x0 + c * w / (Q - 1), y0 + r * h / (Q - 1)) > 0 for c in range(Q)] for r in range(Q)]
+ cells = sum(1 for r in range(Q - 1) for c in range(Q - 1) if len({g[r][c], g[r][c + 1], g[r + 1][c], g[r + 1][c + 1]}) > 1)
+ return cells / (Q - 1) ** 2
+
+
+def straight_lines(n, m, origin, hx, hy):
+ """True if the window crosses a coordinate where n·t and m·t are both whole: both terms of the
+ plate vanish there for every value of the other coordinate, which draws a straight line."""
+ for centre, half in ((origin[0], hx), (origin[1], hy)):
+ steps = 400
+ for i in range(steps + 1):
+ t = centre - half + 2 * half * i / steps
+ if abs(n * t - round(n * t)) < 0.03 and abs(m * t - round(m * t)) < 0.03:
+ return True
+ return False
+
+
+def rnd(rng, lo, hi, step=0.01):
+ return round(rng.uniform(lo, hi) / step) * step
+
+
+def make_plate(rng, rotated):
+ while True:
+ n, m = rnd(rng, 1.1, 4.6), rnd(rng, 1.1, 4.6)
+ if abs(n - m) < 0.35: continue
+ length = rnd(rng, 1.0, 2.8)
+ if not (2.4 <= max(n, m) * length <= 8.5): continue
+ a = rnd(rng, 0.8, 1.7) * rng.choice([-1, 1])
+ b = rnd(rng, 0.4, 1.5) * rng.choice([-1, 1])
+ if abs(abs(a) - abs(b)) < 0.25: continue
+ rotation = rnd(rng, 0.2, 1.3) if rotated else 0
+ # Keep the whole (rotated) window off both axes.
+ half_diag = math.hypot(length * ASPECT / 2, length / 2) if rotated else None
+ hx = half_diag if rotated else length * ASPECT / 2
+ hy = half_diag if rotated else length / 2
+ origin = [-round(hx + rnd(rng, 0.3, 2.5), 2), round(hy + rnd(rng, 0.3, 2.5), 2)]
+ f = {'mode': 'plate', 'a': a, 'b': b, 'n': n, 'm': m, 'origin': origin, 'length': length}
+ if rotated: f['rotation'] = rotation
+ if straight_lines(n, m, origin, hx, hy): continue
+ # Hover: flip the dominant weight and bring it down to 40-75 % of the other. Never near zero
+ # (a single sin·sin term draws a grid of straight lines) and never near a = ±b.
+ ha = None
+ for _ in range(20):
+ cand = round(-math.copysign(1, a) * abs(b) * rng.uniform(0.4, 0.75), 2)
+ if abs(abs(cand) - abs(b)) >= 0.2:
+ ha = cand; break
+ if ha is None: continue
+ f['hover'] = {'a': ha}
+ return f
+
+
+def make_sources(rng):
+ while True:
+ count = rng.choice([3, 3, 4, 4, 5, 6])
+ length = rnd(rng, 1.8, 2.8)
+ f = {
+ 'mode': 'sources', 'sources': count,
+ 'frequency': rnd(rng, 1.0, 2.5), 'phase': rnd(rng, 0, 0.5), 'elasticity': 1,
+ 'radius': rnd(rng, 0.3, min(1.1, length / 2.2)),
+ 'origin': [rnd(rng, -1.3, 1.3), rnd(rng, -0.4, 0.4)], 'length': length,
+ 'spin': rnd(rng, 0, 2 * math.pi / count),
+ }
+ if f['radius'] * f['frequency'] < (0.6 if count >= 5 else 0.35): continue
+ f['hover'] = {'phase': round(f['phase'] + rnd(rng, 0.35, 0.55), 2), 'spin': round(f['spin'] + rnd(rng, 0.35, 0.65), 2)}
+ return f
+
+
+def make_field(rng):
+ while True:
+ cols = rng.sample(COLOURS, 3)
+ if sum(c in LIGHT for c in cols) <= 1: break
+ pos = [[rnd(rng, 30, 70, 1), rnd(rng, 30, 70, 1)]] + [[rnd(rng, 5, 95, 1), rnd(rng, 5, 95, 1)] for _ in range(2)]
+ return {'colors': cols, 'positions': pos}
+
+
+def generate(pid, lo, hi):
+ rng = random.Random(int(hashlib.sha256(pid.encode()).hexdigest(), 16))
+ for _ in range(200):
+ kind = rng.choices(['sources', 'plate', 'rotated'], [0.45, 0.35, 0.2])[0]
+ f = make_sources(rng) if kind == 'sources' else make_plate(rng, kind == 'rotated')
+ d0, d1 = density(f), density(f, f['hover'])
+ if lo <= d0 <= hi and lo * 0.8 <= d1 <= hi * 1.2:
+ return f, make_field(rng), d0
+ raise SystemExit('no acceptable figure for ' + pid)
+
+
+if __name__ == '__main__':
+ data = json.load(open(PATH))
+ # Density band, calibrated on the 18 hand-picked figures (0.24 to 0.51 on this grid), without
+ # the two busiest, which read as scribbles at small card sizes.
+ lo, hi = 0.23, 0.45
+ regen = '--regenerate' in sys.argv
+ for p in data['papers']:
+ if 'figure' in p and not regen: continue
+ p['figure'], p['field'], d = generate(p['id'], lo, hi)
+ if '--report' in sys.argv: print(f"{d:.3f} {p['figure']['mode']:8} {p['id'][:50]}")
+ json.dump(data, open(PATH, 'w'), indent=2, ensure_ascii=False)
diff --git a/src/assets/images/team.png b/src/assets/images/team.png
index 7c0c088..0cc101c 100644
Binary files a/src/assets/images/team.png and b/src/assets/images/team.png differ
diff --git a/src/components/team/Research.astro b/src/components/team/Research.astro
new file mode 100644
index 0000000..f2f8b8a
--- /dev/null
+++ b/src/components/team/Research.astro
@@ -0,0 +1,332 @@
+---
+import data from '../../data/research.json';
+import { t, type Lang } from '../../i18n/ui';
+import type { ChladniParams } from '../../scripts/chladni';
+
+interface Props { lang: Lang }
+const { lang } = Astro.props;
+const ui = t(lang);
+const r = ui.team.research;
+
+/* Papers from both founders' Google Scholar profiles, deduplicated (src/data/research.json).
+ Rendered most cited first, so the page reads right without script; the script below re-sorts,
+ filters and folds the list. Entries marked display: false stay in the data only. */
+const papers = data.papers.filter((p) => p.display && p.href);
+const categories = data.categories.filter((c) => papers.some((p) => p.categories.includes(c)));
+const countIn = (c: string) => papers.filter((p) => p.categories.includes(c)).length;
+const INITIAL = 9;
+const labels = { showAll: r.showAll(papers.length), showAllTpl: r.showAll(999).replace('999', '{n}'), showFewer: r.showFewer, count: r.count(111, 999).replace('111', '{shown}').replace('999', '{total}') };
+
+/* Every paper carries its own figure (Chladni parameters for src/scripts/chladni.ts) and colour
+ field (three tokens and their positions) in research.json, generated per paper by
+ scripts/research-figures.py. They travel with the paper, so sorting and filtering never move them. */
+const figure = (paper: (typeof papers)[number]) => ({ quality: 121, ...paper.figure }) as ChladniParams;
+const fieldStyle = (paper: (typeof papers)[number]) =>
+ paper.field.colors.map((c, k) => `--c${k + 1}: var(--${c}); --ax${k + 1}: ${paper.field.positions[k][0]}%; --ay${k + 1}: ${paper.field.positions[k][1]}%;`).join(' ');
+---
+
+