Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
1 parent
abf0a2f248
commit
d4faee3a1e
214 files changed
+9406
-1688
No files matched your search
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Rotating announcement bar above the sticky nav (it scrolls away with the page).
|
||||
* Light brand-tinted background; announcements auto-advance by SLIDING in one
|
||||
* direction only (a clone of the first slide follows the last one, and the track
|
||||
* snaps back without animation once the clone is fully in view). No manual
|
||||
* switch controls; hovering or focusing the bar pauses the rotation. Every
|
||||
* announcement is a link into the blog, with a small arrow marking it as
|
||||
* click-through.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { ArrowRightIcon } from "./icons";
|
||||
|
||||
const ROTATE_MS = 6000;
|
||||
|
||||
/** Both announcements point at blog posts (content/blog/<slug>.*.md). */
|
||||
const ITEMS = [
|
||||
{ key: "models", to: "/blog/introducing-penguinharness" },
|
||||
{ key: "fireworks", to: "/blog/fireworks-credits-amd" },
|
||||
] as const;
|
||||
|
||||
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||
}
|
||||
|
||||
export function AnnouncementBar() {
|
||||
const texts: Record<(typeof ITEMS)[number]["key"], string> = {
|
||||
models: S.announcement.models,
|
||||
fireworks: S.announcement.fireworks,
|
||||
};
|
||||
// pos runs 0..ITEMS.length where ITEMS.length is the clone of slide 0.
|
||||
const [pos, setPos] = useState(0);
|
||||
const [animate, setAnimate] = useState(true);
|
||||
// Focus pauses like hover does: tabIndex={-1} never blurs an already focused
|
||||
// link, so advancing past it would strand document focus inside an aria-hidden
|
||||
// slide translated out of the viewport, and WCAG 2.2.2 wants the rotation
|
||||
// stoppable without a pointer. The two are tracked apart so the pointer
|
||||
// leaving the bar cannot restart it while focus is still inside.
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const paused = hovered || focused;
|
||||
const [reduced, setReduced] = useState(prefersReducedMotion);
|
||||
|
||||
// Tracked live rather than read once, because the preference can be toggled
|
||||
// while the page is open and it decides how the rotation wraps around.
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||
setReduced(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
// styles.css kills every transition under reduced motion, so the clone's
|
||||
// transitionend never arrives and the snap-back below never runs: those
|
||||
// users wrap around the real slides instead and never land on the clone.
|
||||
const advance = (p: number) => {
|
||||
if (reduced) return (p + 1) % ITEMS.length;
|
||||
return p >= ITEMS.length ? p : p + 1;
|
||||
};
|
||||
const timer = setInterval(() => setPos(advance), ROTATE_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, reduced]);
|
||||
|
||||
// After snapping back to the real first slide, re-enable the transition on the
|
||||
// next frame pair so the jump itself never animates.
|
||||
useEffect(() => {
|
||||
if (animate) return;
|
||||
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setAnimate(true)));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [animate]);
|
||||
|
||||
const slides = [...ITEMS, ITEMS[0]!];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={S.announcement.label}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocusCapture={() => setFocused(true)}
|
||||
onBlurCapture={() => setFocused(false)}
|
||||
className="border-b border-brand-100 bg-brand-50 dark:border-brand-950 dark:bg-brand-950/50"
|
||||
>
|
||||
<div className="mx-auto h-9 max-w-6xl overflow-hidden px-4 sm:px-6">
|
||||
<div
|
||||
className={`flex h-full ${animate ? "transition-transform duration-500 ease-out" : ""}`}
|
||||
style={{ transform: `translateX(-${pos * 100}%)` }}
|
||||
onTransitionEnd={(e) => {
|
||||
if (e.target !== e.currentTarget || e.propertyName !== "transform") return;
|
||||
if (pos === ITEMS.length) {
|
||||
setAnimate(false);
|
||||
setPos(0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{slides.map((item, i) => (
|
||||
<div
|
||||
key={`${item.key}-${i}`}
|
||||
aria-hidden={i !== pos}
|
||||
className="flex w-full shrink-0 items-center justify-center"
|
||||
>
|
||||
<Link
|
||||
to={item.to}
|
||||
tabIndex={i === pos ? 0 : -1}
|
||||
className="inline-flex min-w-0 items-center gap-1.5 text-[13px] font-medium text-brand-800 underline-offset-2 hover:underline dark:text-brand-200"
|
||||
>
|
||||
<span className="truncate">{texts[item.key]}</span>
|
||||
<ArrowRightIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Demo video embed. The file itself is hosted in the sibling community repo (see
|
||||
* `demoVideoUrl` in lib/links.ts for why), so the important part here is that nothing
|
||||
* is fetched until the visitor asks: `preload="none"` plus a poster means a page view
|
||||
* costs one ~37 KB still, not a ~9 MB download, and the section still looks finished
|
||||
* before anyone presses play.
|
||||
*
|
||||
* Deliberately NOT wrapped in BrowserFrame: that chrome (with its localhost address bar)
|
||||
* says "this is the product's UI", which is true of a screenshot and of the case demos,
|
||||
* but not of a narrated slide deck. A plain framed surface instead.
|
||||
*/
|
||||
|
||||
export function DemoVideo({
|
||||
src,
|
||||
poster,
|
||||
label,
|
||||
caption,
|
||||
}: {
|
||||
src: string;
|
||||
poster: string;
|
||||
/** Accessible name — the surrounding copy is decorative to a screen reader. */
|
||||
label: string;
|
||||
caption?: string;
|
||||
}) {
|
||||
return (
|
||||
<figure className="mx-auto w-full max-w-4xl">
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950 shadow-sm dark:border-gray-800">
|
||||
<video
|
||||
src={src}
|
||||
poster={poster}
|
||||
controls
|
||||
preload="none"
|
||||
playsInline
|
||||
aria-label={label}
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
{caption && (
|
||||
<figcaption className="mt-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -315,6 +315,22 @@ export function ChevronDownIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalLinkIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
/**
|
||||
* Sticky top navigation: logo + section anchors + blog link + language/theme toggles +
|
||||
* GitHub. Desktop links share a sliding hover pill, while the selected section or route
|
||||
* keeps its own active background; section links route through "/#id" so the Router
|
||||
* and URL hash stay in sync while native smooth scrolling remains enabled. A disclosure
|
||||
* menu covers small screens.
|
||||
* keeps its own active background. On the home page the active section tracks the
|
||||
* LIVE scroll position (scroll-spy), so the highlight follows as you scroll; on other
|
||||
* routes it falls back to route state (e.g. Blog). Section links route through "/#id"
|
||||
* so the URL hash stays in sync on click. A disclosure menu covers small screens.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { S } from "../lib/strings";
|
||||
import { DOCS_URL, REPO_URL } from "../lib/links";
|
||||
import { getActiveNavItem, SECTION_IDS } from "../lib/nav-state";
|
||||
import type { ActiveNavItem, SectionId } from "../lib/nav-state";
|
||||
import { useScrollSpy } from "../lib/use-scroll-spy";
|
||||
import { GitHubIcon, MenuIcon, XIcon } from "./icons";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { LangToggle } from "./lang-toggle";
|
||||
|
||||
interface Indicator {
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
/** Stable empty list: keeps the spy idle away from the home page. */
|
||||
const NO_IDS: readonly string[] = [];
|
||||
|
||||
export function Nav() {
|
||||
const { pathname, hash } = useLocation();
|
||||
const activeItem = getActiveNavItem(pathname, hash);
|
||||
const onHome = pathname === "/";
|
||||
const spied = useScrollSpy(onHome ? SECTION_IDS : NO_IDS) as SectionId | null;
|
||||
const activeItem: ActiveNavItem = onHome ? spied : getActiveNavItem(pathname, hash);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [indicator, setIndicator] = useState<Indicator | null>(null);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
const pillVisible = useRef(false);
|
||||
|
||||
const sectionLabel: Record<(typeof SECTION_IDS)[number], string> = {
|
||||
highlights: S.nav.highlights,
|
||||
@@ -45,9 +49,33 @@ export function Nav() {
|
||||
const deskLinkCls = (active: boolean) =>
|
||||
`relative z-10 rounded-md px-2.5 py-1.5 text-sm transition-colors ${active ? activeLinkCls : deskInactiveLinkCls}`;
|
||||
|
||||
/**
|
||||
* The hover pill appears IN PLACE under the first link it lands on (position
|
||||
* jumps with only the fade animating), slides while moving between links, and
|
||||
* fades out where it is on leave — never sweeping in from the nav's edge.
|
||||
*/
|
||||
const slideTo = (e: MouseEvent<HTMLElement>) => {
|
||||
const el = e.currentTarget;
|
||||
setIndicator({ left: el.offsetLeft, width: el.offsetWidth });
|
||||
const pill = pillRef.current;
|
||||
if (!pill) return;
|
||||
if (!pillVisible.current) {
|
||||
pill.style.transitionProperty = "opacity";
|
||||
pill.style.left = `${el.offsetLeft}px`;
|
||||
pill.style.width = `${el.offsetWidth}px`;
|
||||
void pill.offsetWidth; // flush the jump before restoring the full transition
|
||||
pill.style.transitionProperty = "";
|
||||
pillVisible.current = true;
|
||||
} else {
|
||||
pill.style.left = `${el.offsetLeft}px`;
|
||||
pill.style.width = `${el.offsetWidth}px`;
|
||||
}
|
||||
pill.style.opacity = "1";
|
||||
};
|
||||
|
||||
const hidePill = () => {
|
||||
const pill = pillRef.current;
|
||||
if (pill) pill.style.opacity = "0";
|
||||
pillVisible.current = false;
|
||||
};
|
||||
|
||||
const desktopLinks = (
|
||||
@@ -122,17 +150,14 @@ export function Nav() {
|
||||
<nav
|
||||
className="relative ml-4 hidden items-center gap-0.5 md:flex"
|
||||
aria-label="Primary"
|
||||
onMouseLeave={() => setIndicator(null)}
|
||||
onMouseLeave={hidePill}
|
||||
>
|
||||
{/* Sliding hover pill: moves between links; active links retain their own background. */}
|
||||
{/* Sliding hover pill: appears in place, slides between links, fades out in place; active links retain their own background. */}
|
||||
<span
|
||||
ref={pillRef}
|
||||
aria-hidden="true"
|
||||
className="absolute top-1/2 h-8 -translate-y-1/2 rounded-md bg-gray-100 transition-[left,width,opacity] duration-200 ease-out dark:bg-gray-800"
|
||||
style={{
|
||||
left: indicator?.left ?? 0,
|
||||
width: indicator?.width ?? 0,
|
||||
opacity: indicator ? 1 : 0,
|
||||
}}
|
||||
style={{ left: 0, width: 0, opacity: 0 }}
|
||||
/>
|
||||
{desktopLinks}
|
||||
</nav>
|
||||
|
||||
Reference in new issue
Block a user