update owliver skill
This commit is contained in:
183
src/components/krow/Journey.jsx
Normal file
183
src/components/krow/Journey.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user