mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(web): tighten entry-tab layout and design-system showcase color picker (#412)
This commit is contained in:
parent
6c2a8ba09f
commit
5a09e39f1f
22 changed files with 411 additions and 67 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
71
apps/daemon/tests/design-system-showcase.test.ts
Normal file
71
apps/daemon/tests/design-system-showcase.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -275,6 +275,8 @@ export const ar: Dict = {
|
|||
'examples.scenarioEducation': 'تعليم',
|
||||
'examples.scenarioPersonal': 'شخصي',
|
||||
'examples.emptyNoSkills': 'لا توجد مهارات متوفرة. هل البرنامج الخفي يعمل؟',
|
||||
'examples.searchPlaceholder': 'بحث في الأمثلة...',
|
||||
'examples.searchAria': 'بحث في الأمثلة بالاسم',
|
||||
'examples.emptyNoMatch': 'لا توجد أمثلة تطابق هذه الفلاتر.',
|
||||
'examples.openPreview': '⤢ فتح المعاينة',
|
||||
'examples.loadingPreview': 'جاري تحميل المعاينة...',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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': 'در حال بارگذاری پیشنمایش…',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -274,6 +274,8 @@ export const ja: Dict = {
|
|||
'examples.scenarioEducation': '教育',
|
||||
'examples.scenarioPersonal': '個人',
|
||||
'examples.emptyNoSkills': 'スキルがありません。デーモンは起動していますか?',
|
||||
'examples.searchPlaceholder': 'サンプルを検索…',
|
||||
'examples.searchAria': '名前でサンプルを検索',
|
||||
'examples.emptyNoMatch': 'このフィルターに一致するサンプルがありません。',
|
||||
'examples.openPreview': '⤢ プレビューを開く',
|
||||
'examples.loadingPreview': 'プレビューを読み込み中…',
|
||||
|
|
|
|||
|
|
@ -275,6 +275,8 @@ export const ko: Dict = {
|
|||
'examples.scenarioEducation': '교육',
|
||||
'examples.scenarioPersonal': '개인',
|
||||
'examples.emptyNoSkills': '사용 가능한 스킬이 없습니다. 데몬이 실행 중인지 확인하세요.',
|
||||
'examples.searchPlaceholder': '예제 검색…',
|
||||
'examples.searchAria': '이름으로 예제 검색',
|
||||
'examples.emptyNoMatch': '필터와 일치하는 예제가 없습니다.',
|
||||
'examples.openPreview': '⤢ 미리보기 열기',
|
||||
'examples.loadingPreview': '미리보기 불러오는 중…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -274,6 +274,8 @@ export const ru: Dict = {
|
|||
'examples.scenarioEducation': 'Образование',
|
||||
'examples.scenarioPersonal': 'Личное',
|
||||
'examples.emptyNoSkills': 'Нет доступных навыков. Демон запущен?',
|
||||
'examples.searchPlaceholder': 'Поиск примеров…',
|
||||
'examples.searchAria': 'Поиск примеров по имени',
|
||||
'examples.emptyNoMatch': 'Нет примеров, соответствующих этим фильтрам.',
|
||||
'examples.openPreview': '⤢ Открыть предпросмотр',
|
||||
'examples.loadingPreview': 'Загрузка предпросмотра…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -274,6 +274,8 @@ export const uk: Dict = {
|
|||
'examples.scenarioLegal': 'Юридичні послуги',
|
||||
'examples.scenarioEducation': 'Освіта',
|
||||
'examples.scenarioPersonal': 'Особисте',
|
||||
'examples.searchPlaceholder': 'Пошук прикладів…',
|
||||
'examples.searchAria': 'Пошук прикладів за назвою',
|
||||
'examples.emptyNoSkills': 'Навички недоступні. Чи запущений фоновий процес?',
|
||||
'examples.emptyNoMatch': 'Приклади, що відповідають цим фільтрам, не знайдені.',
|
||||
'examples.openPreview': '⤢ Відкрити попередній перегляд',
|
||||
|
|
|
|||
|
|
@ -270,6 +270,8 @@ export const zhCN: Dict = {
|
|||
'examples.scenarioEducation': '教育',
|
||||
'examples.scenarioPersonal': '个人',
|
||||
'examples.emptyNoSkills': '没有可用的技能,守护进程是否在运行?',
|
||||
'examples.searchPlaceholder': '搜索示例…',
|
||||
'examples.searchAria': '按名称搜索示例',
|
||||
'examples.emptyNoMatch': '没有匹配当前筛选的示例。',
|
||||
'examples.openPreview': '⤢ 打开预览',
|
||||
'examples.loadingPreview': '正在加载预览…',
|
||||
|
|
|
|||
|
|
@ -270,6 +270,8 @@ export const zhTW: Dict = {
|
|||
'examples.scenarioEducation': '教育',
|
||||
'examples.scenarioPersonal': '個人',
|
||||
'examples.emptyNoSkills': '沒有可用的技能,守護程序是否在執行?',
|
||||
'examples.searchPlaceholder': '搜尋範例…',
|
||||
'examples.searchAria': '依名稱搜尋範例',
|
||||
'examples.emptyNoMatch': '沒有符合當前篩選的範例。',
|
||||
'examples.openPreview': '⤢ 開啟預覽',
|
||||
'examples.loadingPreview': '正在載入預覽…',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue