fix(web): tighten entry-tab layout and design-system showcase color picker (#412)

This commit is contained in:
Tom Huang 2026-05-04 13:49:41 +08:00 committed by GitHub
parent 6c2a8ba09f
commit 5a09e39f1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 411 additions and 67 deletions

View file

@ -18,24 +18,80 @@ export function renderDesignSystemShowcase(id, raw) {
const colors = extractColors(raw);
const fonts = extractFonts(raw);
// Hints are matched against each color's role description (the prose that
// follows the name in DESIGN.md, e.g. "Primary background.") first, then
// against the color name. We use word-boundary matching so descriptive
// names like "Cardinal Red" don't accidentally satisfy a "card" hint and
// "Gem Pink" doesn't satisfy "ink".
// Hint ordering matters: more specific phrases come first so a system
// with both "Primary background" and "Page background in light mode" (e.g.
// Linear's marketing black + light-mode escape hatch) lands on the
// dominant role rather than the light-mode subtitle. We drop 'page
// background' from the bg hints entirely because in practice it almost
// always belongs to a secondary, light-mode-only entry.
const bg =
pickColor(colors, ['page background', 'background', 'canvas', 'paper', 'bg ', 'page bg'])
pickColor(colors, ['primary background', 'background', 'canvas', 'paper'])
?? firstLightish(colors)
?? '#ffffff';
// Exclude `bg` so a token whose hex matches the page background (for
// example Warp's "Warm Parchment" doubling as primary text *and* the
// firstLightish bg fallback) doesn't make body copy invisible.
const fg =
pickColor(colors, ['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
pickColor(
colors,
[
'primary text',
'body text',
'foreground',
'ink primary',
'heading',
'ink',
'graphite',
'navy',
],
[bg],
)
?? pickReadableForeground(bg)
?? '#0a0a0a';
const accent =
pickColor(colors, ['primary brand', 'brand primary', 'primary', 'brand', 'accent'])
?? firstNonNeutral(colors)
pickColor(colors, [
'brand primary',
'primary brand',
'primary cta',
'gradient origin',
'brand mark',
'brand color',
])
?? firstNonNeutral(colors, [bg, fg])
?? '#2f6feb';
const accent2 =
pickColor(colors, ['secondary', 'tertiary', 'highlight', 'support'])
?? secondNonNeutral(colors, accent)
pickColor(colors, [
'brand secondary',
'secondary brand',
'gradient terminus',
'tertiary brand',
'tertiary',
'highlight',
])
?? secondNonNeutral(colors, [accent, bg, fg])
?? accent;
const muted = pickColor(colors, ['muted', 'subtle', 'caption', 'meta', 'neutral']) ?? '#666666';
const border = pickColor(colors, ['border', 'divider', 'rule', 'stroke']) ?? '#e6e6e6';
const muted =
pickColor(colors, ['secondary text', 'caption', 'metadata', 'placeholder', 'muted', 'subtle'])
?? '#666666';
const border =
pickColor(colors, ['border', 'divider', 'hairline', 'rule', 'stroke'])
?? '#e6e6e6';
const surface =
pickColor(colors, ['surface', 'card', 'background-secondary', 'panel', 'elevated'])
pickColor(colors, [
'secondary surface',
'section break',
'sidebar',
'surface subtle',
'surface',
'panel',
'elevated',
'card surface',
])
?? mixSurface(bg);
const display = fonts.display ?? fonts.heading ?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
@ -598,23 +654,75 @@ function extractSubtitle(raw) {
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractColors(raw) {
export function extractColors(raw) {
const colors = [];
const seen = new Set();
function push(name, value) {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
function push(name, value, role) {
const cleanName = String(name).replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
if (!cleanName || cleanName.length > 60) return;
const v = normalizeHex(value);
const key = `${cleanName.toLowerCase()}|${v}`;
if (seen.has(key)) return;
const cleanRole = String(role || '')
.replace(/[`*_]+/g, '')
.replace(/\s+/g, ' ')
.trim()
.replace(/[.;]+$/, '');
if (seen.has(key)) {
// Already recorded — but if this occurrence carries a richer role
// description, upgrade the stored entry so role-based lookups don't
// fall back to the bare name.
if (cleanRole) {
const existing = colors.find(
(c) => c.name.toLowerCase() === cleanName.toLowerCase() && c.value === v,
);
if (existing && (!existing.role || cleanRole.length > existing.role.length)) {
existing.role = cleanRole;
}
}
return;
}
seen.add(key);
colors.push({ name: cleanName, value: v });
colors.push({ name: cleanName, value: v, role: cleanRole });
}
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*\**\s*[:]\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
// Process the file line-by-line so multi-hex entries like Linear's
// `**Marketing Black** (\`#010102\` / \`#08090a\`): role` don't confuse a
// single global regex. We extract three pieces from each candidate line:
// - the bold (or list-prefixed) name
// - the FIRST hex on the line
// - everything after the first `:` that follows the hex (the role)
for (const rawLine of raw.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
// Pattern A: **Name** … #hex … : role description
const bold = /\*\*([A-Za-z][A-Za-z0-9 /&()+_'-]{1,40}?)\*\*([^\n]+)/.exec(line);
if (bold) {
const rest = bold[2] ?? '';
const hex = /#[0-9a-fA-F]{3,8}\b/.exec(rest);
if (hex) {
const after = rest.slice((hex.index ?? 0) + hex[0].length);
const colonIdx = after.search(/[:]/);
const role = colonIdx >= 0 ? after.slice(colonIdx + 1).trim() : '';
push(bold[1], hex[0], role);
continue;
}
}
// Pattern B: list-prefixed spec lines like
// "- Background: `#7d2ae8`" inside a ### Buttons block.
// Also handles the `- **Name:** \`#hex\`` shape (colon inside the bold
// wrapper) used by agentic/warm-editorial: the optional `\*{0,2}` slots
// before the name and after the colon let us absorb the surrounding
// `**` markers without needing a third pattern.
// Use the name itself as the role so lookups can still see "Background"
// and "Text" labels.
const spec = /^[\s>*-]*\*{0,2}([A-Za-z][^:*\n]{1,40}?)\*{0,2}\s*[:]\s*\*{0,2}\s*`?(#[0-9a-fA-F]{3,8})/.exec(line);
if (spec) {
push(spec[1], spec[2], spec[1]);
}
}
return colors;
}
@ -634,45 +742,88 @@ function extractFonts(raw) {
return out;
}
function pickColor(colors, hints) {
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Match a hint as a whole word inside `text` (case-insensitive). We use word
// boundaries so descriptive color names like "Cardinal Red" don't satisfy a
// "card" hint, and "Gem Pink" doesn't satisfy "ink" — both real bugs the
// substring-based version produced for the Duolingo and Canva showcases.
function matchesHint(text, hint) {
if (!text) return false;
const needle = hint.toLowerCase().trim();
if (!needle) return false;
const re = new RegExp(`\\b${escapeRegex(needle)}\\b`, 'i');
return re.test(text);
}
function pickColor(colors, hints, exclude = []) {
// Two-pass lookup: each hint is first checked against every color's role
// description (the prose authors use to explain how the color is used)
// and only then against the bare name. This ensures a `**Snow** … Primary
// background.` line is recognised as the page background even though the
// name "Snow" doesn't contain the word "background".
// `exclude` skips colors whose hex equals an already-chosen role (e.g.
// pass `[bg]` when picking `fg`) so two roles can't collapse to the same
// hex and erase contrast.
const blocked = new Set(
exclude
.map((v) => (v == null ? '' : String(v).toLowerCase()))
.filter((v) => v.length > 0),
);
const isAllowed = (c) => !blocked.has(c.value.toLowerCase());
for (const hint of hints) {
const needle = hint.toLowerCase();
const found = colors.find((c) => c.name.toLowerCase().includes(needle));
if (found) return found.value;
const byRole = colors.find((c) => isAllowed(c) && matchesHint(c.role, hint));
if (byRole) return byRole.value;
const byName = colors.find((c) => isAllowed(c) && matchesHint(c.name, hint));
if (byName) return byName.value;
}
return null;
}
function firstNonNeutral(colors) {
function colorSaturation(hex) {
const v = String(hex).replace('#', '').toLowerCase();
if (v.length !== 6) return 0;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
return max === 0 ? 0 : (max - min) / max;
}
function colorLuminance(hex) {
const v = String(hex).replace('#', '').toLowerCase();
if (v.length !== 6) return 0.5;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
function firstLightish(colors) {
for (const c of colors) {
const v = c.value.replace('#', '').toLowerCase();
if (v.length !== 6) continue;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const sat = max === 0 ? 0 : (max - min) / max;
if (sat > 0.25) return c.value;
if (colorSaturation(c.value) > 0.15) continue;
if (colorLuminance(c.value) >= 0.92) return c.value;
}
return null;
}
function secondNonNeutral(colors, exclude) {
let seen = false;
function firstNonNeutral(colors, exclude = []) {
const set = new Set(exclude.map((v) => String(v || '').toLowerCase()));
for (const c of colors) {
const v = c.value.replace('#', '').toLowerCase();
if (v.length !== 6) continue;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const sat = max === 0 ? 0 : (max - min) / max;
if (sat > 0.25) {
if (c.value === exclude || (!seen)) { seen = true; continue; }
return c.value;
}
if (set.has(c.value.toLowerCase())) continue;
if (colorSaturation(c.value) > 0.25) return c.value;
}
return null;
}
function secondNonNeutral(colors, exclude = []) {
const set = new Set(exclude.map((v) => String(v || '').toLowerCase()));
for (const c of colors) {
if (set.has(c.value.toLowerCase())) continue;
if (colorSaturation(c.value) > 0.25) return c.value;
}
return null;
}

View file

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { extractColors } from '../src/design-system-showcase.js';
type Color = { name: string; value: string; role: string };
function findColor(colors: Color[], name: string): Color | undefined {
return colors.find((c) => c.name.toLowerCase() === name.toLowerCase());
}
describe('extractColors / Pattern B', () => {
it('parses `- **Name:** `#hex`` (colon inside bold) — agentic / warm-editorial shape', () => {
const md = [
'## 2. Color',
'',
'- **Primary:** `#FF5701` — Token from style foundations.',
'- **Secondary:** `#F6F6F1` — Token from style foundations.',
'- **Surface:** `#FFFFFF` — Token from style foundations.',
'- **Text:** `#111827` — Token from style foundations.',
].join('\n');
const colors = extractColors(md);
expect(findColor(colors, 'Primary')?.value).toBe('#ff5701');
expect(findColor(colors, 'Secondary')?.value).toBe('#f6f6f1');
expect(findColor(colors, 'Surface')?.value).toBe('#ffffff');
expect(findColor(colors, 'Text')?.value).toBe('#111827');
});
it('parses `- Name: `#hex`` bare list shape', () => {
const md = [
'### Buttons',
'',
'- Background: `#7d2ae8`',
'- Text: `#ffffff`',
].join('\n');
const colors = extractColors(md);
expect(findColor(colors, 'Background')?.value).toBe('#7d2ae8');
expect(findColor(colors, 'Text')?.value).toBe('#ffffff');
});
it('parses `**Name** `#hex`: role` (Duolingo / Canva shape with role suffix)', () => {
const md = [
'## Color',
'',
'- **Owl Green** `#58CC02`: Primary brand and CTA.',
'- **Feather Blue** `#1CB0F6`: Secondary accent.',
].join('\n');
const colors = extractColors(md);
const owl = findColor(colors, 'Owl Green');
expect(owl?.value).toBe('#58cc02');
expect(owl?.role).toContain('Primary brand');
const feather = findColor(colors, 'Feather Blue');
expect(feather?.value).toBe('#1cb0f6');
expect(feather?.role).toContain('Secondary accent');
});
it('extracts the first hex from multi-hex `**Name** (`#a` / `#b`): role` (Linear shape)', () => {
const md = '- **Marketing Black** (`#010102` / `#08090a`): Marketing surface and dark canvas.';
const colors = extractColors(md);
const black = findColor(colors, 'Marketing Black');
expect(black?.value).toBe('#010102');
expect(black?.role).toContain('Marketing surface');
});
});

View file

@ -9,6 +9,7 @@ import { fetchSkillExample } from '../providers/registry';
import { exportAsHtml, exportAsPdf, exportAsZip } from '../runtime/exports';
import { buildSrcdoc } from '../runtime/srcdoc';
import type { SkillSummary, Surface } from '../types';
import { Icon } from './Icon';
import { PreviewModal } from './PreviewModal';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
@ -108,6 +109,10 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
const [modeFilter, setModeFilter] = useState<ModeFilter>('all');
const [scenarioFilter, setScenarioFilter] = useState<ScenarioFilter>('all');
// Free-text search filters by skill name + description + prompt so users
// can find a known example by typing any associated word ("airbnb",
// "wireframe", "deck") without having to click through filter pills first.
const [search, setSearch] = useState('');
const [previewSkillId, setPreviewSkillId] = useState<string | null>(null);
const loadPreview = useCallback(
@ -177,10 +182,15 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
}, [scenarioCounts]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const matched = skills.filter((s) => {
if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) return false;
if (scenarioFilter === 'all') return true;
return (s.scenario || 'general') === scenarioFilter;
if (scenarioFilter !== 'all' && (s.scenario || 'general') !== scenarioFilter) return false;
if (!q) return true;
const desc = localizeSkillDescription(locale, s);
const prompt = localizeSkillPrompt(locale, s) || '';
const haystack = `${s.name} ${desc} ${prompt} ${s.scenario ?? ''}`.toLowerCase();
return haystack.includes(q);
});
// Featured magazine-style examples float to the top (lower priority
// number wins). Non-featured skills keep their server-side order so
@ -194,7 +204,7 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
return a.idx - b.idx;
})
.map(({ s }) => s);
}, [skills, surfaceFilter, modeFilter, scenarioFilter]);
}, [skills, surfaceFilter, modeFilter, scenarioFilter, search, locale]);
if (skills.length === 0) {
return <div className="tab-empty">{t('examples.emptyNoSkills')}</div>;
@ -203,6 +213,18 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
return (
<div className="tab-panel examples-panel">
<div className="examples-toolbar">
<div className="examples-search">
<span className="search-icon" aria-hidden>
<Icon name="search" size={13} />
</span>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('examples.searchPlaceholder')}
aria-label={t('examples.searchAria')}
/>
</div>
<div
className="examples-filter-row"
role="tablist"

View file

@ -230,8 +230,10 @@ export function PetOverlay({ pet, onTuck, onOpenSettings }: Props) {
drag.moved = true;
// Convert pointer movement into right/bottom offsets so the sprite
// tracks the cursor while staying anchored to the corner system.
const nextRight = Math.max(8, Math.min(window.innerWidth - 80, drag.startRight - dx));
const nextBottom = Math.max(8, Math.min(window.innerHeight - 80, drag.startBottom - dy));
// The clamp budget (~120px) keeps the 96px sprite plus its drop
// shadow on-screen even when dragged toward the opposite edge.
const nextRight = Math.max(8, Math.min(window.innerWidth - 120, drag.startRight - dx));
const nextBottom = Math.max(8, Math.min(window.innerHeight - 120, drag.startBottom - dy));
setPosition({ right: nextRight, bottom: nextBottom });
// Classify the gesture direction once it clears the jitter floor

View file

@ -275,6 +275,8 @@ export const ar: Dict = {
'examples.scenarioEducation': 'تعليم',
'examples.scenarioPersonal': 'شخصي',
'examples.emptyNoSkills': 'لا توجد مهارات متوفرة. هل البرنامج الخفي يعمل؟',
'examples.searchPlaceholder': 'بحث في الأمثلة...',
'examples.searchAria': 'بحث في الأمثلة بالاسم',
'examples.emptyNoMatch': 'لا توجد أمثلة تطابق هذه الفلاتر.',
'examples.openPreview': '⤢ فتح المعاينة',
'examples.loadingPreview': 'جاري تحميل المعاينة...',

View file

@ -275,6 +275,8 @@ export const de: Dict = {
'examples.scenarioEducation': 'Bildung',
'examples.scenarioPersonal': 'Persönlich',
'examples.emptyNoSkills': 'Keine Skills verfügbar. Läuft der Daemon?',
'examples.searchPlaceholder': 'Beispiele suchen…',
'examples.searchAria': 'Beispiele nach Namen suchen',
'examples.emptyNoMatch': 'Keine Beispiele passen zu diesen Filtern.',
'examples.openPreview': '⤢ Vorschau öffnen',
'examples.loadingPreview': 'Vorschau wird geladen…',

View file

@ -274,6 +274,8 @@ export const en: Dict = {
'examples.scenarioLegal': 'Legal',
'examples.scenarioEducation': 'Education',
'examples.scenarioPersonal': 'Personal',
'examples.searchPlaceholder': 'Search examples…',
'examples.searchAria': 'Search examples by name',
'examples.emptyNoSkills': 'No skills available. Is the daemon running?',
'examples.emptyNoMatch': 'No examples match these filters.',
'examples.openPreview': '⤢ Open preview',

View file

@ -276,6 +276,8 @@ export const esES: Dict = {
'examples.scenarioEducation': 'Educación',
'examples.scenarioPersonal': 'Personal',
'examples.emptyNoSkills': 'No hay skills disponibles. ¿Está el daemon en ejecución?',
'examples.searchPlaceholder': 'Buscar ejemplos…',
'examples.searchAria': 'Buscar ejemplos por nombre',
'examples.emptyNoMatch': 'Ningún ejemplo coincide con estos filtros.',
'examples.openPreview': '⤢ Abrir vista previa',
'examples.loadingPreview': 'Cargando vista previa…',

View file

@ -275,6 +275,8 @@ export const fa: Dict = {
'examples.scenarioEducation': 'آموزش',
'examples.scenarioPersonal': 'شخصی',
'examples.emptyNoSkills': 'هیچ مهارتی موجود نیست. آیا daemon در حال اجرا است؟',
'examples.searchPlaceholder': 'جستجوی نمونه‌ها…',
'examples.searchAria': 'جستجوی نمونه‌ها بر اساس نام',
'examples.emptyNoMatch': 'هیچ نمونه‌ای با این فیلترها مطابقت ندارد.',
'examples.openPreview': '⤢ باز کردن پیش‌نمایش',
'examples.loadingPreview': 'در حال بارگذاری پیش‌نمایش…',

View file

@ -274,6 +274,8 @@ export const fr: Dict = {
'examples.scenarioLegal': 'Juridique',
'examples.scenarioEducation': 'Éducation',
'examples.scenarioPersonal': 'Personnel',
'examples.searchPlaceholder': 'Rechercher des exemples…',
'examples.searchAria': 'Rechercher des exemples par nom',
'examples.emptyNoSkills': 'Aucune compétence disponible. Le daemon est-il en cours d\'exécution ?',
'examples.emptyNoMatch': 'Aucun exemple ne correspond à ces filtres.',
'examples.openPreview': '⤢ Ouvrir l\'aperçu',

View file

@ -275,6 +275,8 @@ export const hu: Dict = {
'examples.scenarioEducation': 'Oktatás',
'examples.scenarioPersonal': 'Személyes',
'examples.emptyNoSkills': 'Nincs elérhető skill. Fut a daemon?',
'examples.searchPlaceholder': 'Példák keresése…',
'examples.searchAria': 'Példák keresése név alapján',
'examples.emptyNoMatch': 'Egy példa sem felel meg ezeknek a szűrőknek.',
'examples.openPreview': '⤢ Előnézet megnyitása',
'examples.loadingPreview': 'Előnézet betöltése…',

View file

@ -274,6 +274,8 @@ export const ja: Dict = {
'examples.scenarioEducation': '教育',
'examples.scenarioPersonal': '個人',
'examples.emptyNoSkills': 'スキルがありません。デーモンは起動していますか?',
'examples.searchPlaceholder': 'サンプルを検索…',
'examples.searchAria': '名前でサンプルを検索',
'examples.emptyNoMatch': 'このフィルターに一致するサンプルがありません。',
'examples.openPreview': '⤢ プレビューを開く',
'examples.loadingPreview': 'プレビューを読み込み中…',

View file

@ -275,6 +275,8 @@ export const ko: Dict = {
'examples.scenarioEducation': '교육',
'examples.scenarioPersonal': '개인',
'examples.emptyNoSkills': '사용 가능한 스킬이 없습니다. 데몬이 실행 중인지 확인하세요.',
'examples.searchPlaceholder': '예제 검색…',
'examples.searchAria': '이름으로 예제 검색',
'examples.emptyNoMatch': '필터와 일치하는 예제가 없습니다.',
'examples.openPreview': '⤢ 미리보기 열기',
'examples.loadingPreview': '미리보기 불러오는 중…',

View file

@ -275,6 +275,8 @@ export const pl: Dict = {
'examples.scenarioEducation': 'Edukacja',
'examples.scenarioPersonal': 'Osobiste',
'examples.emptyNoSkills': 'Brak dostępnych umiejętności. Czy daemon jest uruchomiony?',
'examples.searchPlaceholder': 'Szukaj przykładów…',
'examples.searchAria': 'Szukaj przykładów po nazwie',
'examples.emptyNoMatch': 'Brak przykładów pasujących do filtrów.',
'examples.openPreview': '⤢ Otwórz podgląd',
'examples.loadingPreview': 'Ładowanie podglądu…',

View file

@ -274,6 +274,8 @@ export const ptBR: Dict = {
'examples.scenarioEducation': 'Educação',
'examples.scenarioPersonal': 'Pessoal',
'examples.emptyNoSkills': 'Nenhuma skill disponível. O daemon está em execução?',
'examples.searchPlaceholder': 'Buscar exemplos…',
'examples.searchAria': 'Buscar exemplos por nome',
'examples.emptyNoMatch': 'Nenhum exemplo corresponde a esses filtros.',
'examples.openPreview': '⤢ Abrir prévia',
'examples.loadingPreview': 'Carregando prévia…',

View file

@ -274,6 +274,8 @@ export const ru: Dict = {
'examples.scenarioEducation': 'Образование',
'examples.scenarioPersonal': 'Личное',
'examples.emptyNoSkills': 'Нет доступных навыков. Демон запущен?',
'examples.searchPlaceholder': 'Поиск примеров…',
'examples.searchAria': 'Поиск примеров по имени',
'examples.emptyNoMatch': 'Нет примеров, соответствующих этим фильтрам.',
'examples.openPreview': '⤢ Открыть предпросмотр',
'examples.loadingPreview': 'Загрузка предпросмотра…',

View file

@ -274,6 +274,8 @@ export const tr: Dict = {
'examples.scenarioEducation': 'Eğitim',
'examples.scenarioPersonal': 'Şahsi',
'examples.emptyNoSkills': 'Yetenekler mevcut değil. Arka plan servisi çalışıyor mu?',
'examples.searchPlaceholder': 'Örnek ara…',
'examples.searchAria': 'Örnekleri ada göre ara',
'examples.emptyNoMatch': 'Hiçbir örnek bu filtrelere uymuyor.',
'examples.openPreview': '⤢ Önizlemeyi aç',
'examples.loadingPreview': 'Önizleme yükleniyor…',

View file

@ -274,6 +274,8 @@ export const uk: Dict = {
'examples.scenarioLegal': 'Юридичні послуги',
'examples.scenarioEducation': 'Освіта',
'examples.scenarioPersonal': 'Особисте',
'examples.searchPlaceholder': 'Пошук прикладів…',
'examples.searchAria': 'Пошук прикладів за назвою',
'examples.emptyNoSkills': 'Навички недоступні. Чи запущений фоновий процес?',
'examples.emptyNoMatch': 'Приклади, що відповідають цим фільтрам, не знайдені.',
'examples.openPreview': '⤢ Відкрити попередній перегляд',

View file

@ -270,6 +270,8 @@ export const zhCN: Dict = {
'examples.scenarioEducation': '教育',
'examples.scenarioPersonal': '个人',
'examples.emptyNoSkills': '没有可用的技能,守护进程是否在运行?',
'examples.searchPlaceholder': '搜索示例…',
'examples.searchAria': '按名称搜索示例',
'examples.emptyNoMatch': '没有匹配当前筛选的示例。',
'examples.openPreview': '⤢ 打开预览',
'examples.loadingPreview': '正在加载预览…',

View file

@ -270,6 +270,8 @@ export const zhTW: Dict = {
'examples.scenarioEducation': '教育',
'examples.scenarioPersonal': '個人',
'examples.emptyNoSkills': '沒有可用的技能,守護程序是否在執行?',
'examples.searchPlaceholder': '搜尋範例…',
'examples.searchAria': '依名稱搜尋範例',
'examples.emptyNoMatch': '沒有符合當前篩選的範例。',
'examples.openPreview': '⤢ 開啟預覽',
'examples.loadingPreview': '正在載入預覽…',

View file

@ -321,6 +321,8 @@ export interface Dict {
'examples.scenarioLegal': string;
'examples.scenarioEducation': string;
'examples.scenarioPersonal': string;
'examples.searchPlaceholder': string;
'examples.searchAria': string;
'examples.emptyNoSkills': string;
'examples.emptyNoMatch': string;
'examples.openPreview': string;

View file

@ -2628,24 +2628,44 @@ code {
}
.tab-panel-toolbar {
display: flex;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
/* Older browsers ignore row-gap on flex with wrap explicit row-gap keeps
the wrapped row visually separated rather than flush against the pill. */
row-gap: 8px;
}
.tab-panel-toolbar .toolbar-left {
display: flex;
gap: 8px;
align-items: center;
flex: 0 0 auto;
min-width: 0;
}
.tab-panel-toolbar .toolbar-left,
.tab-panel-toolbar .toolbar-right {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
flex: 1 1 auto;
min-width: 0;
justify-content: flex-end;
flex-wrap: wrap;
}
.tab-panel-toolbar .toolbar-search {
position: relative;
flex: 1 1 240px;
width: min(280px, 100%);
max-width: 100%;
flex: 1 1 180px;
min-width: 140px;
max-width: 280px;
}
/* Narrow columns (entry tab content sometimes lands at ~570px wide) keep
the segmented pill on its own row above the search/view toggle so the
search input never collapses into a tiny stub squeezed between two pills. */
@media (max-width: 720px) {
.tab-panel-toolbar { flex-direction: column; align-items: stretch; }
.tab-panel-toolbar .toolbar-left { justify-content: flex-start; }
.tab-panel-toolbar .toolbar-right { justify-content: space-between; }
.tab-panel-toolbar .toolbar-search { max-width: none; }
}
.tab-panel-toolbar .toolbar-search input {
padding-left: 30px;
@ -5823,8 +5843,15 @@ code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre;
overflow: auto;
/* Wrap long lines instead of forcing the side pane to scroll horizontally
DESIGN.md prose can have 200+ char paragraphs that otherwise produce a
scrollbar inside the modal. `overflow-wrap: anywhere` keeps long
hyphenated tokens (URLs, file paths) from blowing out the column. */
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
overflow-y: auto;
overflow-x: hidden;
color: var(--text);
background: var(--bg-panel);
flex: 1;
@ -5888,6 +5915,35 @@ code {
gap: 10px;
margin-bottom: 8px;
}
.examples-search {
position: relative;
width: min(360px, 100%);
}
.examples-search input {
width: 100%;
padding: 7px 12px 7px 32px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
color: var(--text);
}
.examples-search input::placeholder { color: var(--text-faint); }
.examples-search input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.examples-search .search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-faint);
pointer-events: none;
display: inline-flex;
align-items: center;
}
.examples-filter-row {
display: flex;
flex-wrap: wrap;
@ -6951,8 +7007,13 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
.pet-sprite {
position: relative;
width: 56px;
height: 56px;
/* The overlay sprite was 56px which read as a tiny postage stamp
against a 1280px+ canvas bumped to 96px so the pet feels like
a present companion rather than a thumbnail. The image-mode
children inherit width/height: 100% via .pet-image, so atlas /
strip / static pets all scale up automatically. */
width: 96px;
height: 96px;
background: transparent;
border: 0;
box-shadow: none;
@ -6969,20 +7030,23 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
}
.pet-sprite:active { cursor: grabbing; }
.pet-sprite-glyph {
font-size: 30px;
/* Glyph font-size scales with the sprite box (~0.55 ratio) so
emoji-only built-ins and the avatar mark stay legible at the
larger overlay size. */
font-size: 52px;
line-height: 1;
animation: var(--pet-anim, pet-float) 3.4s ease-in-out infinite;
filter: drop-shadow(0 1px 0 rgba(0,0,0,0.08));
}
.pet-sprite-shadow {
position: absolute;
bottom: -10px;
bottom: -12px;
left: 50%;
width: 36px;
height: 6px;
width: 64px;
height: 8px;
background: rgba(0, 0, 0, 0.18);
border-radius: 50%;
filter: blur(3px);
filter: blur(4px);
transform: translateX(-50%);
animation: pet-shadow 3.4s ease-in-out infinite;
}