diff --git a/package.json b/package.json index 56f1cc28d..45038dc57 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.6.1", + "version": "0.6.2", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx index 15484caf9..b3b828d6c 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx @@ -67,6 +67,42 @@ export const Empty: Story = { args: { logs: [] }, }; +/** Seeds the text filter box on mount — e.g. landing pre-filtered from a deep link. */ +export const WithInitialQuery: Story = { + args: { logs: SAMPLE_LOGS, initialQuery: "users" }, +}; + +/** Every user-facing string overridden — e.g. for a non-English locale. */ +export const Localized: Story = { + args: { + logs: SAMPLE_LOGS, + labels: { + title: "日誌", + live: "即時", + paused: "已暫停", + floorAll: "全部", + floorWarn: "警告以上", + floorError: "錯誤", + filterAriaLabel: "依層級篩選", + filterPlaceholder: "篩選紀錄", + clearFilterAriaLabel: "清除篩選", + copyAriaLabel: "複製紀錄", + noMatchingEntries: "沒有符合的紀錄。", + request: "請求", + response: "回應", + noRequestResponseBody: "沒有請求或回應內容。", + loadOlderEntries: "載入較舊的紀錄", + entriesLabel: (filteredCount, total, query) => { + const base = + filteredCount === total ? `共 ${total} 筆` : `${total} 筆中的 ${filteredCount} 筆`; + return query ? `${base} · "${query}"` : base; + }, + tailingStatus: "即時追蹤中", + pausedStatus: "已暫停", + }, + }, +}; + // Older pages are generated on demand, seq-keyed, and appended at the OLD end // (the bottom). Scroll near the bottom or click the row to load a page. const PAGE_SIZE = 20; diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx index 21a8c0c84..e06b15d1f 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx @@ -23,6 +23,53 @@ export const meta: ComponentMeta = { "Service log console with severity-floor filter, text filter, live-tail toggle, copy, load-older paging, and an expandable request/response detail per line", }; +/** Every user-facing string ServiceLogcat renders, for callers that need it + * in a locale other than English — all optional, defaulting to the current + * English copy, so passing nothing is fully backward compatible. */ +export interface ServiceLogcatLabels { + /** Default: "Logcat" */ + title?: string; + /** Live-tail toggle when on. Default: "Live" */ + live?: string; + /** Live-tail toggle when off. Default: "Paused" */ + paused?: string; + /** Severity-floor option: no filter. Default: "All" */ + floorAll?: string; + /** Severity-floor option: warn and above. Default: "Warn+" */ + floorWarn?: string; + /** Severity-floor option: errors only. Default: "Errors" */ + floorError?: string; + /** aria-label on the severity-floor segmented control. Default: "Filter by level" */ + filterAriaLabel?: string; + /** Text-filter input label. Default: "Filter logs" */ + filterPlaceholder?: string; + /** aria-label on the clear-filter button. Default: "Clear filter" */ + clearFilterAriaLabel?: string; + /** aria-label on the copy-logs button. Default: "Copy logs" */ + copyAriaLabel?: string; + /** Shown when the filter matches nothing. Default: "No matching log entries." */ + noMatchingEntries?: string; + /** Expanded-row request section heading. Default: "Request" */ + request?: string; + /** Expanded-row response section heading. Default: "Response" */ + response?: string; + /** Expanded-row body when neither request nor response is present. + * Default: "No request or response body." */ + noRequestResponseBody?: string; + /** Load-older-entries row label. Default: "Load older entries" */ + loadOlderEntries?: string; + /** Footer entry-count line, e.g. "42 entries" or "5 of 42" plus the active + * query. Called with (filteredCount, total, query). */ + entriesLabel?: (filteredCount: number, total: number, query: string) => string; + /** Footer status when tailing. Default: "tailing" */ + tailingStatus?: string; + /** Footer status when paused. Default: "paused" */ + pausedStatus?: string; +} + +const defaultEntriesLabel = (filteredCount: number, total: number, query: string) => + `${filteredCount === total ? `${total} entries` : `${filteredCount} of ${total}`}${query ? ` · "${query}"` : ""}`; + export interface ServiceLogcatProps { /** Log entries, chronological (oldest first); the console reverses them so the newest line sits at the top. */ logs: LogEntry[]; @@ -32,6 +79,12 @@ export interface ServiceLogcatProps { hasOlder?: boolean; /** Older page currently loading — shows the row in a loading state and suppresses re-fire. */ loadingOlder?: boolean; + /** Seeds the text filter box on mount (e.g. a module slug, to land pre-filtered + * from a deep link) — after that, the input is normal uncontrolled state. */ + initialQuery?: string; + /** Override any/all of the component's own strings for i18n. Unset fields + * fall back to the English default. */ + labels?: ServiceLogcatLabels; } const LEVEL_BADGE: Record = { @@ -48,12 +101,6 @@ const LEVEL_TEXT: Record = { error: "text-error", }; -const FLOOR_OPTIONS: readonly { value: LevelFloor; label: string }[] = [ - { value: "all", label: "All" }, - { value: "warn", label: "Warn+" }, - { value: "error", label: "Errors" }, -]; - // Compact single-line timestamp: "2026-06-30T16:23:45.225373081Z" -> // "2026-06-30 16:23:45.225" (drop the T, nanoseconds, and trailing Z). function fmtTs(ts: string): string { @@ -101,9 +148,16 @@ export function ServiceLogcat({ onLoadOlder, hasOlder = false, loadingOlder = false, + initialQuery, + labels, }: ServiceLogcatProps) { + const floorOptions: readonly { value: LevelFloor; label: string }[] = [ + { value: "all", label: labels?.floorAll ?? "All" }, + { value: "warn", label: labels?.floorWarn ?? "Warn+" }, + { value: "error", label: labels?.floorError ?? "Errors" }, + ]; const { query, setQuery, floor, setFloor, tailing, setTailing, filtered, total } = - useLogcat(logs); + useLogcat(logs, initialQuery); const rowKeys = useMemo(() => rowKeysFor(filtered), [filtered]); const scrollRef = useRef(null); const pinnedRef = useRef(true); // is the view currently pinned at the top? @@ -190,7 +244,7 @@ export function ServiceLogcat({ return (
- Logcat + {labels?.title ?? "Logcat"}
{filtered.length === 0 ? ( -

No matching log entries.

+

{labels?.noMatchingEntries ?? "No matching log entries."}

) : (
{filtered.map((l, i) => { @@ -294,10 +348,12 @@ export function ServiceLogcat({
{open && (
- {l.req_body ? : null} - {l.res_body ? : null} + {l.req_body ? : null} + {l.res_body ? : null} {!l.req_body && !l.res_body ? ( -

No request or response body.

+

+ {labels?.noRequestResponseBody ?? "No request or response body."} +

) : null}
)} @@ -315,7 +371,7 @@ export function ServiceLogcat({ onClick={onLoadOlder} className="mt-1" > - Load older entries + {labels?.loadOlderEntries ?? "Load older entries"} )}
@@ -323,10 +379,9 @@ export function ServiceLogcat({
- {filtered.length === total ? `${total} entries` : `${filtered.length} of ${total}`} - {query && ` · “${query}”`} + {(labels?.entriesLabel ?? defaultEntriesLabel)(filtered.length, total, query)} - {tailing ? "tailing" : "paused"} + {tailing ? (labels?.tailingStatus ?? "tailing") : (labels?.pausedStatus ?? "paused")}
); diff --git a/src/components/ui/dev/service-logcat/useLogcat.ts b/src/components/ui/dev/service-logcat/useLogcat.ts index 4761c6df7..5531ef832 100644 --- a/src/components/ui/dev/service-logcat/useLogcat.ts +++ b/src/components/ui/dev/service-logcat/useLogcat.ts @@ -32,8 +32,8 @@ export interface UseLogcatResult { * first); the hook reverses them to newest-first so the console shows the * latest line at the top. */ -export function useLogcat(logs: LogEntry[]): UseLogcatResult { - const [query, setQuery] = useState(""); +export function useLogcat(logs: LogEntry[], initialQuery = ""): UseLogcatResult { + const [query, setQuery] = useState(initialQuery); const [floor, setFloor] = useState("all"); const [tailing, setTailing] = useState(true); diff --git a/src/components/ui/inputs/segmented-button/SegmentedButton.stories.tsx b/src/components/ui/inputs/segmented-button/SegmentedButton.stories.tsx index a2b86fe95..1ac125490 100644 --- a/src/components/ui/inputs/segmented-button/SegmentedButton.stories.tsx +++ b/src/components/ui/inputs/segmented-button/SegmentedButton.stories.tsx @@ -82,6 +82,18 @@ export const Disabled: Story = { }, }; +/** A small red count badge anchored to an option's corner (e.g. an open-items count) */ +export const WithBadge: Story = { + args: { + options: [ + { value: "list", label: "List" }, + { value: "issues", label: "Issues", badge: 4 }, + ], + value: "list", + "aria-label": "Select view", + }, +}; + /** Boxed/connected track with an inset selected pill */ export const Boxed: Story = { render: (args) => { diff --git a/src/components/ui/inputs/segmented-button/SegmentedButton.tsx b/src/components/ui/inputs/segmented-button/SegmentedButton.tsx index 23aea82c6..a71f499af 100644 --- a/src/components/ui/inputs/segmented-button/SegmentedButton.tsx +++ b/src/components/ui/inputs/segmented-button/SegmentedButton.tsx @@ -21,6 +21,9 @@ export interface SegmentedButtonOption { readonly icon?: string; /** Visual tone for the unselected state. Defaults to "default". */ readonly tone?: SegmentedButtonOptionTone; + /** Small red count badge anchored to the option's top-right corner (e.g. an + * open-items count). Omit for no badge. */ + readonly badge?: string | number; } export type SegmentedButtonSize = "sm" | "md"; @@ -139,7 +142,7 @@ export function SegmentedButton({ disabled={disabled} aria-pressed={value === opt.value} className={cn( - "font-medium transition-colors inline-flex items-center justify-center gap-1.5", + "relative font-medium transition-colors inline-flex items-center justify-center gap-1.5", isBoxed ? boxedSizeStyles[size] : sizeStyles[size], isBoxed ? boxedButtonStyle(value === opt.value, inverse) @@ -151,6 +154,14 @@ export function SegmentedButton({ > {opt.icon && } {opt.label} + {opt.badge != null && ( + + )} ))}