Work section: bring the OpenVibe bento back as group one, no duplicated sites
Changes
BBinary files a/public/assets/img/previews/alexfrison.net.jpg and b/public/assets/img/previews/alexfrison.net.jpg differBBinary files a/public/assets/img/previews/local_home.jpg and b/public/assets/img/previews/local_home.jpg differBBinary files a/public/assets/img/previews/local_home__about.jpg and b/public/assets/img/previews/local_home__about.jpg differBBinary files a/public/assets/img/previews/local_home__contact.jpg and b/public/assets/img/previews/local_home__contact.jpg differBBinary files a/public/assets/img/previews/local_home__experience.jpg and b/public/assets/img/previews/local_home__experience.jpg differBBinary files a/public/assets/img/previews/local_home__metrics.jpg and b/public/assets/img/previews/local_home__metrics.jpg differBBinary files a/public/assets/img/previews/local_home__network.jpg and b/public/assets/img/previews/local_home__network.jpg differBBinary files a/public/assets/img/previews/local_home__skills.jpg and b/public/assets/img/previews/local_home__skills.jpg differBBinary files a/public/assets/img/previews/local_home__work.jpg and b/public/assets/img/previews/local_home__work.jpg differBBinary files a/public/assets/img/previews/local_portfolio.jpg and b/public/assets/img/previews/local_portfolio.jpg differBBinary files a/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differBBinary files a/public/assets/img/previews/local_portfolio_gameserverlists.jpg and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differBBinary files a/public/assets/img/previews/local_portfolio_gameserverstats.jpg and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differBBinary files a/public/assets/img/previews/local_portfolio_hartools.jpg and b/public/assets/img/previews/local_portfolio_hartools.jpg differBBinary files a/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differBBinary files a/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differBBinary files a/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differBBinary files a/public/assets/img/previews/local_portfolio_live-translation.jpg and b/public/assets/img/previews/local_portfolio_live-translation.jpg differBBinary files a/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differBBinary files a/public/assets/img/previews/local_portfolio_mp-select-mini.jpg and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-community.jpg and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-games.jpg and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-live.jpg and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-media.jpg and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-network.jpg and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differBBinary files a/public/assets/img/previews/local_portfolio_openvibe-tools.jpg and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differBBinary files a/public/assets/img/previews/local_portfolio_perf-harness.jpg and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differBBinary files a/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differBBinary files a/public/assets/img/previews/local_portfolio_powerchat-payments.jpg and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differBBinary files a/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differBBinary files a/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differBBinary files a/public/assets/img/previews/local_resume.jpg and b/public/assets/img/previews/local_resume.jpg differBBinary files a/public/assets/img/previews/openvibe.chat.jpg and b/public/assets/img/previews/openvibe.chat.jpg differBBinary files a/public/assets/img/previews/openvibe.community.jpg and b/public/assets/img/previews/openvibe.community.jpg differBBinary files a/public/assets/img/previews/openvibe.games.jpg and b/public/assets/img/previews/openvibe.games.jpg differBBinary files a/public/assets/img/previews/openvibe.live.jpg and b/public/assets/img/previews/openvibe.live.jpg differBBinary files a/public/assets/img/previews/openvibe.media.jpg and b/public/assets/img/previews/openvibe.media.jpg differBBinary files a/public/assets/img/previews/openvibe.network.jpg and b/public/assets/img/previews/openvibe.network.jpg differBBinary files a/public/assets/img/previews/openvibe.tools.jpg and b/public/assets/img/previews/openvibe.tools.jpg differnnew file mode 100644@@@ -0,0 +1,165 @@++---++import profile from "../data/profile.json";++const sites = profile.network;++const big = sites[0];++const rest = sites.slice(1);++const icons: Record<string, string> = {++ live: "fa-solid fa-tower-broadcast", network: "fa-solid fa-id-badge", media: "fa-solid fa-photo-film",++ tools: "fa-solid fa-toolbox", games: "fa-solid fa-gamepad", community: "fa-solid fa-comments", sites: "fa-solid fa-globe",++};++// Architecture diagram geometry (viewBox 800 x 420)++const hub = { x: 400, y: 210 };++const nodes = [++ { key: "live", label: "Live", x: 130, y: 90 },++ { key: "media", label: "Media", x: 130, y: 330 },++ { key: "tools", label: "Tools", x: 400, y: 50 },++ { key: "games", label: "Games", x: 400, y: 372 },++ { key: "community", label: "Community", x: 670, y: 90 },++ { key: "sites", label: "13 Sites", x: 670, y: 330 },++];++const edges = [++ ["live", "media"],++];++---++<!-- The OpenVibe network as the bento it used to be (one big Live tile, four small tiles, two wide++ ones) plus the "how it fits together" card and diagram. Rendered inside the Work section's++ OpenVibe group, so the sites appear once on the page instead of as tiles here and again as++ project cards below. -->++<div class="ovb">++ <div class="bento">++ <a class="tile tile--big spot" href={big.url} target="_blank" rel="noopener" data-reveal data-tilt style={`--img:url(${big.image})`}>++ <div class="tile__shot" aria-hidden="true"><img src={big.image} alt="" loading="lazy" width="1120" height="700" /></div>++ <div class="tile__body">++ <div class="tile__top">++ <span class="tile__icon"><i class={icons[big.key]}></i></span>++ <span class="tile__role">{big.role}</span>++ </div>++ <h3 class="tile__name">{big.name}</h3>++ <p class="tile__blurb">{big.blurb}</p>++ <div class="tile__foot"><span class="tile__stat mono">{big.stat}</span><span class="tile__go">Open <i class="fa-solid fa-arrow-up-right-from-square"></i></span></div>++ </div>++ </a>++++ {rest.map((s, i) => (++ <a class={`tile spot tile--${s.key}`} href={s.url} target="_blank" rel="noopener" data-reveal data-delay={String(60 * (i + 1))} data-tilt>++ {s.image && <div class="tile__shot" aria-hidden="true"><img src={s.image} alt="" loading="lazy" width="1120" height="700" /></div>}++ <div class="tile__body">++ <div class="tile__top">++ <span class="tile__icon"><i class={icons[s.key]}></i></span>++ <span class="tile__role">{s.role}</span>++ </div>++ <h3 class="tile__name">{s.name}</h3>++ <p class="tile__blurb">{s.blurb}</p>++ <div class="tile__foot"><span class="tile__stat mono">{s.stat}</span><span class="tile__go">Open <i class="fa-solid fa-arrow-up-right-from-square"></i></span></div>++ </div>++ </a>++ ))}++ </div>++++ <div class="arch card" data-reveal>++ <div class="arch__copy">++ <div class="eyebrow"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> How it fits together</div>++ <h3>One login, one wallet, seven products that feel like one.</h3>++ <p>Sign in once and every OpenVibe site knows who you are, what you own and what you look like. The identity service in the middle handles accounts, security and the shared virtual currency; every other product plugs into it through clear, documented APIs. That's the same playbook I bring to any company: secure single sign-on, payments and APIs designed so new products can be added without breaking the old ones.</p>++ <ul class="arch__facts">++ <li><i class="fa-solid fa-rotate"></i> Updates ship with zero downtime, so users never see an outage</li>++ <li><i class="fa-solid fa-cloud-arrow-up"></i> Media storage tiers automatically from fast local disks to cheap cloud storage</li>++ <li><i class="fa-solid fa-robot"></i> AI features run on a budget-capped, vendor-neutral layer (OpenAI, Anthropic or local models)</li>++ <li><i class="fa-solid fa-shield-halved"></i> Every migration is rehearsed as a dry run before it touches real users</li>++ </ul>++ </div>++ <svg class="arch__svg" viewBox="0 0 800 420" role="img" aria-label="Diagram: OpenVibe.Network at the center connected to Live, Media, Tools, Games, Community and 13 static sites; Live also talks to Media.">++ <defs>++ <linearGradient id="agrad" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="var(--accent)"/><stop offset="1" stop-color="var(--accent-2)"/></linearGradient>++ <filter id="aglow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>++ </defs>++ {nodes.map((n) => (++ <g class="arch__edge">++ <line x1={hub.x} y1={hub.y} x2={n.x} y2={n.y} class="arch__line" />++ <line x1={hub.x} y1={hub.y} x2={n.x} y2={n.y} class="arch__pulse" />++ </g>++ ))}++ {edges.map(([a, b]) => { const A = nodes.find(n => n.key === a)!, B = nodes.find(n => n.key === b)!; return (++ <g class="arch__edge arch__edge--side"><line x1={A.x} y1={A.y} x2={B.x} y2={B.y} class="arch__line" /><line x1={A.x} y1={A.y} x2={B.x} y2={B.y} class="arch__pulse arch__pulse--slow" /></g>++ ); })}++ <g class="arch__hub" transform={`translate(${hub.x} ${hub.y})`}>++ <circle r="58" class="arch__hub-glow" filter="url(#aglow)" />++ <circle r="46" class="arch__hub-ring" />++ <circle r="40" class="arch__hub-fill" />++ <text y="-4" text-anchor="middle" class="arch__hub-t1">OpenVibe</text>++ <text y="14" text-anchor="middle" class="arch__hub-t2">.Network</text>++ <text y="30" text-anchor="middle" class="arch__hub-t3">OIDC · wallet · chrome</text>++ </g>++ {nodes.map((n) => (++ <g class="arch__node" transform={`translate(${n.x} ${n.y})`}>++ <rect x="-62" y="-22" width="124" height="44" rx="12" class="arch__node-box" />++ <text y="5" text-anchor="middle" class="arch__node-t">{n.label}</text>++ </g>++ ))}++ </svg>++ </div>++</div>++++<style>++ .bento { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: minmax(210px, auto); gap: 14px; margin-bottom: 18px; }++ .tile {++ position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end;++ min-height: 210px; border-radius: var(--radius-lg); text-decoration: none; color: var(--fg);++ background: var(--surface); border: 1px solid var(--border);++ transition: transform .5s var(--ease), border-color .35s var(--ease), box-shadow .5s var(--ease);++ will-change: transform;++ }++ .tile:hover { text-decoration: none; border-color: rgba(var(--accent-rgb), .45); box-shadow: var(--shadow-card), var(--shadow-glow); }++ .tile--big { grid-column: span 2; grid-row: span 2; min-height: 440px; }++ .tile__shot { position: absolute; inset: 0; z-index: 0; overflow: hidden; }++ .tile__shot img { width: 100%; height: 100%; object-fit: cover; object-position: top; opacity: .28; transform: scale(1.04); transition: transform 1.2s var(--ease), opacity .6s var(--ease), filter .6s; filter: saturate(.8); }++ .tile:hover .tile__shot img { opacity: .55; transform: scale(1) translateY(-2%); filter: saturate(1.05); }++ .tile__shot::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(7,9,15,.1) 0%, rgba(7,9,15,.55) 45%, rgba(7,9,15,.96) 100%); }++ :root[data-theme="light"] .tile__shot::after { background: linear-gradient(180deg, rgba(244,246,251,.2) 0%, rgba(244,246,251,.7) 45%, rgba(244,246,251,.98) 100%); }++ .tile__body { position: relative; z-index: 1; padding: clamp(18px, 2.4vw, 26px); display: flex; flex-direction: column; gap: 8px; }++ .tile__top { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }++ .tile__icon { width: 34px; height: 34px; border-radius: 10px; display: inline-grid; place-items: center; background: rgba(var(--accent-rgb), .14); color: var(--accent); font-size: .9rem; border: 1px solid rgba(var(--accent-rgb), .2); }++ .tile__role { font-family: var(--font-mono); font-size: .7rem; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++ .tile__foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 6px; }++ .tile__stat { font-size: .7rem; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++ .tile__name { margin: 0; font-size: clamp(1.1rem, 1.6vw, 1.45rem); letter-spacing: -.02em; overflow-wrap: anywhere; }++ .tile--big .tile__name { font-size: clamp(1.8rem, 3vw, 2.6rem); }++ .tile__blurb { margin: 0; font-size: .92rem; color: var(--muted); line-height: 1.55; max-width: 52ch; }++ .tile__go { display: inline-flex; align-items: center; gap: 8px; flex: none; font: 600 .8rem var(--font-mono); color: var(--accent); opacity: 0; transform: translateY(6px); transition: opacity .35s var(--ease), transform .35s var(--ease); }++ .tile:hover .tile__go { opacity: 1; transform: none; }++ .tile--community, .tile--sites { grid-column: span 2; }++ .tile--sites { background: var(--grad-soft); }++ .tile--sites::before { content: ""; position: absolute; inset: 0; background: radial-gradient(60% 60% at 80% 20%, rgba(var(--accent-2-rgb), .25), transparent 70%); }++ @media (max-width: 1024px) { .bento { grid-template-columns: repeat(2, 1fr); } .tile--big { grid-column: span 2; grid-row: span 1; min-height: 360px; } .tile--community, .tile--sites { grid-column: span 1; } }++ @media (max-width: 560px) { .bento { grid-template-columns: 1fr; } .tile--big, .tile--community, .tile--sites { grid-column: span 1; } .tile--big { min-height: 320px; } }++++ /* Architecture */++ .arch { display: grid; grid-template-columns: .9fr 1.1fr; gap: clamp(20px, 4vw, 48px); align-items: center; padding: clamp(22px, 3.5vw, 40px); margin-top: clamp(16px, 3vw, 28px); }++ .arch .eyebrow { display: inline-flex; align-items: center; gap: 8px; }++ .arch__copy h3 { font-size: clamp(1.3rem, 2.2vw, 1.8rem); margin-bottom: .6em; }++ .arch__facts { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; }++ .arch__facts li { display: flex; gap: 10px; align-items: flex-start; color: var(--muted); font-size: .9rem; }++ .arch__facts i { color: var(--accent-3); margin-top: 4px; font-size: .75rem; }++ .arch__svg { width: 100%; height: auto; overflow: visible; }++ .arch__line { stroke: var(--border-strong); stroke-width: 1.2; }++ .arch__pulse { stroke: url(#agrad); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 14 200; animation: pulse 3.2s linear infinite; opacity: .9; }++ .arch__pulse--slow { animation-duration: 4.4s; animation-delay: -1s; }++ .arch__edge:nth-child(2) .arch__pulse { animation-delay: -.6s; }++ .arch__edge:nth-child(3) .arch__pulse { animation-delay: -1.2s; }++ .arch__edge:nth-child(4) .arch__pulse { animation-delay: -1.8s; }++ .arch__edge:nth-child(5) .arch__pulse { animation-delay: -2.4s; }++ .arch__edge:nth-child(6) .arch__pulse { animation-delay: -3s; }++ @keyframes pulse { from { stroke-dashoffset: 214; } to { stroke-dashoffset: -214; } }++ .arch__hub-glow { fill: rgba(var(--accent-rgb), .16); animation: hubGlow 3s ease-in-out infinite; }++ @keyframes hubGlow { 50% { fill: rgba(var(--accent-rgb), .3); } }++ .arch__hub-ring { fill: none; stroke: url(#agrad); stroke-width: 1.5; stroke-dasharray: 6 8; animation: ringSpin 30s linear infinite; transform-origin: center; transform-box: fill-box; }++ @keyframes ringSpin { to { transform: rotate(360deg); } }++ .arch__hub-fill { fill: var(--surface-solid); stroke: var(--border-strong); }++ .arch__hub-t1 { font: 700 15px var(--font-display); fill: var(--fg); }++ .arch__hub-t2 { font: 700 13px var(--font-display); fill: var(--accent); }++ .arch__hub-t3 { font: 500 8px var(--font-mono); fill: var(--dim); letter-spacing: .06em; }++ .arch__node-box { fill: var(--surface-solid); stroke: var(--border-strong); transition: stroke .3s, fill .3s; }++ .arch__node:hover .arch__node-box { stroke: var(--accent); }++ .arch__node-t { font: 600 13px var(--font-body); fill: var(--fg); }++ @media (max-width: 900px) { .arch { grid-template-columns: 1fr; } }++</style>@@@ -5,11 +5,14 @@// compact — one line of title, two of summary — with the full case notes one click away.import { getCollection } from "astro:content";import profile from "../data/profile.json";++import OpenVibeBento from "./OpenVibeBento.astro";const all = (await getCollection("projects")).sort((a, b) => a.data.order - b.data.order);++// OpenVibe's own services are shown once, as the bento in group one — their case-study++// pages (openvibe-* slugs) stay out of the other groups so nothing appears twice.const byCats = (cats: string[], n: number) =>-- all.filter((p) => cats.includes(p.data.category)).sort((a, b) => Number(b.data.featured) - Number(a.data.featured) || a.data.order - b.data.order).slice(0, n);--const countCats = (cats: string[]) => all.filter((p) => cats.includes(p.data.category)).length;++ all.filter((p) => cats.includes(p.data.category) && !p.slug.startsWith("openvibe-")).sort((a, b) => Number(b.data.featured) - Number(a.data.featured) || a.data.order - b.data.order).slice(0, n);++const countCats = (cats: string[]) => all.filter((p) => cats.includes(p.data.category) && !p.slug.startsWith("openvibe-")).length;const catLabel: Record<string, string> = {openvibe: "OpenVibe", powerchat: "PowerChat", amazon: "Amazon", ai: "AI", infra: "Infra & ops",@@@ -25,7 +28,7 @@ const groups = [key: "openvibe", tone: "a", anchor: "network", icon: "fa-solid fa-diagram-project",title: "The OpenVibe network", blurb: "Seven live services behind one sign-in, all built and run by me since 2026.",count: profile.network.length, allHref: "/portfolio/?cat=openvibe,infra", allLabel: "All OpenVibe case notes",-- items: profile.network.map((n) => ({ href: n.url, external: true, image: n.image, icon: netIcon[n.key] ?? "fa-solid fa-cube", title: n.name, sub: n.role, text: n.blurb, meta: n.stat })),++ items: [] as { href: string; external: boolean; image?: string | null; icon: string; title: string; sub: string; text: string; meta: string }[],},{key: "powerchat", tone: "b", icon: "fa-solid fa-bolt",@@@ -69,7 +72,8 @@ const groups = [</div><a class="wk-group__all" href={g.allHref}><i class="fa-solid fa-grip" aria-hidden="true"></i> <span>{g.allLabel}</span> <i class="fa-solid fa-arrow-right wk-group__arrow" aria-hidden="true"></i></a></header>-- <ul class="wk-grid" data-stagger>++ {g.key === "openvibe" && <OpenVibeBento />}++ <ul class="wk-grid" data-stagger hidden={g.items.length === 0}>{g.items.map((it) => (<li class={`wk-card ${it.image ? "wk-card--img" : ""}`}><a href={it.href} class="wk-card__link" target={it.external ? "_blank" : undefined} rel={it.external ? "noopener" : undefined}>
Binary files a/public/assets/img/previews/alexfrison.net.jpg and b/public/assets/img/previews/alexfrison.net.jpg differ
Binary files a/public/assets/img/previews/alexfrison.net.jpg and b/public/assets/img/previews/alexfrison.net.jpg differ
Binary files a/public/assets/img/previews/local_home.jpg and b/public/assets/img/previews/local_home.jpg differ
Binary files a/public/assets/img/previews/local_home.jpg and b/public/assets/img/previews/local_home.jpg differ
Binary files a/public/assets/img/previews/local_home__about.jpg and b/public/assets/img/previews/local_home__about.jpg differ
Binary files a/public/assets/img/previews/local_home__about.jpg and b/public/assets/img/previews/local_home__about.jpg differ
Binary files a/public/assets/img/previews/local_home__contact.jpg and b/public/assets/img/previews/local_home__contact.jpg differ
Binary files a/public/assets/img/previews/local_home__contact.jpg and b/public/assets/img/previews/local_home__contact.jpg differ
Binary files a/public/assets/img/previews/local_home__experience.jpg and b/public/assets/img/previews/local_home__experience.jpg differ
Binary files a/public/assets/img/previews/local_home__experience.jpg and b/public/assets/img/previews/local_home__experience.jpg differ
Binary files a/public/assets/img/previews/local_home__metrics.jpg and b/public/assets/img/previews/local_home__metrics.jpg differ
Binary files a/public/assets/img/previews/local_home__metrics.jpg and b/public/assets/img/previews/local_home__metrics.jpg differ
Binary files a/public/assets/img/previews/local_home__network.jpg and b/public/assets/img/previews/local_home__network.jpg differ
Binary files a/public/assets/img/previews/local_home__network.jpg and b/public/assets/img/previews/local_home__network.jpg differ
Binary files a/public/assets/img/previews/local_home__skills.jpg and b/public/assets/img/previews/local_home__skills.jpg differ
Binary files a/public/assets/img/previews/local_home__skills.jpg and b/public/assets/img/previews/local_home__skills.jpg differ
Binary files a/public/assets/img/previews/local_home__work.jpg and b/public/assets/img/previews/local_home__work.jpg differ
Binary files a/public/assets/img/previews/local_home__work.jpg and b/public/assets/img/previews/local_home__work.jpg differ
Binary files a/public/assets/img/previews/local_portfolio.jpg and b/public/assets/img/previews/local_portfolio.jpg differ
Binary files a/public/assets/img/previews/local_portfolio.jpg and b/public/assets/img/previews/local_portfolio.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_gameserverlists.jpg and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_gameserverlists.jpg and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_gameserverstats.jpg and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_gameserverstats.jpg and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_hartools.jpg and b/public/assets/img/previews/local_portfolio_hartools.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_hartools.jpg and b/public/assets/img/previews/local_portfolio_hartools.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_live-translation.jpg and b/public/assets/img/previews/local_portfolio_live-translation.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_live-translation.jpg and b/public/assets/img/previews/local_portfolio_live-translation.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_mp-select-mini.jpg and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_mp-select-mini.jpg and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-community.jpg and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-community.jpg and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-games.jpg and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-games.jpg and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-live.jpg and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-live.jpg and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-media.jpg and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-media.jpg and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-network.jpg and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-network.jpg and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-tools.jpg and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_openvibe-tools.jpg and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_perf-harness.jpg and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_perf-harness.jpg and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_powerchat-payments.jpg and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_powerchat-payments.jpg and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differ
Binary files a/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differ
Binary files a/public/assets/img/previews/local_resume.jpg and b/public/assets/img/previews/local_resume.jpg differ
Binary files a/public/assets/img/previews/local_resume.jpg and b/public/assets/img/previews/local_resume.jpg differ
Binary files a/public/assets/img/previews/openvibe.chat.jpg and b/public/assets/img/previews/openvibe.chat.jpg differ
Binary files a/public/assets/img/previews/openvibe.chat.jpg and b/public/assets/img/previews/openvibe.chat.jpg differ
Binary files a/public/assets/img/previews/openvibe.community.jpg and b/public/assets/img/previews/openvibe.community.jpg differ
Binary files a/public/assets/img/previews/openvibe.community.jpg and b/public/assets/img/previews/openvibe.community.jpg differ
Binary files a/public/assets/img/previews/openvibe.games.jpg and b/public/assets/img/previews/openvibe.games.jpg differ
Binary files a/public/assets/img/previews/openvibe.games.jpg and b/public/assets/img/previews/openvibe.games.jpg differ
Binary files a/public/assets/img/previews/openvibe.live.jpg and b/public/assets/img/previews/openvibe.live.jpg differ
Binary files a/public/assets/img/previews/openvibe.live.jpg and b/public/assets/img/previews/openvibe.live.jpg differ
Binary files a/public/assets/img/previews/openvibe.media.jpg and b/public/assets/img/previews/openvibe.media.jpg differ
Binary files a/public/assets/img/previews/openvibe.media.jpg and b/public/assets/img/previews/openvibe.media.jpg differ
Binary files a/public/assets/img/previews/openvibe.network.jpg and b/public/assets/img/previews/openvibe.network.jpg differ
Binary files a/public/assets/img/previews/openvibe.network.jpg and b/public/assets/img/previews/openvibe.network.jpg differ
Binary files a/public/assets/img/previews/openvibe.tools.jpg and b/public/assets/img/previews/openvibe.tools.jpg differ
Binary files a/public/assets/img/previews/openvibe.tools.jpg and b/public/assets/img/previews/openvibe.tools.jpg differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+---
+import profile from "../data/profile.json";
+const sites = profile.network;
+const big = sites[0];
+const rest = sites.slice(1);
+const icons: Record<string, string> = {
+ live: "fa-solid fa-tower-broadcast", network: "fa-solid fa-id-badge", media: "fa-solid fa-photo-film",
+ tools: "fa-solid fa-toolbox", games: "fa-solid fa-gamepad", community: "fa-solid fa-comments", sites: "fa-solid fa-globe",
+};
+// Architecture diagram geometry (viewBox 800 x 420)
+const hub = { x: 400, y: 210 };
+const nodes = [
+ { key: "live", label: "Live", x: 130, y: 90 },
+ { key: "media", label: "Media", x: 130, y: 330 },
+ { key: "tools", label: "Tools", x: 400, y: 50 },
+ { key: "games", label: "Games", x: 400, y: 372 },
+ { key: "community", label: "Community", x: 670, y: 90 },
+ { key: "sites", label: "13 Sites", x: 670, y: 330 },
+];
+const edges = [
+ ["live", "media"],
+];
+---
+<!-- The OpenVibe network as the bento it used to be (one big Live tile, four small tiles, two wide
+ ones) plus the "how it fits together" card and diagram. Rendered inside the Work section's
+ OpenVibe group, so the sites appear once on the page instead of as tiles here and again as
+ project cards below. -->
+<div class="ovb">
+ <div class="bento">
+ <a class="tile tile--big spot" href={big.url} target="_blank" rel="noopener" data-reveal data-tilt style={`--img:url(${big.image})`}>
+ <div class="tile__shot" aria-hidden="true"><img src={big.image} alt="" loading="lazy" width="1120" height="700" /></div>
+ <div class="tile__body">
+ <div class="tile__top">
+ <span class="tile__icon"><i class={icons[big.key]}></i></span>
+ <span class="tile__role">{big.role}</span>
+ </div>
+ <h3 class="tile__name">{big.name}</h3>
+ <p class="tile__blurb">{big.blurb}</p>
+ <div class="tile__foot"><span class="tile__stat mono">{big.stat}</span><span class="tile__go">Open <i class="fa-solid fa-arrow-up-right-from-square"></i></span></div>
+ </div>
+ </a>
+
+ {rest.map((s, i) => (
+ <a class={`tile spot tile--${s.key}`} href={s.url} target="_blank" rel="noopener" data-reveal data-delay={String(60 * (i + 1))} data-tilt>
+ {s.image && <div class="tile__shot" aria-hidden="true"><img src={s.image} alt="" loading="lazy" width="1120" height="700" /></div>}
+ <div class="tile__body">
+ <div class="tile__top">
+ <span class="tile__icon"><i class={icons[s.key]}></i></span>
+ <span class="tile__role">{s.role}</span>
+ </div>
+ <h3 class="tile__name">{s.name}</h3>
+ <p class="tile__blurb">{s.blurb}</p>
+ <div class="tile__foot"><span class="tile__stat mono">{s.stat}</span><span class="tile__go">Open <i class="fa-solid fa-arrow-up-right-from-square"></i></span></div>
+ </div>
+ </a>
+ ))}
+ </div>
+
+ <div class="arch card" data-reveal>
+ <div class="arch__copy">
+ <div class="eyebrow"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> How it fits together</div>
+ <h3>One login, one wallet, seven products that feel like one.</h3>
+ <p>Sign in once and every OpenVibe site knows who you are, what you own and what you look like. The identity service in the middle handles accounts, security and the shared virtual currency; every other product plugs into it through clear, documented APIs. That's the same playbook I bring to any company: secure single sign-on, payments and APIs designed so new products can be added without breaking the old ones.</p>
+ <ul class="arch__facts">
+ <li><i class="fa-solid fa-rotate"></i> Updates ship with zero downtime, so users never see an outage</li>
+ <li><i class="fa-solid fa-cloud-arrow-up"></i> Media storage tiers automatically from fast local disks to cheap cloud storage</li>
+ <li><i class="fa-solid fa-robot"></i> AI features run on a budget-capped, vendor-neutral layer (OpenAI, Anthropic or local models)</li>
+ <li><i class="fa-solid fa-shield-halved"></i> Every migration is rehearsed as a dry run before it touches real users</li>
+ </ul>
+ </div>
+ <svg class="arch__svg" viewBox="0 0 800 420" role="img" aria-label="Diagram: OpenVibe.Network at the center connected to Live, Media, Tools, Games, Community and 13 static sites; Live also talks to Media.">
+ <defs>
+ <linearGradient id="agrad" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="var(--accent)"/><stop offset="1" stop-color="var(--accent-2)"/></linearGradient>
+ <filter id="aglow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+ </defs>
+ {nodes.map((n) => (
+ <g class="arch__edge">
+ <line x1={hub.x} y1={hub.y} x2={n.x} y2={n.y} class="arch__line" />
+ <line x1={hub.x} y1={hub.y} x2={n.x} y2={n.y} class="arch__pulse" />
+ </g>
+ ))}
+ {edges.map(([a, b]) => { const A = nodes.find(n => n.key === a)!, B = nodes.find(n => n.key === b)!; return (
+ <g class="arch__edge arch__edge--side"><line x1={A.x} y1={A.y} x2={B.x} y2={B.y} class="arch__line" /><line x1={A.x} y1={A.y} x2={B.x} y2={B.y} class="arch__pulse arch__pulse--slow" /></g>
+ ); })}
+ <g class="arch__hub" transform={`translate(${hub.x} ${hub.y})`}>
+ <circle r="58" class="arch__hub-glow" filter="url(#aglow)" />
+ <circle r="46" class="arch__hub-ring" />
+ <circle r="40" class="arch__hub-fill" />
+ <text y="-4" text-anchor="middle" class="arch__hub-t1">OpenVibe</text>
+ <text y="14" text-anchor="middle" class="arch__hub-t2">.Network</text>
+ <text y="30" text-anchor="middle" class="arch__hub-t3">OIDC · wallet · chrome</text>
+ </g>
+ {nodes.map((n) => (
+ <g class="arch__node" transform={`translate(${n.x} ${n.y})`}>
+ <rect x="-62" y="-22" width="124" height="44" rx="12" class="arch__node-box" />
+ <text y="5" text-anchor="middle" class="arch__node-t">{n.label}</text>
+ </g>
+ ))}
+ </svg>
+ </div>
+</div>
+
+<style>
+ .bento { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: minmax(210px, auto); gap: 14px; margin-bottom: 18px; }
+ .tile {
+ position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end;
+ min-height: 210px; border-radius: var(--radius-lg); text-decoration: none; color: var(--fg);
+ background: var(--surface); border: 1px solid var(--border);
+ transition: transform .5s var(--ease), border-color .35s var(--ease), box-shadow .5s var(--ease);
+ will-change: transform;
+ }
+ .tile:hover { text-decoration: none; border-color: rgba(var(--accent-rgb), .45); box-shadow: var(--shadow-card), var(--shadow-glow); }
+ .tile--big { grid-column: span 2; grid-row: span 2; min-height: 440px; }
+ .tile__shot { position: absolute; inset: 0; z-index: 0; overflow: hidden; }
+ .tile__shot img { width: 100%; height: 100%; object-fit: cover; object-position: top; opacity: .28; transform: scale(1.04); transition: transform 1.2s var(--ease), opacity .6s var(--ease), filter .6s; filter: saturate(.8); }
+ .tile:hover .tile__shot img { opacity: .55; transform: scale(1) translateY(-2%); filter: saturate(1.05); }
+ .tile__shot::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(7,9,15,.1) 0%, rgba(7,9,15,.55) 45%, rgba(7,9,15,.96) 100%); }
+ :root[data-theme="light"] .tile__shot::after { background: linear-gradient(180deg, rgba(244,246,251,.2) 0%, rgba(244,246,251,.7) 45%, rgba(244,246,251,.98) 100%); }
+ .tile__body { position: relative; z-index: 1; padding: clamp(18px, 2.4vw, 26px); display: flex; flex-direction: column; gap: 8px; }
+ .tile__top { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
+ .tile__icon { width: 34px; height: 34px; border-radius: 10px; display: inline-grid; place-items: center; background: rgba(var(--accent-rgb), .14); color: var(--accent); font-size: .9rem; border: 1px solid rgba(var(--accent-rgb), .2); }
+ .tile__role { font-family: var(--font-mono); font-size: .7rem; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ .tile__foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 6px; }
+ .tile__stat { font-size: .7rem; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ .tile__name { margin: 0; font-size: clamp(1.1rem, 1.6vw, 1.45rem); letter-spacing: -.02em; overflow-wrap: anywhere; }
+ .tile--big .tile__name { font-size: clamp(1.8rem, 3vw, 2.6rem); }
+ .tile__blurb { margin: 0; font-size: .92rem; color: var(--muted); line-height: 1.55; max-width: 52ch; }
+ .tile__go { display: inline-flex; align-items: center; gap: 8px; flex: none; font: 600 .8rem var(--font-mono); color: var(--accent); opacity: 0; transform: translateY(6px); transition: opacity .35s var(--ease), transform .35s var(--ease); }
+ .tile:hover .tile__go { opacity: 1; transform: none; }
+ .tile--community, .tile--sites { grid-column: span 2; }
+ .tile--sites { background: var(--grad-soft); }
+ .tile--sites::before { content: ""; position: absolute; inset: 0; background: radial-gradient(60% 60% at 80% 20%, rgba(var(--accent-2-rgb), .25), transparent 70%); }
+ @media (max-width: 1024px) { .bento { grid-template-columns: repeat(2, 1fr); } .tile--big { grid-column: span 2; grid-row: span 1; min-height: 360px; } .tile--community, .tile--sites { grid-column: span 1; } }
+ @media (max-width: 560px) { .bento { grid-template-columns: 1fr; } .tile--big, .tile--community, .tile--sites { grid-column: span 1; } .tile--big { min-height: 320px; } }
+
+ /* Architecture */
+ .arch { display: grid; grid-template-columns: .9fr 1.1fr; gap: clamp(20px, 4vw, 48px); align-items: center; padding: clamp(22px, 3.5vw, 40px); margin-top: clamp(16px, 3vw, 28px); }
+ .arch .eyebrow { display: inline-flex; align-items: center; gap: 8px; }
+ .arch__copy h3 { font-size: clamp(1.3rem, 2.2vw, 1.8rem); margin-bottom: .6em; }
+ .arch__facts { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; }
+ .arch__facts li { display: flex; gap: 10px; align-items: flex-start; color: var(--muted); font-size: .9rem; }
+ .arch__facts i { color: var(--accent-3); margin-top: 4px; font-size: .75rem; }
+ .arch__svg { width: 100%; height: auto; overflow: visible; }
+ .arch__line { stroke: var(--border-strong); stroke-width: 1.2; }
+ .arch__pulse { stroke: url(#agrad); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 14 200; animation: pulse 3.2s linear infinite; opacity: .9; }
+ .arch__pulse--slow { animation-duration: 4.4s; animation-delay: -1s; }
+ .arch__edge:nth-child(2) .arch__pulse { animation-delay: -.6s; }
+ .arch__edge:nth-child(3) .arch__pulse { animation-delay: -1.2s; }
+ .arch__edge:nth-child(4) .arch__pulse { animation-delay: -1.8s; }
+ .arch__edge:nth-child(5) .arch__pulse { animation-delay: -2.4s; }
+ .arch__edge:nth-child(6) .arch__pulse { animation-delay: -3s; }
+ @keyframes pulse { from { stroke-dashoffset: 214; } to { stroke-dashoffset: -214; } }
+ .arch__hub-glow { fill: rgba(var(--accent-rgb), .16); animation: hubGlow 3s ease-in-out infinite; }
+ @keyframes hubGlow { 50% { fill: rgba(var(--accent-rgb), .3); } }
+ .arch__hub-ring { fill: none; stroke: url(#agrad); stroke-width: 1.5; stroke-dasharray: 6 8; animation: ringSpin 30s linear infinite; transform-origin: center; transform-box: fill-box; }
+ @keyframes ringSpin { to { transform: rotate(360deg); } }
+ .arch__hub-fill { fill: var(--surface-solid); stroke: var(--border-strong); }
+ .arch__hub-t1 { font: 700 15px var(--font-display); fill: var(--fg); }
+ .arch__hub-t2 { font: 700 13px var(--font-display); fill: var(--accent); }
+ .arch__hub-t3 { font: 500 8px var(--font-mono); fill: var(--dim); letter-spacing: .06em; }
+ .arch__node-box { fill: var(--surface-solid); stroke: var(--border-strong); transition: stroke .3s, fill .3s; }
+ .arch__node:hover .arch__node-box { stroke: var(--accent); }
+ .arch__node-t { font: 600 13px var(--font-body); fill: var(--fg); }
+ @media (max-width: 900px) { .arch { grid-template-columns: 1fr; } }
+</style>
@@ -5,11 +5,14 @@
@@ -5,11 +5,14 @@
// compact — one line of title, two of summary — with the full case notes one click away.
// compact — one line of title, two of summary — with the full case notes one click away.
import { getCollection } from "astro:content";
import { getCollection } from "astro:content";
import profile from "../data/profile.json";
import profile from "../data/profile.json";
+import OpenVibeBento from "./OpenVibeBento.astro";
const all = (await getCollection("projects")).sort((a, b) => a.data.order - b.data.order);
const all = (await getCollection("projects")).sort((a, b) => a.data.order - b.data.order);
+// OpenVibe's own services are shown once, as the bento in group one — their case-study
+// pages (openvibe-* slugs) stay out of the other groups so nothing appears twice.
const byCats = (cats: string[], n: number) =>
const byCats = (cats: string[], n: number) =>
- all.filter((p) => cats.includes(p.data.category)).sort((a, b) => Number(b.data.featured) - Number(a.data.featured) || a.data.order - b.data.order).slice(0, n);
-const countCats = (cats: string[]) => all.filter((p) => cats.includes(p.data.category)).length;
+ all.filter((p) => cats.includes(p.data.category) && !p.slug.startsWith("openvibe-")).sort((a, b) => Number(b.data.featured) - Number(a.data.featured) || a.data.order - b.data.order).slice(0, n);
+const countCats = (cats: string[]) => all.filter((p) => cats.includes(p.data.category) && !p.slug.startsWith("openvibe-")).length;
const catLabel: Record<string, string> = {
const catLabel: Record<string, string> = {
openvibe: "OpenVibe", powerchat: "PowerChat", amazon: "Amazon", ai: "AI", infra: "Infra & ops",
openvibe: "OpenVibe", powerchat: "PowerChat", amazon: "Amazon", ai: "AI", infra: "Infra & ops",
@@ -25,7 +28,7 @@ const groups = [
@@ -25,7 +28,7 @@ const groups = [
key: "openvibe", tone: "a", anchor: "network", icon: "fa-solid fa-diagram-project",
key: "openvibe", tone: "a", anchor: "network", icon: "fa-solid fa-diagram-project",
title: "The OpenVibe network", blurb: "Seven live services behind one sign-in, all built and run by me since 2026.",
title: "The OpenVibe network", blurb: "Seven live services behind one sign-in, all built and run by me since 2026.",
count: profile.network.length, allHref: "/portfolio/?cat=openvibe,infra", allLabel: "All OpenVibe case notes",
count: profile.network.length, allHref: "/portfolio/?cat=openvibe,infra", allLabel: "All OpenVibe case notes",
- items: profile.network.map((n) => ({ href: n.url, external: true, image: n.image, icon: netIcon[n.key] ?? "fa-solid fa-cube", title: n.name, sub: n.role, text: n.blurb, meta: n.stat })),
+ items: [] as { href: string; external: boolean; image?: string | null; icon: string; title: string; sub: string; text: string; meta: string }[],
},
},
{
{
key: "powerchat", tone: "b", icon: "fa-solid fa-bolt",
key: "powerchat", tone: "b", icon: "fa-solid fa-bolt",
@@ -69,7 +72,8 @@ const groups = [
@@ -69,7 +72,8 @@ const groups = [
</div>
</div>
<a class="wk-group__all" href={g.allHref}><i class="fa-solid fa-grip" aria-hidden="true"></i> <span>{g.allLabel}</span> <i class="fa-solid fa-arrow-right wk-group__arrow" aria-hidden="true"></i></a>
<a class="wk-group__all" href={g.allHref}><i class="fa-solid fa-grip" aria-hidden="true"></i> <span>{g.allLabel}</span> <i class="fa-solid fa-arrow-right wk-group__arrow" aria-hidden="true"></i></a>
</header>
</header>
- <ul class="wk-grid" data-stagger>
+ {g.key === "openvibe" && <OpenVibeBento />}
+ <ul class="wk-grid" data-stagger hidden={g.items.length === 0}>
{g.items.map((it) => (
{g.items.map((it) => (
<li class={`wk-card ${it.image ? "wk-card--img" : ""}`}>
<li class={`wk-card ${it.image ? "wk-card--img" : ""}`}>
<a href={it.href} class="wk-card__link" target={it.external ? "_blank" : undefined} rel={it.external ? "noopener" : undefined}>
<a href={it.href} class="wk-card__link" target={it.external ? "_blank" : undefined} rel={it.external ? "noopener" : undefined}>