Skip to content

Commit a21267e

Browse files
feat(web): r3 signature moments — ribbon, breath, gloss-fuse, callback
Four restraint-first signature additions, all callbacks to gestures the page already makes: - Marginalia ribbon (new ui/marginalia-ribbon.tsx + layout mount + CSS). Editorial running header in the top-right gutter, tracks the active section via IntersectionObserver, mono caps + vermilion stripe + ja mincho. Mirrors a printed yūhō page header. Hidden ≤1100 px. - KG-2 ticker caret breath. Replaces the hard 1.05 s blink with a 2.4 s opacity pulse 0.55 → 1 → 0.55. Reads as "the judge is watching" rather than a typing cursor. Demo-input caret keeps caretBlink (right semantics). - Cite-gloss popover bidirectionality. ReadAlong EN-pane spans now carry data-gloss-label/aux too — the popover fires on EN→JA hover with `SOURCE · {customId}` + truncated JA mate, mirroring the JA→EN direction already present. - Manifest accent inking-underline callback. Same 2 px vermilion hairline + faint ink-bleed shadow as the hero's `In English.` end-state, fading in on .reveal.is-in. No wet-smear cinema — this is recognition, not declaration. Bookends the document the same way the 宣 / 結 seals do. ~108 LOC across 4 files. Typecheck clean, vitest 41/41, build clean.
1 parent a06b1b5 commit a21267e

4 files changed

Lines changed: 173 additions & 1 deletion

File tree

web/app/globals.css

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,57 @@ body.use-system-cursor #cursor-dot { display: none !important; }
686686
.progress-rail { display: none; }
687687
}
688688

