Share language and theme across the sites; prepare the 0.1.0 release (#13)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Opus 4.8 authored and GitHub committed 2026-07-21 18:00:11 +08:00
1 parent d4faee3a1e
commit 0e134e9c6c
27 files changed
+206 -36

No files matched your search

+1 -1
View File
@@ -2,5 +2,5 @@
One brief line per release. Per-release detail lives in [`changelog/<version>/`](changelog/). One brief line per release. Per-release detail lives in [`changelog/<version>/`](changelog/).
- **0.0.2** — unreleased. ([details](changelog/0.0.2/README.md)) - **0.1.0** — 2026-07-21. First feature release: the Web App, landing site and docs, the model catalog, and the self-improvement skills. ([details](changelog/0.1.0/README.md))
- **0.0.1** — 2026-07-19. First tagged release; changelog history starts after this tag. - **0.0.1** — 2026-07-19. First tagged release; changelog history starts after this tag.
File renamed without changes.
@@ -41,7 +41,7 @@ one-line-per-change index, and detail files live inside the folder (mirrors agen
## Details ## Details
- `changelog/<version>/README.md` is the release summary: one line per change with its - `changelog/<version>/README.md` is the release summary: one line per change with its
title and one-sentence summary, linking the detail file relatively; `0.0.2/README.md` title and one-sentence summary, linking the detail file relatively; `0.1.0/README.md`
absorbs the section previously kept in the top-level index. absorbs the section previously kept in the top-level index.
- `changelog/README.md` now documents only the layout and the entry conventions (no - `changelog/README.md` now documents only the layout and the entry conventions (no
per-file listing). per-file listing).
File renamed without changes.
@@ -0,0 +1,11 @@
# Language and theme carry across the landing page and the docs site
The two sites are separate SPAs deployed to one origin — penguin.ooo and penguin.ooo/docs/ — but each persisted its preferences under its own `localStorage` key (`penguin-landing.*` and `penguin-docs.*`). Picking dark mode or Chinese on one and clicking through to the other therefore dropped the visitor back to the system default, which read as the setting being ignored.
- Both sites now read and write one shared pair of keys, `penguin-site.theme` and `penguin-site.lang`, via a small `state/site-prefs.ts` module duplicated in each package (they share no package, exactly as `theme.tsx` and `locale.tsx` already are). The keys must stay identical on both sides; if they drift, the sync stops silently.
- The retired per-site keys are still read as a fallback, so a returning visitor keeps the choice they made before the change instead of being reset once.
- A `storage` listener covers the case where both sites are open at once: switching theme or language in one tab now updates the other without a reload. The site-to-site hop is a full page load and is served by the read on mount.
- The docs site's pre-paint theme script reads the shared key too, and the landing page — which had no such script — gained the same one. Without it, arriving at the landing page from a dark docs page flashed a white background before React booted.
- Reading `localStorage` is wrapped throughout: it throws outright in cookie-blocked and partitioned contexts, and a preference is not worth taking the page down for.
Verified against the assembled site (landing at `/`, docs under `/docs/`, one origin): a dark + Chinese choice made on the landing page survives the hop to the docs site and back, a change made on the docs side propagates the other way, and a profile carrying only the retired `penguin-docs.*` keys still renders dark and Chinese.
@@ -1,6 +1,8 @@
# Version 0.0.2 # Version 0.1.0
Unreleased. Released on 2026-07-21.
- [2026-07-21] Language and theme are shared between the landing page and the docs site, so a choice made on one carries across to the other. ([details](2026-07-21-site-preferences-sync.md))
- [2026-07-21] A model is always referenced by an explicit `(provider, model_id)` pair — the provider is never inferred, guessed, or defaulted — together with the fixes from a full review of the release branch, refreshed benchmark results, and the demo videos on the landing page. ([details](2026-07-21-model-reference-and-review-fixes.md)) - [2026-07-21] A model is always referenced by an explicit `(provider, model_id)` pair — the provider is never inferred, guessed, or defaulted — together with the fixes from a full review of the release branch, refreshed benchmark results, and the demo videos on the landing page. ([details](2026-07-21-model-reference-and-review-fixes.md))
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "penguin-harness", "name": "penguin-harness",
"version": "0.0.1", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "PenguinHarness — TypeScript AI Agent (SDK + CLI).", "description": "PenguinHarness — TypeScript AI Agent (SDK + CLI).",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@prismshadow/penguin-cli", "name": "@prismshadow/penguin-cli",
"version": "0.0.1", "version": "0.1.0",
"type": "module", "type": "module",
"description": "PenguinHarness CLI: interactive REPL and single-task runner over @prismshadow/penguin-core.", "description": "PenguinHarness CLI: interactive REPL and single-task runner over @prismshadow/penguin-core.",
"license": "Apache-2.0", "license": "Apache-2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@prismshadow/penguin-core", "name": "@prismshadow/penguin-core",
"version": "0.0.1", "version": "0.1.0",
"type": "module", "type": "module",
"description": "PenguinHarness core SDK: context_engine, OmniMessage protocol, LLM/Environment interfaces.", "description": "PenguinHarness core SDK: context_engine, OmniMessage protocol, LLM/Environment interfaces.",
"license": "Apache-2.0", "license": "Apache-2.0",
+1 -1
View File
@@ -48,4 +48,4 @@ export { Agent, createAgent } from "./agent.js";
export type { CreateAgentOptions, CreateSessionOptions, ResumeSessionOptions } from "./agent.js"; export type { CreateAgentOptions, CreateSessionOptions, ResumeSessionOptions } from "./agent.js";
/** SDK version number. */ /** SDK version number. */
export const VERSION = "0.0.1"; export const VERSION = "0.1.0";
+7 -3
View File
@@ -18,11 +18,15 @@
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<link rel="icon" type="image/svg+xml" href="./penguin-logo.svg" /> <link rel="icon" type="image/svg+xml" href="./penguin-logo.svg" />
<title>PenguinHarness Docs</title> <title>PenguinHarness Docs</title>
<!-- Apply the stored/system theme before first paint so text-heavy doc pages <!-- Apply the stored/system theme before first paint so the page doesn't flash the
don't flash the wrong background while React boots. --> wrong background while React boots. Reads the key shared with the sibling site
(see src/state/site-prefs.ts), falling back to the retired per-site keys. -->
<script> <script>
try { try {
var m = localStorage.getItem("penguin-docs.theme"); var m =
localStorage.getItem("penguin-site.theme") ||
localStorage.getItem("penguin-landing.theme") ||
localStorage.getItem("penguin-docs.theme");
if (m === "dark" || (m !== "light" && matchMedia("(prefers-color-scheme: dark)").matches)) if (m === "dark" || (m !== "light" && matchMedia("(prefers-color-scheme: dark)").matches))
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
} catch {} } catch {}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@prismshadow/penguin-docs", "name": "@prismshadow/penguin-docs",
"version": "0.0.1", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "PenguinHarness documentation site (React + Vite + Tailwind CSS): bilingual zh/en Markdown pages with light/dark themes and per-page Copy Markdown, deployed to GitHub Pages under /docs/ next to the landing page.", "description": "PenguinHarness documentation site (React + Vite + Tailwind CSS): bilingual zh/en Markdown pages with light/dark themes and per-page Copy Markdown, deployed to GitHub Pages under /docs/ next to the landing page.",
+7 -5
View File
@@ -14,13 +14,14 @@ import {
useState, useState,
} from "react"; } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { LANG_KEY, readPref, subscribePref, writePref } from "./site-prefs";
import { setActiveStrings, zh } from "../lib/strings"; import { setActiveStrings, zh } from "../lib/strings";
import { en } from "../lib/strings-en"; import { en } from "../lib/strings-en";
export type LangPref = "zh" | "en" | "system"; export type LangPref = "zh" | "en" | "system";
export type Locale = "zh" | "en"; export type Locale = "zh" | "en";
const STORAGE_KEY = "penguin-docs.lang"; const LANG_PREFS = ["zh", "en", "system"] as const;
interface LocaleContextValue { interface LocaleContextValue {
lang: LangPref; lang: LangPref;
@@ -44,9 +45,7 @@ function resolve(lang: LangPref): Locale {
} }
function initialLang(): LangPref { function initialLang(): LangPref {
const stored = localStorage.getItem(STORAGE_KEY); return readPref<LangPref>(LANG_KEY, LANG_PREFS) ?? "system";
if (stored === "zh" || stored === "en" || stored === "system") return stored;
return "system";
} }
export function LocaleProvider({ children }: { children: ReactNode }) { export function LocaleProvider({ children }: { children: ReactNode }) {
@@ -71,10 +70,13 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
}, [lang]); }, [lang]);
const setLang = useCallback((next: LangPref) => { const setLang = useCallback((next: LangPref) => {
localStorage.setItem(STORAGE_KEY, next); writePref(LANG_KEY, next);
setLangState(next); setLangState(next);
}, []); }, []);
// The sibling site (landing <-> docs) writing the shared key in another tab.
useEffect(() => subscribePref<LangPref>(LANG_KEY, LANG_PREFS, setLangState), []);
return ( return (
<LocaleContext.Provider value={{ lang, locale, setLang }}>{children}</LocaleContext.Provider> <LocaleContext.Provider value={{ lang, locale, setLang }}>{children}</LocaleContext.Provider>
); );
+66
View File
@@ -0,0 +1,66 @@
/**
* Preferences shared by the landing page and the docs site.
*
* The two are separate SPAs but ship to one origin (penguin.ooo and penguin.ooo/docs/),
* so a single localStorage key is all it takes for a language or theme choice to survive
* the hop between them. They previously wrote `penguin-landing.*` and `penguin-docs.*`,
* which is why picking dark mode on the landing page and clicking through to the docs
* dropped you back into light.
*
* This module is duplicated verbatim in packages/landing (as theme.tsx and locale.tsx
* already are — the two sites share no package). The KEYS below must stay identical on
* both sides; if they drift, the sync silently stops working with nothing failing.
*/
export const THEME_KEY = "penguin-site.theme";
export const LANG_KEY = "penguin-site.lang";
/** Pre-unification keys, still read once so an existing visitor keeps their choice. */
const LEGACY_KEYS: Record<string, readonly string[]> = {
[THEME_KEY]: ["penguin-landing.theme", "penguin-docs.theme"],
[LANG_KEY]: ["penguin-landing.lang", "penguin-docs.lang"],
};
/**
* Reads a stored preference, falling back to the retired per-site keys. Everything is
* wrapped: localStorage throws outright in a cookie-blocked or partitioned context, and
* a preference is never worth taking the page down for.
*/
export function readPref<T extends string>(key: string, allowed: readonly T[]): T | null {
try {
for (const k of [key, ...(LEGACY_KEYS[key] ?? [])]) {
const stored = localStorage.getItem(k);
if (stored && (allowed as readonly string[]).includes(stored)) return stored as T;
}
} catch {
// Storage unavailable — fall through to the system default.
}
return null;
}
export function writePref(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Non-persistent session: the choice still applies for this page.
}
}
/**
* Live sync for the both-tabs-open case. The storage event fires in every *other* tab on
* the origin, so switching theme on the landing page updates an already-open docs tab
* without a reload. Navigating between the two sites is a full page load and is covered
* by the read on mount instead.
*/
export function subscribePref<T extends string>(
key: string,
allowed: readonly T[],
onChange: (value: T) => void,
): () => void {
const handler = (e: StorageEvent) => {
if (e.key !== key || e.newValue === null) return;
if ((allowed as readonly string[]).includes(e.newValue)) onChange(e.newValue as T);
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}
+7 -5
View File
@@ -6,10 +6,11 @@
*/ */
import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { THEME_KEY, readPref, subscribePref, writePref } from "./site-prefs";
export type ThemeMode = "light" | "dark" | "system"; export type ThemeMode = "light" | "dark" | "system";
const MODE_KEY = "penguin-docs.theme"; const THEME_MODES = ["light", "dark", "system"] as const;
interface ThemeContextValue { interface ThemeContextValue {
mode: ThemeMode; mode: ThemeMode;
@@ -21,9 +22,7 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null); const ThemeContext = createContext<ThemeContextValue | null>(null);
function initialMode(): ThemeMode { function initialMode(): ThemeMode {
const stored = localStorage.getItem(MODE_KEY); return readPref<ThemeMode>(THEME_KEY, THEME_MODES) ?? "system";
if (stored === "light" || stored === "dark" || stored === "system") return stored;
return "system";
} }
function systemDark(): boolean { function systemDark(): boolean {
@@ -50,10 +49,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [mode]); }, [mode]);
const setMode = useCallback((next: ThemeMode) => { const setMode = useCallback((next: ThemeMode) => {
localStorage.setItem(MODE_KEY, next); writePref(THEME_KEY, next);
setModeState(next); setModeState(next);
}, []); }, []);
// The sibling site (landing <-> docs) writing the shared key in another tab.
useEffect(() => subscribePref<ThemeMode>(THEME_KEY, THEME_MODES, setModeState), []);
return <ThemeContext.Provider value={{ mode, dark, setMode }}>{children}</ThemeContext.Provider>; return <ThemeContext.Provider value={{ mode, dark, setMode }}>{children}</ThemeContext.Provider>;
} }
+13
View File
@@ -18,6 +18,19 @@
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<link rel="icon" type="image/svg+xml" href="./penguin-logo.svg" /> <link rel="icon" type="image/svg+xml" href="./penguin-logo.svg" />
<title>PenguinHarness — Efficient Self-Improving Harness for Everyone</title> <title>PenguinHarness — Efficient Self-Improving Harness for Everyone</title>
<!-- Apply the stored/system theme before first paint so the page doesn't flash the
wrong background while React boots. Reads the key shared with the sibling site
(see src/state/site-prefs.ts), falling back to the retired per-site keys. -->
<script>
try {
var m =
localStorage.getItem("penguin-site.theme") ||
localStorage.getItem("penguin-landing.theme") ||
localStorage.getItem("penguin-docs.theme");
if (m === "dark" || (m !== "light" && matchMedia("(prefers-color-scheme: dark)").matches))
document.documentElement.classList.add("dark");
} catch {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@prismshadow/penguin-landing", "name": "@prismshadow/penguin-landing",
"version": "0.0.1", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "PenguinHarness landing page (React + Vite + Tailwind CSS): multilingual, light/dark themes, benchmark showcase, and a local Markdown blog, deployed to GitHub Pages via GitHub Actions.", "description": "PenguinHarness landing page (React + Vite + Tailwind CSS): multilingual, light/dark themes, benchmark showcase, and a local Markdown blog, deployed to GitHub Pages via GitHub Actions.",
+7 -5
View File
@@ -14,13 +14,14 @@ import {
useState, useState,
} from "react"; } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { LANG_KEY, readPref, subscribePref, writePref } from "./site-prefs";
import { setActiveStrings, zh } from "../lib/strings"; import { setActiveStrings, zh } from "../lib/strings";
import { en } from "../lib/strings-en"; import { en } from "../lib/strings-en";
export type LangPref = "zh" | "en" | "system"; export type LangPref = "zh" | "en" | "system";
export type Locale = "zh" | "en"; export type Locale = "zh" | "en";
const STORAGE_KEY = "penguin-landing.lang"; const LANG_PREFS = ["zh", "en", "system"] as const;
interface LocaleContextValue { interface LocaleContextValue {
lang: LangPref; lang: LangPref;
@@ -44,9 +45,7 @@ function resolve(lang: LangPref): Locale {
} }
function initialLang(): LangPref { function initialLang(): LangPref {
const stored = localStorage.getItem(STORAGE_KEY); return readPref<LangPref>(LANG_KEY, LANG_PREFS) ?? "system";
if (stored === "zh" || stored === "en" || stored === "system") return stored;
return "system";
} }
export function LocaleProvider({ children }: { children: ReactNode }) { export function LocaleProvider({ children }: { children: ReactNode }) {
@@ -71,10 +70,13 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
}, [lang]); }, [lang]);
const setLang = useCallback((next: LangPref) => { const setLang = useCallback((next: LangPref) => {
localStorage.setItem(STORAGE_KEY, next); writePref(LANG_KEY, next);
setLangState(next); setLangState(next);
}, []); }, []);
// The sibling site (landing <-> docs) writing the shared key in another tab.
useEffect(() => subscribePref<LangPref>(LANG_KEY, LANG_PREFS, setLangState), []);
return ( return (
<LocaleContext.Provider value={{ lang, locale, setLang }}>{children}</LocaleContext.Provider> <LocaleContext.Provider value={{ lang, locale, setLang }}>{children}</LocaleContext.Provider>
); );
+66
View File
@@ -0,0 +1,66 @@
/**
* Preferences shared by the landing page and the docs site.
*
* The two are separate SPAs but ship to one origin (penguin.ooo and penguin.ooo/docs/),
* so a single localStorage key is all it takes for a language or theme choice to survive
* the hop between them. They previously wrote `penguin-landing.*` and `penguin-docs.*`,
* which is why picking dark mode on the landing page and clicking through to the docs
* dropped you back into light.
*
* This module is duplicated verbatim in packages/docs (as theme.tsx and locale.tsx
* already are — the two sites share no package). The KEYS below must stay identical on
* both sides; if they drift, the sync silently stops working with nothing failing.
*/
export const THEME_KEY = "penguin-site.theme";
export const LANG_KEY = "penguin-site.lang";
/** Pre-unification keys, still read once so an existing visitor keeps their choice. */
const LEGACY_KEYS: Record<string, readonly string[]> = {
[THEME_KEY]: ["penguin-landing.theme", "penguin-docs.theme"],
[LANG_KEY]: ["penguin-landing.lang", "penguin-docs.lang"],
};
/**
* Reads a stored preference, falling back to the retired per-site keys. Everything is
* wrapped: localStorage throws outright in a cookie-blocked or partitioned context, and
* a preference is never worth taking the page down for.
*/
export function readPref<T extends string>(key: string, allowed: readonly T[]): T | null {
try {
for (const k of [key, ...(LEGACY_KEYS[key] ?? [])]) {
const stored = localStorage.getItem(k);
if (stored && (allowed as readonly string[]).includes(stored)) return stored as T;
}
} catch {
// Storage unavailable — fall through to the system default.
}
return null;
}
export function writePref(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Non-persistent session: the choice still applies for this page.
}
}
/**
* Live sync for the both-tabs-open case. The storage event fires in every *other* tab on
* the origin, so switching theme on the landing page updates an already-open docs tab
* without a reload. Navigating between the two sites is a full page load and is covered
* by the read on mount instead.
*/
export function subscribePref<T extends string>(
key: string,
allowed: readonly T[],
onChange: (value: T) => void,
): () => void {
const handler = (e: StorageEvent) => {
if (e.key !== key || e.newValue === null) return;
if ((allowed as readonly string[]).includes(e.newValue)) onChange(e.newValue as T);
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}
+7 -5
View File
@@ -5,10 +5,11 @@
*/ */
import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { THEME_KEY, readPref, subscribePref, writePref } from "./site-prefs";
export type ThemeMode = "light" | "dark" | "system"; export type ThemeMode = "light" | "dark" | "system";
const MODE_KEY = "penguin-landing.theme"; const THEME_MODES = ["light", "dark", "system"] as const;
interface ThemeContextValue { interface ThemeContextValue {
mode: ThemeMode; mode: ThemeMode;
@@ -20,9 +21,7 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null); const ThemeContext = createContext<ThemeContextValue | null>(null);
function initialMode(): ThemeMode { function initialMode(): ThemeMode {
const stored = localStorage.getItem(MODE_KEY); return readPref<ThemeMode>(THEME_KEY, THEME_MODES) ?? "system";
if (stored === "light" || stored === "dark" || stored === "system") return stored;
return "system";
} }
function systemDark(): boolean { function systemDark(): boolean {
@@ -49,10 +48,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [mode]); }, [mode]);
const setMode = useCallback((next: ThemeMode) => { const setMode = useCallback((next: ThemeMode) => {
localStorage.setItem(MODE_KEY, next); writePref(THEME_KEY, next);
setModeState(next); setModeState(next);
}, []); }, []);
// The sibling site (landing <-> docs) writing the shared key in another tab.
useEffect(() => subscribePref<ThemeMode>(THEME_KEY, THEME_MODES, setModeState), []);
return <ThemeContext.Provider value={{ mode, dark, setMode }}>{children}</ThemeContext.Provider>; return <ThemeContext.Provider value={{ mode, dark, setMode }}>{children}</ThemeContext.Provider>;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@prismshadow/penguin-server", "name": "@prismshadow/penguin-server",
"version": "0.0.1", "version": "0.1.0",
"type": "module", "type": "module",
"description": "PenguinHarness web server: multi-user authentication and authorization, Session execution with SSE streaming, and usage accounting, built on @prismshadow/penguin-core.", "description": "PenguinHarness web server: multi-user authentication and authorization, Session execution with SSE streaming, and usage accounting, built on @prismshadow/penguin-core.",
"license": "Apache-2.0", "license": "Apache-2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@prismshadow/penguin-skills", "name": "@prismshadow/penguin-skills",
"version": "0.0.1", "version": "0.1.0",
"type": "module", "type": "module",
"description": "PenguinHarness skill library: built-in SKILL.md documents and skill groups, decoupled from core.", "description": "PenguinHarness skill library: built-in SKILL.md documents and skill groups, decoupled from core.",
"license": "Apache-2.0", "license": "Apache-2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@prismshadow/penguin-web", "name": "@prismshadow/penguin-web",
"version": "0.0.1", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "PenguinHarness web frontend SPA: React + Vite + Tailwind CSS, rendering conversations streamed over the OmniMessage protocol, plus Agent configuration, model configuration, usage stats, and Traces.", "description": "PenguinHarness web frontend SPA: React + Vite + Tailwind CSS, rendering conversations streamed over the OmniMessage protocol, plus Agent configuration, model configuration, usage stats, and Traces.",