feat: Add packages/site — landing vitrine (Vue 3 + Tailwind 4)
- Scaffold packages/site with 11 Vue components: hero (animated terminal), features, showcase, work groups, how-it-works, security, FAQ accordion, footer - i18n EN/FR via vue-i18n with 82+ keys, autodetect navigator.language, toggle without reload - Animated timeline (terminal, dialog, toast) ported from design, scroll-reveal, copy buttons (2 instances) - Self-hosted JetBrains Mono fonts (@fontsource), optimized assets (mark 28KB, og-cover 1200×630) - SEO: canonical, og:image, theme-color, robots.txt, sitemap.xml for git-arboretum.com - Monorepo integration (decoupled from npm release): build:site / dev:site / preview:site scripts - CI: add build:site step to ci.yml for early detection; create .gitea/workflows/prod.yml for FTPS deploy (lftp mirror) to git-arboretum.com - Validated builds ✓ and UI rendering (EN/FR headless test) ✓
This commit is contained in:
113
packages/site/src/composables/useHeroTimeline.ts
Normal file
113
packages/site/src/composables/useHeroTimeline.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
// Portage 1:1 de la timeline animée du mockup hero (Arboretum.dc.html).
|
||||
export type SessionState = 'idle' | 'busy' | 'waiting';
|
||||
|
||||
export interface AnimLine {
|
||||
text: string;
|
||||
k: string;
|
||||
}
|
||||
|
||||
export interface HeroAnim {
|
||||
sess: SessionState;
|
||||
lines: AnimLine[];
|
||||
att: number;
|
||||
toast: boolean;
|
||||
dlg: boolean;
|
||||
hl: number;
|
||||
ans: number;
|
||||
cur: boolean;
|
||||
}
|
||||
|
||||
const TOTAL = 13000;
|
||||
const TICK = 80;
|
||||
|
||||
interface ScriptEntry {
|
||||
at: number;
|
||||
text: string;
|
||||
k: string;
|
||||
}
|
||||
|
||||
function script(): ScriptEntry[] {
|
||||
return [
|
||||
{ at: 850, text: '$ claude --resume feat/auth', k: 'cmd' },
|
||||
{ at: 1400, text: '> Reading src/auth/login.ts', k: 'tool' },
|
||||
{ at: 1950, text: '● Edit login.ts', k: 'edit' },
|
||||
{ at: 2400, text: " + import { verifyToken } from './jwt'", k: 'add' },
|
||||
{ at: 2850, text: ' + const user = await verifyToken(req)', k: 'add' },
|
||||
{ at: 3350, text: '● Bash npm test -- auth', k: 'edit' },
|
||||
{ at: 3850, text: ' ✓ 14 passed (1.24s)', k: 'ok' },
|
||||
{ at: 4300, text: '● Permission required to edit login.ts', k: 'warn' },
|
||||
{ at: 7950, text: '✓ approved · applying change', k: 'ok' },
|
||||
{ at: 8500, text: '● Edit login.ts', k: 'edit' },
|
||||
{ at: 9050, text: '● Running…', k: 'run' },
|
||||
];
|
||||
}
|
||||
|
||||
export function deriveAnim(clock: number): HeroAnim {
|
||||
const sess: SessionState = clock < 700 ? 'idle' : clock < 4500 ? 'busy' : clock < 7700 ? 'waiting' : 'busy';
|
||||
const sc = script();
|
||||
const lines: AnimLine[] = [];
|
||||
for (const entry of sc) {
|
||||
if (clock >= entry.at) lines.push({ text: entry.text, k: entry.k });
|
||||
}
|
||||
const att = clock >= 4500 && clock < 7700 ? 1 : 0;
|
||||
const toast = clock >= 4650 && clock < 7700;
|
||||
const dlg = clock >= 4600 && clock < 7700;
|
||||
const hl = clock >= 6000 && clock < 7050 ? 0 : -1;
|
||||
const ans = clock >= 7050 && clock < 7700 ? 0 : -1;
|
||||
const cur = sess !== 'waiting';
|
||||
return { sess, lines, att, toast, dlg, hl, ans, cur };
|
||||
}
|
||||
|
||||
export function useHeroTimeline(stageId = 'hero-stage') {
|
||||
const anim = ref<HeroAnim>(deriveAnim(0));
|
||||
let clock = 0;
|
||||
let visible = true;
|
||||
let everVisible = false;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let io: IntersectionObserver | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
let reduced = false;
|
||||
try {
|
||||
reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (reduced) {
|
||||
anim.value = deriveAnim(5200);
|
||||
return;
|
||||
}
|
||||
const stage = document.getElementById(stageId);
|
||||
if (stage && 'IntersectionObserver' in window) {
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const e = entries[0];
|
||||
if (!e) return;
|
||||
if (e.isIntersecting) {
|
||||
visible = true;
|
||||
everVisible = true;
|
||||
} else if (everVisible) {
|
||||
visible = false;
|
||||
}
|
||||
},
|
||||
{ threshold: 0, rootMargin: '200px 0px 200px 0px' },
|
||||
);
|
||||
io.observe(stage);
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
if (!visible) return;
|
||||
clock += TICK;
|
||||
if (clock >= TOTAL) clock = 0;
|
||||
anim.value = deriveAnim(clock);
|
||||
}, TICK);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
if (io) io.disconnect();
|
||||
});
|
||||
|
||||
return { anim };
|
||||
}
|
||||
Reference in New Issue
Block a user