689+
/* ── Marginalia ribbon ────────────────────────────────────────────
690+
Editorial running header — sits in the top-right gutter just below
691+
the topbar, tracks the section the reader is in. Reads as a printed
692+
yūhō page-header: §-numbered chapter, mono caps, a vermilion margin
693+
stripe, the kanji label in mincho. Hidden on coarse-pointer / mobile
694+
so the narrow viewport keeps the typographic spine clean. */
695+
.marginalia-ribbon {
696+
position: fixed;
697+
top: 76px;
698+
right: 56px;
699+
z-index: 48;
700+
display: inline-flex;
701+
align-items: baseline;
702+
gap: 8px;
703+
padding: 6px 12px 6px 10px;
704+
background: rgba(14, 14, 16, 0.78);
705+
-webkit-backdrop-filter: blur(10px) saturate(120%);
706+
backdrop-filter: blur(10px) saturate(120%);
707+
border-left: 1px solid var(--vermilion);
708+
font-family: var(--f-mono);
709+
font-size: 9px;
710+
letter-spacing: 0.32em;
711+
text-transform: uppercase;
712+
color: var(--type-faint);
713+
pointer-events: none;
714+
opacity: 0;
715+
transition: opacity 380ms var(--ease-out);
716+
font-variant-numeric: tabular-nums;
717+
}
718+
:root[data-theme="light"] .marginalia-ribbon {
719+
background: rgba(250, 247, 238, 0.82);
720+
color: var(--type-muted);
721+
}
722+
.marginalia-ribbon.is-visible { opacity: 1; }
723+
.marginalia-ribbon__num {
724+
color: var(--vermilion);
725+
font-weight: 500;
726+
}
727+
.marginalia-ribbon__sep { color: var(--type-faint); }
728+
.marginalia-ribbon__ja {
729+
font-family: var(--f-jp);
730+
font-size: 12px;
731+
letter-spacing: 0.04em;
732+
text-transform: none;
733+
color: var(--paper-aged);
734+
}
735+
:root[data-theme="light"] .marginalia-ribbon__ja { color: var(--type-primary); }
736+
@media (max-width: 1100px) {
737+
.marginalia-ribbon { display: none; }
738+
}
739+
689740
/* ── Top scroll progress ───────────────────────────────────────── */
690741
#progress {
691742
position: fixed; top: 0; left: 0; right: 0; height: 2px;
@@ -1411,7 +1462,16 @@ h1.hero-title .accent::after {
14111462
font-size: 14px;
14121463
line-height: 1;
14131464
vertical-align: -1px;
1414-
animation: caretBlink 1.05s steps(2, end) infinite;
1465+
/* Kg-2 caret breathes (slow opacity pulse) rather than blinking — the
1466+
ticker carries the brand's "live judge" promise, and a typing-cursor
1467+
blink reads as code, not as a heartbeat. The 2.4 s cycle and 0.55
1468+
floor keep the breath quiet enough to not pull the eye, but visible
1469+
enough that a reader who notices it once notices it again. */
1470+
animation: kg2Breath 2.4s var(--ease-in-out) infinite;
1471+
}
1472+
@keyframes kg2Breath {
1473+
0%, 100% { opacity: 0.55; }
1474+
50% { opacity: 1; }
14151475
}
14161476
@media (prefers-reduced-motion: reduce) {
14171477
.mt-caret { animation: none; opacity: 0.6; }
@@ -3041,6 +3101,39 @@ section[data-paper-stage].manifest > .right {
30413101
margin-inline: 0 !important;
30423102
}
30433103
.manifest .left h2 { margin-bottom: 32px; }
3104+
3105+
/* Manifest accent — quiet callback to the hero's inking underline.
3106+
Same 2px vermilion hairline + faint ink-bleed shadow as the hero's
3107+
end-state, fading in on .reveal.is-in. No wet-smear cinema: this is
3108+
recognition, not declaration. The pair of underlines (`In English.`
3109+
opening, `not a chatbot.` closing) bookends the document the same way
3110+
the 宣 / 結 seals do — opening promise, closing acknowledgement. */
3111+
.manifest .section-title .accent {
3112+
position: relative;
3113+
display: inline-block;
3114+
}
3115+
.manifest .section-title .accent::after {
3116+
content: "";
3117+
position: absolute;
3118+
left: 0;
3119+
right: 0;
3120+
bottom: -0.05em;
3121+
height: 2px;
3122+
background: var(--vermilion);
3123+
box-shadow: 0 1px 0 rgba(232, 80, 58, 0.20);
3124+
opacity: 0;
3125+
pointer-events: none;
3126+
transition: opacity 600ms var(--ease-out);
3127+
}
3128+
.manifest .reveal.is-in .section-title .accent::after {
3129+
opacity: 1;
3130+
}
3131+
@media (prefers-reduced-motion: reduce) {
3132+
.manifest .section-title .accent::after {
3133+
transition: none;
3134+
opacity: 1;
3135+
}
3136+
}
30443137
.manifest .left .sig {
30453138
margin-top: 48px;
30463139
font-family: var(--f-jp);

web/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Script from "next/script";
44
import { CiteDrawerProvider } from "@/components/ui/cite-drawer";
55
import { CiteGlossLayer } from "@/components/ui/cite-gloss-tip";
66
import { CustomCursor } from "@/components/ui/custom-cursor";
7+
import { MarginaliaRibbon } from "@/components/ui/marginalia-ribbon";
78
import { ProgressRail } from "@/components/ui/progress-rail";
89
import { Preloader } from "@/components/ui/preloader";
910
import { TopBar } from "@/components/ui/topbar";
@@ -122,6 +123,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
122123
</LenisProvider>
123124
<CustomCursor />
124125
<CiteGlossLayer />
126+
<MarginaliaRibbon />
125127
<ProgressRail />
126128
<Script
127129
id="ld-json"

web/components/sections/readalong.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ export function ReadAlong() {
189189
key={p.pair}
190190
className={"ra-span" + (hovered === p.pair ? " is-on" : "")}
191191
data-pair={p.pair}
192+
data-gloss-label={`SOURCE · ${filer.customId}`}
193+
data-gloss-aux={truncate(p.jp, 60)}
192194
onMouseEnter={() => setHovered(p.pair)}
193195
onMouseLeave={() => setHovered(null)}
194196
>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"use client";
2+
import { useEffect, useState } from "react";
3+
4+
type SectionMeta = { id: string; num: string; ja: string };
5+
6+
// Section-header data, mirrored from each section's `<div className="section-tag">`.
7+
// Phase-4 ships these in the post-Phase-5 §-numbering format so the ribbon
8+
// reads coherently from day one; Phase 5 brings the in-section tags into
9+
// alignment. Hero / Access intentionally omitted — they bookend the document
10+
// and don't carry a §-numbered chapter label.
11+
const SECTIONS: SectionMeta[] = [
12+
{ id: "problem", num: "§ 01", ja: "読まれない" },
13+
{ id: "how", num: "§ 02", ja: "仕組み" },
14+
{ id: "repro", num: "§ 02 · 3", ja: "明細" },
15+
{ id: "demo", num: "§ 02 · 5", ja: "実演" },
16+
{ id: "hardware", num: "§ 02 · 6", ja: "適合" },
17+
{ id: "dag", num: "§ 02 · 7", ja: "骨格" },
18+
{ id: "readalong", num: "§ 02 · 8", ja: "対訳" },
19+
{ id: "kg2", num: "§ 02 · 9", ja: "実証" },
20+
{ id: "reports", num: "§ 03", ja: "本棚" },
21+
{ id: "failures", num: "§ 03 · 5", ja: "節度" },
22+
{ id: "manifest", num: "§ 04", ja: "節度" },
23+
{ id: "faq", num: "§ 04 · 5", ja: "余白" },
24+
];
25+
26+
// Editorial running header. Sits in the top-right gutter just below the
27+
// topbar, fades in 1.5 s after page-load (same delay as ProgressRail) so
28+
// it never competes with the hero. Tracks the section currently dominant
29+
// in the viewport via an IntersectionObserver tuned to the centre band
30+
// (rootMargin: -30% 0px -30% 0px) so it reads what the user is reading,
31+
// not what is just barely on screen. Hidden on tablet/mobile so the
32+
// reduced viewport keeps the typographic spine.
33+
export function MarginaliaRibbon() {
34+
const [active, setActive] = useState<SectionMeta | null>(null);
35+
const [visible, setVisible] = useState(false);
36+
37+
useEffect(() => {
38+
const t = setTimeout(() => setVisible(true), 1500);
39+
return () => clearTimeout(t);
40+
}, []);
41+
42+
useEffect(() => {
43+
const obs = new IntersectionObserver(
44+
(entries) => {
45+
const inView = entries
46+
.filter((e) => e.isIntersecting)
47+
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
48+
if (inView[0]) {
49+
const id = (inView[0].target as HTMLElement).id;
50+
const meta = SECTIONS.find((s) => s.id === id);
51+
if (meta) setActive(meta);
52+
}
53+
},
54+
{ rootMargin: "-30% 0px -30% 0px", threshold: [0, 0.25, 0.5, 0.75, 1] },
55+
);
56+
SECTIONS.forEach((s) => {
57+
const el = document.getElementById(s.id);
58+
if (el) obs.observe(el);
59+
});
60+
return () => obs.disconnect();
61+
}, []);
62+
63+
if (!active) return null;
64+
65+
return (
66+
<aside
67+
className={"marginalia-ribbon" + (visible ? " is-visible" : "")}
68+
aria-hidden="true"
69+
>
70+
<span className="marginalia-ribbon__num">{active.num}</span>
71+
<span className="marginalia-ribbon__sep">·</span>
72+
<span className="marginalia-ribbon__ja" lang="ja">{active.ja}</span>
73+
</aside>
74+
);
75+
}

0 commit comments

Comments
 (0)