update owliver skill

This commit is contained in:
2026-08-14 17:33:16 +05:30
parent cccada9bd2
commit 4fdfb90326
58 changed files with 8015 additions and 1339 deletions

View File

@@ -0,0 +1,183 @@
import React, {
createContext, useCallback, useContext, useEffect, useMemo, useRef, useState,
} from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* A reading path down a long page.
*
* The candidate profile is one scroll from "who is this" to "what should I do",
* and the sections are steps in that order rather than a pile of independent
* cards. This draws the order: a thin rail, one node per section, filled behind
* you and hollow ahead.
*
* It is navigation you read, not navigation you click — deliberately. A rail of
* links would be the tab bar again, in a thinner shape. Nothing here moves the
* page; it only says where in the evaluation you are.
*
* Built from a flex column and a border. No graph library: the flow is linear,
* and a linear flow drawn by a layout engine costs nothing to ship.
*/
const JourneyContext = createContext(null);
/** Where a section counts as "being read": a third of the way down the viewport. */
const READING_LINE = 0.35;
/**
* Holds which step is current.
*
* "Current" is the last section whose heading has crossed the reading line — not
* merely what is on screen, since three sections can be visible at once and only
* one of them is being read.
*
* A scroll listener rather than an observer: the question is "which of these
* eight is furthest past a line", which is one comparison per step against one
* number. An IntersectionObserver answers a different question — is this element
* in a box — and stitching eight of those answers back into an ordering is both
* more code and less exact at the boundaries. Reads are throttled to one per
* frame and every step is a single `getBoundingClientRect`.
*/
export function Journey({ children, className }) {
const [active, setActive] = useState(0);
const steps = useRef(new Map());
const register = useCallback((index, el) => {
if (el) steps.current.set(index, el);
else steps.current.delete(index);
}, []);
useEffect(() => {
const compute = () => {
const line = window.innerHeight * READING_LINE;
let next = 0;
steps.current.forEach((el, index) => {
if (el.getBoundingClientRect().top <= line && index > next) next = index;
});
setActive((prev) => (prev === next ? prev : next));
};
/* Straight off the scroll event, not through requestAnimationFrame: a
backgrounded tab never runs its frame callbacks, so a rAF-coalesced
version silently stops updating and comes back stuck on whichever step
was current when the tab was hidden. The work here is a handful of
`getBoundingClientRect` reads and no writes, which is well inside what a
scroll handler can afford. */
compute();
window.addEventListener('scroll', compute, { passive: true });
window.addEventListener('resize', compute);
return () => {
window.removeEventListener('scroll', compute);
window.removeEventListener('resize', compute);
};
}, []);
const value = useMemo(() => ({ active, register }), [active, register]);
return (
<JourneyContext.Provider value={value}>
<div className={className}>{children}</div>
</JourneyContext.Provider>
);
}
/**
* One step: a node, the line to the next one, a heading, and the section.
*
* The section fades up once as it arrives and never animates again — motion
* that repeats on every scroll past is a page that will not settle.
*/
export function JourneyStep({ index, label, meta, last = false, children }) {
const { active = 0, register } = useContext(JourneyContext) || {};
const reduced = useReducedMotion();
const ref = useCallback((el) => register?.(index, el), [register, index]);
const state = index < active ? 'done' : index === active ? 'active' : 'upcoming';
return (
<div ref={ref} className="relative flex gap-3.5 sm:gap-4">
{/* Rail */}
<div className="flex w-3 shrink-0 flex-col items-center pt-1" aria-hidden="true">
<span
className={cn(
'grid h-3 w-3 shrink-0 place-items-center rounded-full border-2 transition-colors duration-300',
state === 'upcoming' && 'border-border bg-surface',
state === 'active' && 'border-krow-blue bg-krow-blue ring-4 ring-krow-blue/15',
state === 'done' && 'border-krow-blue bg-krow-blue'
)}
>
{state === 'done' && <Check className="h-2 w-2 text-white" strokeWidth={4} />}
</span>
{!last && (
/* 2px with rounded caps: a hairline reads as a table border, and a
square end butting against a round node reads as a seam. */
<span className="relative mt-1.5 w-0.5 flex-1 overflow-hidden rounded-full bg-border">
{/* The travelled part of the line, drawn over the grey one so the
colour change is a height transition rather than a repaint.
A CSS transition rather than an animated value: the final height
is in the style either way, so a tab that never ran a frame —
backgrounded, or restored from one — still paints the rail in the
right state instead of holding the position it was hidden at. */}
<span
className="absolute inset-x-0 top-0 rounded-full bg-krow-blue transition-[height] duration-300 ease-out motion-reduce:transition-none"
style={{ height: state === 'done' ? '100%' : '0%' }}
/>
</span>
)}
</div>
{/* Section */}
<motion.section
className="min-w-0 flex-1 pb-7"
initial={reduced ? false : { opacity: 0, y: 12 }}
whileInView={reduced ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.12 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">{label}</h2>
{meta && <span className="shrink-0 text-caption text-ink-4">{meta}</span>}
</div>
{children}
</motion.section>
</div>
);
}
/**
* A number that counts up to itself once.
*
* Short and ease-out: the point is to draw the eye to the figure, not to make
* the reader wait for it. Returns the target immediately when the reader has
* asked for reduced motion, or before the element has been seen.
*/
export function useCountUp(target, { duration = 750, start = true } = {}) {
const reduced = useReducedMotion();
const [value, setValue] = useState(reduced || !start ? target : 0);
useEffect(() => {
if (reduced || !start) {
setValue(target);
return undefined;
}
let frame;
const t0 = performance.now();
const tick = (now) => {
const p = Math.min(1, (now - t0) / duration);
/* ease-out cubic — fast to nearly-there, then settles. No overshoot: a
score that bounces past itself reads as a different score. */
setValue(Math.round(target * (1 - (1 - p) ** 3)));
if (p < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target, duration, reduced, start]);
return value;
}
export default Journey;

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { ArrowRight, GraduationCap, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
@@ -39,6 +40,58 @@ const STATUS_LABEL = {
not_started: 'Not started',
};
/**
* One skill as a path: where they are verified, and the level they are walking to.
*
* The same three numbers this row always carried — level held, modules done,
* modules needed — drawn as the progression they describe rather than a comma
* list. The filled segment is `progressToNext`, so a person two modules from
* Advanced looks two modules from Advanced.
*
* A skill at its top level has no path to draw and says so.
*/
function SkillPath({ state }) {
const reduced = useReducedMotion();
const held = state.verifiedLabel;
if (!state.nextLevel) {
return <p className="mt-1.5 text-caption text-ink-3">{held} · top level on this pathway</p>;
}
return (
/* Bounded: a path stretched the width of the card reads as a progress bar
for the card, not a two-stop journey between two named levels. */
<div className="mt-2 max-w-[320px]">
<div className="flex items-center gap-1.5" aria-hidden="true">
<span
className={cn(
'h-2 w-2 shrink-0 rounded-full',
state.verifiedLevel ? 'bg-krow-blue' : 'border-2 border-border bg-surface'
)}
/>
<span className="relative h-px min-w-0 flex-1 bg-border">
<motion.span
className="absolute inset-y-0 left-0 bg-krow-blue"
initial={reduced ? false : { width: 0 }}
whileInView={{ width: `${state.progressToNext}%` }}
viewport={{ once: true, amount: 0.8 }}
transition={reduced ? { duration: 0 } : { duration: 0.7, ease: 'easeOut' }}
/>
</span>
<span className="h-2 w-2 shrink-0 rounded-full border-2 border-border bg-surface" />
</div>
<div className="mt-1 flex items-baseline justify-between gap-3 text-caption">
<span className="truncate text-ink-2">{held}</span>
<span className="shrink-0 text-ink-4">
{levelLabel(state.nextLevel)}
{state.requiredForNext > 0 && ` · ${state.completedInNext}/${state.requiredForNext}`}
</span>
</div>
</div>
);
}
export function VerifiedSkills({
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [],
onOpenTraining, className,
@@ -132,40 +185,34 @@ export function VerifiedSkills({
<div>
<ul className="divide-y divide-border rounded-xl border border-border">
{(held.length ? held : states).map((state) => (
<li key={state.skillId} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">
{state.name}
{/* The training path behind this level, named from the Forge
definition attached to this page. The pathway wording is
the level being worked towards, so a reader can see both
where the person is and what they are on. */}
{byId.get(state.skillId) && state.nextLevel && (
<span className="font-normal text-ink-4">
{' · '}{byId.get(state.skillId).name}
{' · '}{levelLabel(state.nextLevel)} pathway
</span>
<li key={state.skillId} className="px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">
{state.name}
{/* The training path behind this level, named from the Forge
definition attached to this page. The pathway wording is
the level being worked towards, so a reader can see both
where the person is and what they are on. */}
{byId.get(state.skillId) && state.nextLevel && (
<span className="font-normal text-ink-4">
{' · '}{byId.get(state.skillId).name}
</span>
)}
</p>
</div>
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
STATUS_STYLE[state.status]
)}
</p>
<p className="truncate text-caption text-ink-3">
{state.verifiedLabel}
{state.nextLevel && state.requiredForNext > 0 && (
<span className="text-ink-4">
{' · '}{state.completedInNext}/{state.requiredForNext} towards {levelLabel(state.nextLevel)}
{' · '}{state.progressToNext}%
</span>
)}
</p>
>
{state.verifiedLevel && <ShieldCheck className="h-3 w-3" aria-hidden="true" />}
{STATUS_LABEL[state.status]}
</span>
</div>
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
STATUS_STYLE[state.status]
)}
>
{state.verifiedLevel && <ShieldCheck className="h-3 w-3" aria-hidden="true" />}
{STATUS_LABEL[state.status]}
</span>
<SkillPath state={state} />
</li>
))}
</ul>