-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
1480 lines (1367 loc) · 76.9 KB
/
Copy pathindex.html
File metadata and controls
1480 lines (1367 loc) · 76.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="PathFinder plans a New Zealand master’s or PhD end to end for Sri Lankan students — what you qualify for, what it costs, the visa, the move — and connects you with Sri Lankan postgrads already studying there." />
<meta property="og:title" content="PathFinder — Your degree in New Zealand, planned end to end" />
<meta property="og:description" content="One place for the whole route to a New Zealand master’s or PhD — assessment, courses, cost, scholarships, visa and settling in — plus Sri Lankan postgrads already there to ask." />
<title>PathFinder — Your degree in New Zealand, planned end to end</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Google Fonts is loaded ASYNCHRONOUSLY, on purpose.
As a plain stylesheet link this file is render-blocking: the browser
paints NOTHING until fonts.googleapis.com answers. Measured on a
connection where that host was unreachable, first contentful paint
went from 88ms to 12.7 SECONDS — a blank screen, on the exact mobile
networks this audience is on. media="print" makes the browser fetch
it at low priority without blocking the render, and the onload
handler promotes it to "all" the moment it arrives.
`display=block` (not swap) on Material Symbols is deliberate: these
icons are ligatures, so with `swap` the fallback font briefly renders
the LITERAL WORDS ("location_on", "check") all over the page. `block`
keeps the glyph invisible for up to ~3s instead, then swaps in. Text
faces keep `swap`, where a fallback is the right behaviour. -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..600,0..100,0..1;1,9..144,300..600,0..100,0..1&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" media="print" onload="this.media='all';this.onload=null" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0&display=block" media="print" onload="this.media='all';this.onload=null" />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..600,0..100,0..1;1,9..144,300..600,0..100,0..1&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" /><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0&display=block" /></noscript>
<link rel="stylesheet" href="assets/css/site.css?v=5d6b2b40" />
<style>
/* ════════ HERO — the threshold (WebGL) ════════
Cinematic dark stage. A real 3D doorway rendered with
Three.js: true perspective, point-lit frame, floor
reflection, dust, fog. Scroll dollies the camera through. */
/* 200vh, not 260: the dolly still reads as a walk through the doorway,
but a visitor reaches the first real section a screen and a half sooner. */
.hero-zone { position: relative; height: 200vh; background: var(--night); }
.stage {
position: sticky; top: 0; height: 100vh; overflow: hidden;
display: flex; flex-direction: column; isolation: isolate;
background: var(--night); color: #F0EDE6;
}
#hero-gl { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; }
/* ── the flat hero, drawn in CSS, sitting UNDER the canvas ──
Light from beyond a threshold rather than the threshold itself. When
the 3D scene runs, the opaque canvas paints straight over this and it
costs nothing; when it doesn't — a phone, a short window, reduced
motion, no WebGL — this IS the hero. It is deliberately all glow and no
geometry: the copy then sits on near-solid dark and stays readable,
which is exactly what the doorway behind the text was costing. */
.stage::before, .stage::after {
content: ''; position: absolute; z-index: 0; pointer-events: none;
left: 50%; transform: translateX(-50%);
}
.stage::before { /* the wide bloom off the floor */
bottom: -24%; width: min(900px, 132%); height: 82%;
background: radial-gradient(closest-side, rgba(232,118,63,.52), rgba(194,64,28,.19) 46%, transparent 76%);
filter: blur(14px);
}
.stage::after { /* the bright seam at its centre */
bottom: -4%; width: min(230px, 40%); height: 52%; border-radius: 50% 50% 0 0 / 22% 22% 0 0;
background: linear-gradient(180deg, transparent, rgba(255,205,160,.2) 48%, rgba(255,231,205,.42));
filter: blur(18px);
}
/* on a wide screen the copy holds the left, so the light sits where the
doorway itself stands in the 3D scene rather than dead centre */
@media (min-width: 981px) {
.stage::before, .stage::after { left: 66%; }
}
/* cinematic vignette + readability scrim behind copy */
.stage-vignette {
position: absolute; inset: 0; z-index: 2; pointer-events: none;
background:
linear-gradient(100deg, rgba(24,22,16,.84) 0%, rgba(24,22,16,.46) 38%, transparent 62%),
radial-gradient(130% 100% at 50% 50%, transparent 55%, rgba(10,9,6,.58) 100%);
}
.grain {
position: absolute; inset: -50%; z-index: 3; pointer-events: none; opacity: .28;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
}
.hero-grid {
position: relative; z-index: 10; flex: 1; min-height: 0;
display: grid; grid-template-columns: minmax(380px, 1fr) minmax(360px, 1fr);
align-items: center;
padding: 76px clamp(24px, 5vw, 72px) 0;
}
/* — copy (dark-adapted) — */
.copy { position: relative; max-width: 600px; padding-bottom: 36px; will-change: transform, opacity; }
.eyebrow {
display: flex; align-items: center; gap: 16px; margin-bottom: 30px;
opacity: 0; transform: translateY(14px); animation: rise .8s cubic-bezier(.16,.84,.32,1) .5s forwards;
}
.eyebrow .rule { width: 46px; height: 2px; background: #E8763F; }
.eyebrow .mono { color: rgba(240,237,230,.55); }
.eyebrow .mono .tick { color: #E8763F; }
.hero h1 {
font-weight: 640; line-height: .99; letter-spacing: -.03em;
font-size: clamp(43px, 5.6vw, 86px); color: #F4F1EA;
font-family: var(--font-body);
}
.hero h1 .ln { display: block; overflow: hidden; padding-bottom: .05em; }
.hero h1 .ln > span { display: block; transform: translateY(112%); animation: lineUp 1s cubic-bezier(.16,.84,.32,1) forwards; }
.hero h1 .serif {
font-family: var(--font-display); font-style: italic; font-weight: 500;
font-variation-settings: 'SOFT' 70, 'WONK' 1;
letter-spacing: -.012em; color: #E8763F;
}
@keyframes lineUp { to { transform: translateY(0); } }
.sub {
margin-top: 26px; max-width: 430px;
font-size: clamp(15px, 1.1vw, 16.5px); line-height: 1.65; color: rgba(240,237,230,.64);
opacity: 0; transform: translateY(14px); animation: rise .8s cubic-bezier(.16,.84,.32,1) .64s forwards;
}
.actions {
margin-top: 36px; display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
opacity: 0; transform: translateY(14px); animation: rise .8s cubic-bezier(.16,.84,.32,1) .76s forwards;
}
.stage .btn-hero {
border: none; background: var(--route); color: #FFF4ED;
box-shadow: 0 12px 32px -10px rgba(194,64,28,.55), inset 0 1px 0 rgba(255,255,255,.18);
}
.stage .btn-hero:hover { background: #D9531F; transform: translateY(-2px); }
/* the two tracks are equal choices, so the second reads as a peer of the
first rather than a lesser action — outlined, not ghosted away */
.actions-dual { gap: 12px; margin-top: 32px; }
.stage .btn-hero-alt {
background: transparent; color: #F0EDE6;
border: 1px solid rgba(240,237,230,.34); box-shadow: none;
}
.stage .btn-hero-alt:hover { background: rgba(240,237,230,.09); border-color: rgba(240,237,230,.6); }
.stage .tlink { color: rgba(240,237,230,.85); }
.stage .tlink:hover { color: #E8763F; }
.stage .tlink::after { background: #E8763F; }
@keyframes rise { to { opacity: 1; transform: translateY(0); } }
/* bottom strip */
.stage-foot {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 10;
display: flex; justify-content: flex-end; align-items: center; gap: 16px;
padding: 0 clamp(24px, 5vw, 72px) 22px;
}
/* a short laptop window is the case where an absolutely-positioned
footer runs into the copy above it — drop it rather than overlap */
@media (max-height: 720px) { .stage-foot { display: none; } }
.stage-foot .mono { color: rgba(240,237,230,.4); }
.stage-foot .mono .tick { color: #E8763F; }
.scroll-hint { display: flex; align-items: center; gap: 10px; }
.scroll-hint .sbar { width: 34px; height: 1px; background: rgba(240,237,230,.2); position: relative; overflow: hidden; }
.scroll-hint .sbar::after { content: ""; position: absolute; left: -40%; top: 0; width: 40%; height: 100%; background: #E8763F; animation: slide 2.4s ease-in-out infinite; }
@keyframes slide { 0% { left: -40%; } 60%, 100% { left: 100%; } }
/* hand-off veil into the paper site */
.veil { position: absolute; inset: 0; z-index: 40; background: var(--paper); opacity: 0; pointer-events: none; }
/* No canvas: no WebGL, or reduced motion. The flat hero underneath shows
through on its own — this only stops an empty canvas sitting over it. */
.stage.no-gl #hero-gl, .stage.no-gl .veil { display: none; }
/* nav over the dark stage */
.nav.on-dark { background: transparent; border-bottom-color: transparent; }
.nav.on-dark.scrolled { background: rgba(24,22,16,.80); backdrop-filter: blur(14px); border-bottom-color: rgba(244,241,234,.07); }
.nav.on-dark .logo .word { color: #F0EDE6; }
.nav.on-dark .logo .word i { color: #E8763F; }
.nav.on-dark .logo .mono { color: rgba(240,237,230,.35); }
.nav.on-dark .nav-links a { color: rgba(240,237,230,.6); }
.nav.on-dark .nav-links a:hover { color: #E8763F; }
.nav.on-dark .nav-links a[aria-current="page"] { color: #E8763F; border-bottom-color: #E8763F; }
.nav.on-dark .tlink { color: rgba(240,237,230,.8); }
.nav.on-dark .btn-primary { background: #F4F1EA; color: var(--ink); border-color: #F4F1EA; }
.nav.on-dark .btn-primary:hover { background: var(--route); border-color: var(--route); color: #FFF4ED; }
/* ── where the 3D doorway stops being worth it ──
On a phone, or any window too short to hold the scene, the doorway ends
up BEHIND the copy instead of beside it and the text gets hard to read
— so those screens get the flat hero above and never load three.js at
all (~600KB saved on exactly the metered mobile connections this
audience is on). Laptops, desktops and tablets held landscape keep the
full scene. This query and GL_QUERY in the hero script are the same
line written twice; change them together. */
@media (max-width: 980px), (max-height: 560px) {
#hero-gl, .veil { display: none; }
.hero-zone { height: auto !important; }
.stage { position: relative; height: auto; min-height: 100svh; }
}
@media (max-width: 980px) {
.hero-grid { grid-template-columns: 1fr; align-items: center; padding-top: 96px; }
/* the right-hand column holds the doorway's space in the 3D scene, not
content — as a second grid row it just pushes the copy off the bottom */
.hero-grid > div:last-child { display: none; }
.copy { padding-bottom: 44px; max-width: none; }
/* the flat hero's glow rises from the foot of the screen, so the scrim
runs the other way: heavy under the copy, open where the light is */
.stage-vignette {
background: linear-gradient(180deg, rgba(24,22,16,.68) 0%, rgba(24,22,16,.52) 34%, rgba(24,22,16,.2) 72%, rgba(24,22,16,.1) 100%);
}
/* the bloom belongs at the foot of a tall screen, not behind the words */
.stage::before { bottom: -26%; height: 56%; }
.stage::after { bottom: -8%; height: 34%; }
.stage-foot { display: none; }
}
@media (max-width: 560px) {
.hero h1 { font-size: clamp(38px, 10.5vw, 52px); }
.actions .btn { flex: 1; justify-content: center; }
}
/* a wide but short window — a laptop with the browser half-height, a
tablet in landscape — needs the copy to fit above the fold */
@media (min-width: 981px) and (max-height: 620px) {
.hero-grid { padding-top: 62px; }
.hero h1 { font-size: clamp(36px, 4.3vw, 54px); }
.sub { margin-top: 16px; }
.actions-dual { margin-top: 22px; }
.actions { margin-top: 22px; }
.copy { padding-bottom: 16px; }
}
/* ════════ FEE SPLIT — PhD vs master's, stated side by side ════════ */
.fee-split { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); }
.fee-col { background: var(--paper); padding: 28px 26px; }
.fee-col h3 { font-family: var(--font-display); font-size: 1.22rem; font-weight: 560; line-height: 1.25; margin: 12px 0 12px; }
.fee-col p { font-size: 14px; line-height: 1.68; color: var(--ink-soft); }
.fee-col strong { color: var(--ink); font-weight: 600; }
.chip-lite {
display: inline-block; padding: 4px 10px; border: 1px solid var(--line-2); border-radius: 3px;
font-family: var(--font-mono); font-size: 10px; font-weight: 600;
letter-spacing: .13em; text-transform: uppercase; color: var(--route);
}
/* ════════ CATALOGUE — the register, as proof ════════ */
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); }
.cat-cell {
background: var(--paper); padding: 20px 20px 18px; display: flex; flex-direction: column; gap: 6px;
transition: background .2s, transform .2s cubic-bezier(.22,1,.36,1);
}
.cat-cell:hover { background: var(--surface); transform: translateY(-2px); }
.cat-cell .n { font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; color: var(--route); }
.cat-cell .t { font-size: 14.5px; line-height: 1.38; font-weight: 500; color: var(--ink); }
.cat-stats { display: flex; gap: 44px; flex-wrap: wrap; margin: 30px 0 34px; }
/* tabular figures so the count-up doesn't wobble digit by digit; the
width is pinned in JS to the final string before the count starts */
.cat-stat .fig {
font-family: var(--font-display); font-size: clamp(2rem, 4.4vw, 3.1rem);
font-weight: 560; line-height: 1; color: var(--ink);
font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1;
}
.cat-stat .cap { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .11em; text-transform: uppercase; color: var(--ink-faint); margin-top: 8px; }
/* ════════ THE JOURNEY — seven stages on a drawn route ════════
The one diagram the whole page is built around: the route from
"I'm thinking about it" to a flat in Wellington, drawn end to end so
nobody has to guess which parts PathFinder covers. Each stop is a link
into the view that handles that stage.
The rail is two lines stacked: a dashed one that is always there — the
route as it exists before you start — and a solid vermilion one that
draws along it on reveal, lighting each stop as it reaches it, with a
lit head running ahead of it. That is the whole idea of the product in
one gesture, so it is worth the ~40 lines. It runs once, and
reduced-motion gets the finished state with no travel at all. */
.journey { margin-top: 52px; position: relative; }
.j-rail {
position: absolute; left: 9px; right: 9px; top: 9px; height: 0; z-index: 0;
border-top: 1px dashed var(--line-2); pointer-events: none;
}
.j-line {
position: absolute; left: 0; top: -1px; height: 2px; width: 100%;
background: linear-gradient(90deg, rgba(194,64,28,.28), var(--route) 22%, var(--route));
transform-origin: left center; transform: scaleX(var(--draw, 0));
}
.j-head {
position: absolute; top: -4.5px; left: calc(var(--draw, 0) * 100%);
width: 9px; height: 9px; margin-left: -4.5px; border-radius: 50%;
background: #FFEBD2; opacity: var(--head, 0);
box-shadow: 0 0 8px 2px rgba(232,118,63,.75), 0 0 22px 6px rgba(194,64,28,.35);
}
.j-track { display: grid; grid-template-columns: repeat(7, 1fr); position: relative; z-index: 1; }
.j-stop {
position: relative; display: flex; flex-direction: column; gap: 13px;
align-items: flex-start; padding-right: 20px; min-width: 0;
}
.j-stop .node {
width: 19px; height: 19px; border-radius: 50%; display: grid; place-items: center;
background: var(--paper); border: 1.5px solid var(--line-2);
position: relative; z-index: 1;
transition: border-color .4s, box-shadow .4s, transform .35s cubic-bezier(.22,1,.36,1);
}
/* the filled centre — scales in when the route reaches this stop */
.j-stop .node::after {
content: ''; width: 7px; height: 7px; border-radius: 50%;
background: var(--route); transform: scale(0);
transition: transform .45s cubic-bezier(.34,1.56,.64,1);
}
/* and a ring that leaves the node once, like the light arriving */
.j-stop .node::before {
content: ''; position: absolute; inset: -3px; border-radius: 50%;
border: 1px solid var(--route); opacity: 0; transform: scale(.5);
}
.j-stop.on .node { border-color: var(--route); box-shadow: 0 0 0 4px rgba(194,64,28,.08); }
.j-stop.on .node::after { transform: scale(1); }
.j-stop.on .node::before { animation: jArrive 1.1s cubic-bezier(.22,1,.36,1) forwards; }
@keyframes jArrive {
0% { opacity: .85; transform: scale(.5); }
60% { opacity: .28; }
100% { opacity: 0; transform: scale(2.6); }
}
/* the words follow the light rather than arriving with the layout */
.j-stop .no, .j-stop .txt {
opacity: 0; transform: translateY(7px);
transition: opacity .55s ease, transform .55s cubic-bezier(.22,1,.36,1);
}
.j-stop .txt { transition-delay: .07s; }
.j-stop.on .no, .j-stop.on .txt { opacity: 1; transform: none; }
.j-stop .no { font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; color: var(--ink-faint); }
.j-stop .txt { display: flex; flex-direction: column; gap: 6px; }
.j-stop .t { font-size: 14.5px; font-weight: 600; line-height: 1.3; color: var(--ink); transition: color .2s; }
.j-stop .d { font-size: 12.5px; line-height: 1.5; color: var(--ink-faint); transition: color .2s; }
.j-stop:hover .t { color: var(--route); }
.j-stop:hover .d { color: var(--ink-soft); }
.j-stop:hover .node { transform: scale(1.18); border-color: var(--route); box-shadow: 0 0 0 6px rgba(194,64,28,.12); }
@media (max-width: 920px) {
.j-rail { left: 9px; right: auto; top: 10px; bottom: 10px; height: auto; width: 0; border-top: none; border-left: 1px dashed var(--line-2); }
.j-line {
left: -1px; top: 0; width: 2px; height: 100%;
background: linear-gradient(180deg, rgba(194,64,28,.28), var(--route) 22%, var(--route));
transform-origin: center top; transform: scaleY(var(--draw, 0));
}
.j-head { left: -4.5px; top: calc(var(--draw, 0) * 100%); margin: -4.5px 0 0 0; }
.j-track { grid-template-columns: 1fr; gap: 22px; }
.j-stop { flex-direction: row; align-items: baseline; gap: 16px; padding-right: 0; }
.j-stop .node { flex: 0 0 auto; transform: translateY(4px); }
.j-stop:hover .node { transform: translateY(4px) scale(1.18); }
.j-stop .no { flex: 0 0 22px; }
}
@media (prefers-reduced-motion: reduce) {
.j-stop .node::before { animation: none !important; }
.j-stop .no, .j-stop .txt, .j-stop .node, .j-stop .node::after { transition: none !important; }
}
/* ════════ CHAPTER 01 — assessment widget ════════ */
.split { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: clamp(32px, 6vw, 96px); align-items: start; }
@media (max-width: 880px) { .split { grid-template-columns: 1fr; } }
.asm-widget { background: var(--surface); border: 1px solid var(--ink); border-radius: 3px; box-shadow: 6px 6px 0 var(--paper-2); }
.asm-widget .head { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid var(--line); }
.asm-widget .body { padding: 24px 20px; }
.asm-q { font-family: var(--font-display); font-size: 19px; font-weight: 520; margin-bottom: 18px; }
.asm-row {
display: flex; align-items: center; gap: 12px; width: 100%;
text-align: left; background: none; border: none; border-top: 1px solid var(--line-soft);
padding: 13px 4px; font-size: 14.5px; transition: padding-left .25s cubic-bezier(.22,1,.36,1), color .2s;
}
.asm-row .idx { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-faint); width: 18px; }
.asm-row::after { content: '→'; margin-left: auto; opacity: 0; transition: opacity .2s; color: var(--route); }
.asm-row:hover { padding-left: 12px; color: var(--route); }
.asm-row:hover::after { opacity: 1; }
/* ════════ NIGHT BAND — the people chapter ════════ */
.night { background: var(--night); color: var(--paper); }
.night .chapter { border-top-color: rgba(244,241,234,.85); }
.night .chapter .ttl { color: rgba(244,241,234,.6); }
.night .chapter .end { color: rgba(244,241,234,.35); }
.night .muted { color: rgba(244,241,234,.62); }
.night .faint { color: rgba(244,241,234,.38); }
.night .display em { color: #E8763F; }
.night .note + .note { border-left-color: rgba(244,241,234,.16); }
.night .notes .muted { color: rgba(244,241,234,.58); }
@media (max-width: 880px) { .night .note + .note { border-top-color: rgba(244,241,234,.16); } }
.night .btn { border-color: var(--paper); color: var(--paper); }
.night .btn-primary { background: var(--paper); color: var(--ink); }
.night .btn-primary:hover { background: var(--route); border-color: var(--route); color: var(--paper); }
.night .tlink { color: rgba(244,241,234,.85); }
.night .tlink:hover { color: #E8763F; }
.night .tlink::after { background: #E8763F; }
/* ════════ NOTE COLUMNS — people, straight answers ════════ */
.notes { display: grid; grid-template-columns: repeat(3, 1fr); }
.note { padding: 8px clamp(20px, 3vw, 40px) 8px 0; }
.note + .note { border-left: 1px solid var(--line); padding-left: clamp(20px, 3vw, 40px); }
.note blockquote { font-family: var(--font-display); font-size: clamp(17px, 1.8vw, 21px); font-weight: 460; line-height: 1.4; font-variation-settings: 'SOFT' 60; }
.note .who { margin-top: 18px; }
@media (max-width: 880px) {
.notes { grid-template-columns: 1fr; gap: 32px; }
.note + .note { border-left: none; padding-left: 0; border-top: 1px solid var(--line); padding-top: 32px; }
}
/* ════════ DISPATCH (newsletter) — secondary to the CTA above it ════════ */
.dispatch-inline {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 420px);
gap: 24px 48px; align-items: center;
margin-top: clamp(48px, 6vw, 80px); padding-top: 32px; border-top: 1px solid var(--line);
}
.dispatch-inline strong { font-size: 15px; font-weight: 600; margin-right: 8px; }
.dispatch-inline .muted { font-size: 14px; line-height: 1.6; }
.dispatch-inline .email-row { margin: 0; }
@media (max-width: 880px) { .dispatch-inline { grid-template-columns: 1fr; } }
.email-row { display: flex; gap: 0; max-width: 420px; margin: 30px auto 0; border: 1px solid var(--ink); border-radius: 2px; background: var(--surface); }
.email-row .field { border: none; box-shadow: none !important; background: transparent; }
.email-row .btn { border: none; border-left: 1px solid var(--ink); border-radius: 0; }
@media (max-width: 480px) {
.email-row { flex-direction: column; }
.email-row .btn { border-left: none; border-top: 1px solid var(--ink); justify-content: center; }
}
/* ════════ FOOTER colophon ════════ */
.colophon { border-top: 1px solid var(--ink); padding: var(--s6) 0 var(--s4); }
.colophon .big {
font-family: var(--font-display); font-weight: 480; letter-spacing: -.03em;
font-size: clamp(3.4rem, 11vw, 9rem); line-height: .95; color: var(--ink);
}
.colophon .big i { font-style: italic; color: var(--route); }
</style>
</head>
<body>
<!-- ══════════ NAV ══════════ -->
<header class="nav" id="nav">
<div class="wrap nav-inner">
<a class="logo" href="index.html"><span class="word">Path<i>finder</i></span><span class="mono" style="font-size:10px">POSTGRAD · NEW ZEALAND</span></a>
<nav class="nav-links">
<a href="#journey">The journey</a>
<a href="#catalogue">Programmes & cost</a>
<a href="#people">Mentors</a>
<a href="#honest">What this is</a>
</nav>
<div style="display:flex;gap:10px;align-items:center">
<!-- One entry point, not two competing ones (U-22) — always lands on
the dashboard overview, which already leads a first-time visitor
straight into the assessment itself (see renderFirstRun in
app.js). Label swaps to "Dashboard" for a returning visitor once
store.js has loaded, below. -->
<a class="btn btn-primary btn-sm" id="nav-cta" href="app.html#dashboard">Start free</a>
</div>
</div>
</header>
<!-- ══════════ HERO ══════════ -->
<section class="hero-zone" id="hero-zone">
<div class="stage hero" id="stage">
<canvas id="hero-gl" aria-hidden="true"></canvas>
<div class="stage-vignette" aria-hidden="true"></div>
<div class="grain" aria-hidden="true"></div>
<div class="hero-grid">
<div class="copy" id="hero-copy">
<div class="eyebrow">
<span class="rule"></span>
<span class="mono">For Sri Lankan students <span class="tick">·</span> Master’s & PhD in New Zealand</span>
</div>
<h1>
<span class="ln"><span style="animation-delay:.08s">Your degree in</span></span>
<span class="ln"><span class="serif" style="animation-delay:.2s">New Zealand</span></span>
<span class="ln"><span style="animation-delay:.32s">starts here.</span></span>
</h1>
<p class="sub">
The whole route in one place — what you qualify for, what it really costs, the
visa, the move. And Sri Lankan postgrads already in New Zealand to ask when
you get stuck.
</p>
<!-- Two journeys, chosen before the student ever reaches the app: the
?track= parameter is consumed by adoptTrackFromQuery() in app.js.
The money, the entry bar and the shape of the application are
genuinely different, so asking up front beats guessing. The track
never selects immigration content — see PF_VISA_NOTICES. -->
<div class="actions actions-dual">
<a class="btn btn-hero" href="app.html?track=masters#assessment">
I’m applying for a master’s
<span class="material-symbols-outlined" style="font-size:17px">east</span>
</a>
<a class="btn btn-hero btn-hero-alt" href="app.html?track=phd#assessment">
I’m applying for a PhD
<span class="material-symbols-outlined" style="font-size:17px">east</span>
</a>
</div>
<div class="actions" style="margin-top:14px">
<span class="mono" style="color:rgba(240,237,230,.42);font-size:11px;letter-spacing:.1em">
Free to start <span class="tick" style="color:#E8763F">·</span> No account needed
</span>
</div>
</div>
<div><!-- right column: the rendered doorway sits here in 3D space --></div>
</div>
<!-- One line only. Anything else down here collides with the copy on a
short laptop screen, and the hero has already said its piece. -->
<div class="stage-foot">
<span class="scroll-hint mono">Scroll to continue<span class="sbar"></span></span>
</div>
<div class="veil" id="veil" aria-hidden="true"></div>
</div>
</section>
<script type="module">
/* ══════════ The Threshold — real-time 3D doorway ══════════
A doorway standing in the dark with light behind it, rendered for real:
perspective, a point-lit stone frame, a cool rim to separate it from the
ground, a light pool and reflection on the floor, drifting dust, fog.
Scroll dollies the camera to the threshold and hands off to the page.
Two rules hold the piece together:
· IT IS THE SAME ROOM AS THE REST OF THE SITE. The ground is --night
(#181610) — the exact band the People chapter sits on — and the light
runs paper-white → #E8763F (the hero accent) → --route (#C2401C). No
colour appears here that isn't already in the stylesheet, so scrolling
out of the hero is a change of light, not a change of brand.
· EVERY MOTION IS FRAME-RATE INDEPENDENT. Damping is
1 - exp(-lambda * dt), never a fixed per-frame fraction, so the sway,
the dolly and the dust move identically at 60, 90 and 144Hz instead of
racing on a high-refresh screen. dt is clamped so a backgrounded tab
doesn't teleport the camera on its first frame back.
And it only runs where it earns its place — see GL_QUERY below. */
const stage = document.getElementById('stage');
const zone = document.getElementById('hero-zone');
const copy = document.getElementById('hero-copy');
const veil = document.getElementById('veil');
const canvas = document.getElementById('hero-gl');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* Wide enough to put the doorway BESIDE the copy, and tall enough to hold the
scene. Below either bar the door lands behind the words and the hero stops
being readable, so those screens get the flat CSS hero instead — and, since
the import is dynamic, they never download three.js at all. Keep in step
with the matching @media query in the stylesheet above. */
const GL_QUERY = '(min-width: 981px) and (min-height: 561px)';
if (reduced || !matchMedia(GL_QUERY).matches) {
stage.classList.add('no-gl');
if (matchMedia(GL_QUERY).matches) zone.style.height = '100vh'; // reduced motion on a big screen
} else {
import('https://unpkg.com/three@0.160.0/build/three.module.js')
.then(buildDoorway)
.catch(() => stage.classList.add('no-gl')); // offline, blocked CDN, anything
}
function buildDoorway(THREE) {
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
} catch (e) { /* no context — fall through to the flat hero */ }
if (!renderer) { stage.classList.add('no-gl'); return; }
{
/* ── palette, straight off the stylesheet ── */
const C = {
night: 0x181610, // --night, the site's dark band
stone: 0x4A4238, // frame, a warm ink-grey
ember: 0xE8763F, // the hero accent
route: 0xC2401C, // --route
rim: 0x7C8798, // cool separation light — the only cold note
mote: 0xFFC79B,
};
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.06;
const scene = new THREE.Scene();
scene.background = new THREE.Color(C.night);
scene.fog = new THREE.FogExp2(C.night, 0.05);
const camera = new THREE.PerspectiveCamera(36, 1, 0.1, 80);
/* ── canvas-generated textures (no assets to load) ── */
function makeTex(w, h, draw) {
const c = document.createElement('canvas');
c.width = w; c.height = h;
draw(c.getContext('2d'), w, h);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = renderer.capabilities.getMaxAnisotropy();
return t;
}
// the luminous plane inside the doorway — hot core low, cooling upward.
// The stops are the site's own ramp: paper → ember → route → embers out.
const apertureTex = makeTex(512, 1024, (g, w, h) => {
const grd = g.createRadialGradient(w/2, h*1.02, h*.02, w/2, h*1.02, h*.98);
grd.addColorStop(0, '#FFE3C2'); // warm cream, never #fff — ACES turns a
grd.addColorStop(.09, '#F9CFA0'); // blown-out white into a green-grey smear
grd.addColorStop(.22, '#F09A55');
grd.addColorStop(.40, '#E8763F');
grd.addColorStop(.60, '#C2401C');
grd.addColorStop(.80, '#7A2A0C');
grd.addColorStop(1, '#341509');
g.fillStyle = grd; g.fillRect(0, 0, w, h);
});
// soft round glow sprite — bloom, halo, dust and the floor pool all use it
const glowTex = makeTex(256, 256, (g, w, h) => {
const grd = g.createRadialGradient(w/2, h/2, 0, w/2, h/2, w/2);
grd.addColorStop(0, 'rgba(255,255,255,1)');
grd.addColorStop(.25, 'rgba(255,214,178,.55)');
grd.addColorStop(.6, 'rgba(232,118,63,.15)');
grd.addColorStop(1, 'rgba(232,118,63,0)');
g.fillStyle = grd; g.fillRect(0, 0, w, h);
});
// vertical streak for the floor reflection
const streakTex = makeTex(128, 256, (g, w, h) => {
const grd = g.createLinearGradient(0, 0, 0, h);
grd.addColorStop(0, 'rgba(255,198,158,.92)');
grd.addColorStop(.4, 'rgba(232,118,63,.32)');
grd.addColorStop(1, 'rgba(232,118,63,0)');
g.fillStyle = grd; g.fillRect(0, 0, w, h);
// feather the sides — a beam with straight edges is the most CG-looking
// thing in the frame, and light falling through a doorway has none
g.globalCompositeOperation = 'destination-in';
const side = g.createLinearGradient(0, 0, w, 0);
side.addColorStop(0, 'rgba(0,0,0,0)');
side.addColorStop(.28, 'rgba(0,0,0,.85)');
side.addColorStop(.5, 'rgba(0,0,0,1)');
side.addColorStop(.72, 'rgba(0,0,0,.85)');
side.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = side; g.fillRect(0, 0, w, h);
});
// seamless grain — bump + roughness variation on the stone. Drawn nine
// times per blob (3×3 wrap) so the tile has no visible seam.
const grainTex = makeTex(256, 256, (g, w, h) => {
g.fillStyle = '#7a7a7a'; g.fillRect(0, 0, w, h);
for (let i = 0; i < 220; i++) {
const x = Math.random() * w, y = Math.random() * h;
const r = 3 + Math.random() * 26;
const v = Math.random() * 255 | 0;
for (let ox = -1; ox <= 1; ox++) for (let oy = -1; oy <= 1; oy++) {
const grd = g.createRadialGradient(x + ox*w, y + oy*h, 0, x + ox*w, y + oy*h, r);
grd.addColorStop(0, `rgba(${v},${v},${v},.5)`);
grd.addColorStop(1, `rgba(${v},${v},${v},0)`);
g.fillStyle = grd;
g.fillRect(x + ox*w - r, y + oy*h - r, r*2, r*2);
}
}
});
grainTex.wrapS = grainTex.wrapT = THREE.RepeatWrapping;
grainTex.repeat.set(2, 5);
// slow-drifting light wisps laid over the aperture, so the portal reads as
// something moving rather than a printed gradient
const wispTex = makeTex(256, 256, (g, w, h) => {
g.clearRect(0, 0, w, h);
for (let i = 0; i < 34; i++) {
const x = Math.random() * w, y = Math.random() * h;
const r = 18 + Math.random() * 62, a = .06 + Math.random() * .12;
for (let ox = -1; ox <= 1; ox++) for (let oy = -1; oy <= 1; oy++) {
const grd = g.createRadialGradient(x + ox*w, y + oy*h, 0, x + ox*w, y + oy*h, r);
grd.addColorStop(0, `rgba(255,222,190,${a})`);
grd.addColorStop(1, 'rgba(255,200,160,0)');
g.fillStyle = grd;
g.fillRect(x + ox*w - r, y + oy*h - r, r*2, r*2);
}
}
});
wispTex.wrapS = wispTex.wrapT = THREE.RepeatWrapping;
/* ── the doorway ── */
const FLOOR_Y = -2.05;
const doorway = new THREE.Group();
scene.add(doorway);
const APW = 2.1, APH = 3.9; // aperture size
const aperture = new THREE.Mesh(
new THREE.PlaneGeometry(APW, APH),
new THREE.MeshBasicMaterial({ map: apertureTex, toneMapped: true })
);
aperture.position.set(0, FLOOR_Y + APH / 2, -.06);
doorway.add(aperture);
// the moving light inside it
const wisps = new THREE.Mesh(
new THREE.PlaneGeometry(APW, APH),
new THREE.MeshBasicMaterial({ map: wispTex, transparent: true, opacity: .55,
blending: THREE.AdditiveBlending, depthWrite: false })
);
wisps.position.set(0, FLOOR_Y + APH / 2, -.05);
doorway.add(wisps);
// three glow layers standing in for a bloom pass: a tight core hugging the
// aperture, a mid spill that licks the jambs, and a wide ambient halo.
const glowLayer = (sx, sy, opacity, color, z, dy = 0) => {
const m = new THREE.Mesh(
new THREE.PlaneGeometry(APW * sx, APH * sy),
new THREE.MeshBasicMaterial({ map: glowTex, transparent: true, opacity,
blending: THREE.AdditiveBlending, depthWrite: false, color })
);
m.position.set(0, FLOOR_Y + APH / 2 - .2 + dy, z);
doorway.add(m);
return m;
};
const core = glowLayer(1.05, .8, .34, 0xFF9F63, -.05, -APH * .18);
const bloom = glowLayer(3.2, 2.1, .34, C.ember, -.10);
const halo = glowLayer(7.2, 3.6, .13, C.route, -.14);
// monolithic stone frame — lit for real by the doorway's point light
const stoneMat = new THREE.MeshStandardMaterial({
color: C.stone, roughness: .9, metalness: .03,
bumpMap: grainTex, bumpScale: .05, roughnessMap: grainTex,
});
const jambGeo = new THREE.BoxGeometry(.42, APH + .5, .8);
const jambL = new THREE.Mesh(jambGeo, stoneMat);
jambL.position.set(-(APW / 2 + .21), FLOOR_Y + (APH + .5) / 2, .12);
const jambR = jambL.clone(); jambR.position.x = APW / 2 + .21;
const lintel = new THREE.Mesh(new THREE.BoxGeometry(APW + 1.26, .46, .8), stoneMat);
lintel.position.set(0, FLOOR_Y + APH + .5 + .23 - .25, .12);
doorway.add(jambL, jambR, lintel);
// floor — rough dark ground that catches the warm light pool
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(120, 60),
new THREE.MeshStandardMaterial({
color: 0x15130E, roughness: .62, metalness: .1,
bumpMap: grainTex, bumpScale: .02,
})
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = FLOOR_Y;
scene.add(floor);
// light pool spilling out of the doorway onto the ground
const pool = new THREE.Mesh(
new THREE.PlaneGeometry(APW * 3.4, 13),
new THREE.MeshBasicMaterial({ map: glowTex, transparent: true, opacity: .14,
blending: THREE.AdditiveBlending, depthWrite: false, color: C.ember })
);
pool.rotation.x = -Math.PI / 2;
pool.position.set(0, FLOOR_Y + .001, 2.2);
doorway.add(pool);
// the frame's own contact shadow — no shadow map, just a dark patch where
// the stone meets the ground. Without it the doorway floats.
const contact = new THREE.Mesh(
new THREE.PlaneGeometry(APW * 2.4, 1.7),
new THREE.MeshBasicMaterial({ map: glowTex, transparent: true, opacity: .7,
depthWrite: false, color: 0x000000 })
);
contact.rotation.x = -Math.PI / 2;
contact.position.set(0, FLOOR_Y + .004, .1);
doorway.add(contact);
// glossy reflection streak stretching toward the camera
const streak = new THREE.Mesh(
new THREE.PlaneGeometry(APW * .96, 7.5),
new THREE.MeshBasicMaterial({ map: streakTex, transparent: true, opacity: .24,
blending: THREE.AdditiveBlending, depthWrite: false })
);
streak.rotation.x = -Math.PI / 2;
streak.position.set(0, FLOOR_Y + .003, 3.75);
doorway.add(streak);
/* ── lights ── */
const portalLight = new THREE.PointLight(C.ember, 88, 26, 2);
portalLight.position.set(0, FLOOR_Y + 1.4, .9);
doorway.add(portalLight);
// low warm bounce off the floor, so the jambs aren't black at the base
const bounce = new THREE.PointLight(C.route, 16, 12, 2);
bounce.position.set(0, FLOOR_Y + .25, 2.2);
doorway.add(bounce);
// a cool rim from high behind separates the stone from the background —
// the one cold light in the scene, and what stops it reading as a sticker
const rim = new THREE.DirectionalLight(C.rim, 1.15);
rim.position.set(-5, 7, -2);
scene.add(rim);
// a low, cool key from the camera's side so the stone has visible form and
// its grain catches — without it the frame is a cut-out against the dark
const key = new THREE.DirectionalLight(0x9FA6AE, .42);
key.position.set(-6, 3, 8);
scene.add(key);
scene.add(new THREE.HemisphereLight(0x4A4438, 0x0C0B08, .34));
/* ── dust: two layers, so it has depth instead of one flat sparkle ── */
function dustLayer(count, spread, size, opacity, speed) {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count * 3), vel = new Float32Array(count);
for (let i = 0; i < count; i++) {
pos[i*3] = (Math.random() - .5) * spread;
pos[i*3+1] = FLOOR_Y + Math.random() * 5.6;
pos[i*3+2] = -0.5 + Math.random() * 7.5;
vel[i] = speed * (.5 + Math.random());
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const pts = new THREE.Points(geo, new THREE.PointsMaterial({
map: glowTex, size, transparent: true, opacity, color: C.mote,
blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true
}));
doorway.add(pts);
return { geo, vel, count };
}
const dustFar = dustLayer(210, 11, .038, .34, .09);
const dustNear = dustLayer(60, 7, .105, .42, .16);
/* ── responsive composition ── */
let doorX = 0;
function layout() {
const w = stage.clientWidth, h = stage.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
doorX = w > 980 ? 2.35 : 0; // door right of copy on desktop, centred on mobile
doorway.position.x = doorX;
camera.fov = w > 980 ? 36 : 44;
camera.updateProjectionMatrix();
}
layout();
addEventListener('resize', layout);
/* ── camera rig: base pose + mouse sway + scroll dolly ── */
const look = new THREE.Vector3();
let mx = 0, my = 0, smx = 0, smy = 0; // mouse target / smoothed
if (!reduced && !matchMedia('(pointer: coarse)').matches) {
stage.addEventListener('mousemove', e => {
const r = stage.getBoundingClientRect();
mx = (e.clientX - r.left) / r.width - .5;
my = (e.clientY - r.top) / r.height - .5;
}, { passive: true });
}
let sp = 0, spT = 0; // scroll progress (smoothed / target)
const canDolly = () => !reduced && innerWidth > 980;
function readScroll() {
if (!canDolly()) { spT = 0; return; }
const range = zone.offsetHeight - innerHeight;
spT = Math.min(Math.max(-zone.getBoundingClientRect().top / range, 0), 1);
}
addEventListener('scroll', readScroll, { passive: true });
readScroll();
/* render only while the hero is on screen */
let visible = true;
new IntersectionObserver(es => { visible = es[0].isIntersecting; }, { threshold: 0 }).observe(stage);
/* frame-rate independent damping: the fraction of the remaining distance
covered depends on elapsed time, not on how often we happen to be called */
const damp = (cur, target, lambda, dt) => cur + (target - cur) * (1 - Math.exp(-lambda * dt));
const clock = new THREE.Clock();
let lastCopyOpacity = -1;
function frame() {
requestAnimationFrame(frame);
if (!visible || document.hidden) return;
const dt = Math.min(clock.getDelta(), .05); // clamp: a backgrounded tab
const t = clock.getElapsedTime(); // must not jump the rig
smx = damp(smx, mx, 3.2, dt);
smy = damp(smy, my, 3.2, dt);
sp = damp(sp, spT, 6.5, dt);
const e = sp * sp * (3 - 2 * sp); // smoothstep
// handheld float — small, slow, irrationally-related frequencies so the
// camera never visibly repeats itself
const fx = Math.sin(t * .37) * .042 + Math.sin(t * .91) * .017;
const fy = Math.cos(t * .29) * .034 + Math.sin(t * 1.13) * .012;
// dolly: from establishing shot to the threshold
const z = 10.2 - e * 7.6;
camera.position.set(
doorX * .35 + smx * .55 + fx + e * (doorX * .62),
.5 + smy * -.3 + fy - e * .25,
z
);
look.set(doorX * (.9 + e * .1), FLOOR_Y + APH * .48, 0);
camera.lookAt(look);
camera.rotation.z += Math.sin(t * .23) * .0035; // a breath of roll
// a touch of dolly-zoom as you arrive, and the light gets stronger
camera.fov = (innerWidth > 980 ? 36 : 44) - e * 3;
camera.updateProjectionMatrix();
renderer.toneMappingExposure = 1.06 + e * .1;
// living light: slow breathing + tiny flicker, intensifying as you approach
const breathe = Math.sin(t * 1.1) * .5 + Math.sin(t * 3.7) * .18;
portalLight.intensity = (86 + breathe * 9) * (1 + e * .55);
bounce.intensity = 16 * (1 + e * .8);
core.material.opacity = .32 + breathe * .035 + e * .14;
bloom.material.opacity = .32 + breathe * .03 + e * .16;
halo.material.opacity = .12 + breathe * .012 + e * .1;
pool.material.opacity = .13 + breathe * .015 + e * .09;
// the wisps drift upward, two speeds, so the portal surface never settles
wispTex.offset.y += dt * .014;
wisps.material.opacity = .5 + breathe * .05;
// dust drifts up, recycles — in metres per second, not per frame
for (const layer of [dustFar, dustNear]) {
const p = layer.geo.attributes.position.array;
for (let i = 0; i < layer.count; i++) {
p[i*3+1] += layer.vel[i] * dt;
p[i*3] += Math.sin(t * .6 + i) * dt * .05;
if (p[i*3+1] > FLOOR_Y + 5.7) p[i*3+1] = FLOOR_Y + .05;
}
layer.geo.attributes.position.needsUpdate = true;
}
// copy falls away, veil hands off to the paper site
const co = Math.max(0, 1 - e * 1.9);
if (Math.abs(co - lastCopyOpacity) > .002) { // skip no-op style writes
copy.style.opacity = co;
copy.style.transform = `translateY(${e * -40}px)`;
veil.style.opacity = Math.max(0, (e - .8) / .2);
lastCopyOpacity = co;
}
renderer.render(scene, camera);
}
frame();
}
}
/* nav adapts while over the dark stage */
const nav = document.getElementById('nav');
function navMode() {
nav.classList.toggle('on-dark', scrollY < zone.offsetHeight - innerHeight * .55);
}
addEventListener('scroll', navMode, { passive: true });
navMode();
</script>
<!-- ══════════ 01 · THE JOURNEY ══════════
The spine of the page. Everything PathFinder does is one of these
seven stages, so the page says so once, in order, and lets the
student click straight into whichever one they are standing on. -->
<section class="sec" id="journey">
<div class="wrap">
<div class="chapter" data-reveal>
<span class="no">01</span><span class="ttl">The Journey</span><span class="end">Assessment → first week</span>
</div>
<div class="split" style="align-items:end">
<div>
<h2 class="display" data-reveal style="font-size:clamp(1.9rem,3.6vw,2.9rem)">Seven stages between<br>you and <em>New Zealand.</em></h2>
</div>
<p class="muted" data-reveal style="font-size:15px;max-width:46ch">
The application is the part everyone talks about. The rest — what you actually
qualify for, what the year truly costs, what Immigration NZ wants on file, where
you sleep in week one — is where people get stuck. PathFinder covers all seven,
and remembers where you got to.
</p>
</div>
<div class="journey" data-reveal>
<div class="j-rail" aria-hidden="true"><i class="j-line"></i><i class="j-head"></i></div>
<div class="j-track" id="j-track"></div>
</div>
<div style="display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-top:52px" data-reveal>
<a class="btn btn-primary" href="app.html#assessment">Start at stage one</a>
<a class="tlink" href="app.html#dashboard">Or open the board — every application and deadline in one view</a>
</div>
</div>
</section>
<!-- ══════════ 02 · COURSES & COST ══════════
The credibility chapter: not a curated shortlist, the actual national
register — and the one financial fact that changes everything, stated
for both tracks rather than for the flattering one. Counts are baked in
at build time from assets/js/catalogue.js. -->
<section class="sec" id="catalogue" style="padding-top:0">
<div class="wrap">
<div class="chapter" data-reveal>
<span class="no">02</span><span class="ttl">Programmes & Cost</span><span class="end">NZQA register</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap">
<h2 class="display" data-reveal style="font-size:clamp(1.9rem,3.6vw,2.9rem)">Not a shortlist.<br><em>The register itself.</em></h2>
<p class="muted" data-reveal style="max-width:38ch;font-size:14.5px">
Every current postgraduate qualification NZQA lists, from all 51 providers that
teach one — universities, polytechnics and private colleges — with the entry
requirements as published, so you can check them against your own transcript.
</p>
</div>
<div class="cat-stats" data-reveal>
<div class="cat-stat"><div class="fig">1,716</div><div class="cap">Qualifications</div></div>
<div class="cat-stat"><div class="fig">51</div><div class="cap">Providers</div></div>
<div class="cat-stat"><div class="fig">281</div><div class="cap">Scholarships</div></div>
<div class="cat-stat"><div class="fig">11</div><div class="cap">Subject areas</div></div>
</div>
<div class="cat-grid" data-reveal>
<a class="cat-cell" href="app.html#courses"><span class="n">321</span><span class="t">Society and Culture</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">275</span><span class="t">Management and Commerce</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">252</span><span class="t">Health</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">147</span><span class="t">Creative Arts</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">139</span><span class="t">Education</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">122</span><span class="t">Natural and Physical Sciences</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">111</span><span class="t">Engineering and Related Technologies</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">103</span><span class="t">Information Technology</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">64</span><span class="t">Agriculture & Environmental Studies</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">63</span><span class="t">Architecture and Building</span></a>
<a class="cat-cell" href="app.html#courses"><span class="n">54</span><span class="t">Mixed Field Programmes</span></a>