diff --git a/icons/border-48.png b/icons/border-48.png
new file mode 100644
index 0000000..90687de
Binary files /dev/null and b/icons/border-48.png differ
diff --git a/sciezka.css b/sciezka.css
new file mode 100644
index 0000000..35e9c50
--- /dev/null
+++ b/sciezka.css
@@ -0,0 +1,339 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+/* Color tokens */
+:root {
+ --bg: #fff;
+ --bg-alt: #fafafa;
+ --border: #e8e8e8;
+ --text: #1a1a1a;
+ --text-secondary: #999;
+ --text-muted: #aaa;
+ --text-dim: #777;
+ --icon: #999;
+ --badge-bg: #f0f0f0;
+ --kbd-bg: #e8e8e8;
+ --kbd-text: #666;
+ --hover: #f7f7f7;
+ --selected: #eff6ff;
+ --accent: #3b82f6;
+ --mark-bg: #fde68a;
+ --mark-text: inherit;
+ --scrollbar: #ddd;
+ --scrollbar-hover: #ccc;
+ --badge-tabs-bg: #dbeafe;
+ --badge-tabs-text: #2563eb;
+ --badge-history-bg: #fef3c7;
+ --badge-history-text: #d97706;
+ --badge-bookmarks-bg: #dcfce7;
+ --badge-bookmarks-text: #16a34a;
+ --badge-closed-bg: #f3e8ff;
+ --badge-closed-text: #9333ea;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #1e1e1e;
+ --bg-alt: #252525;
+ --border: #333;
+ --text: #e0e0e0;
+ --text-secondary: #666;
+ --text-muted: #555;
+ --text-dim: #888;
+ --icon: #666;
+ --badge-bg: #333;
+ --kbd-bg: #3a3a3a;
+ --kbd-text: #888;
+ --hover: #272727;
+ --selected: #1e3a5f;
+ --accent: #3b82f6;
+ --mark-bg: #854d0e;
+ --mark-text: #fef3c7;
+ --scrollbar: #444;
+ --scrollbar-hover: #555;
+ --badge-tabs-bg: #1e3a5f;
+ --badge-tabs-text: #60a5fa;
+ --badge-history-bg: #422006;
+ --badge-history-text: #fbbf24;
+ --badge-bookmarks-bg: #052e16;
+ --badge-bookmarks-text: #4ade80;
+ --badge-closed-bg: #2e1065;
+ --badge-closed-text: #c084fc;
+ }
+}
+
+[data-theme="dark"] {
+ --bg: #1e1e1e;
+ --bg-alt: #252525;
+ --border: #333;
+ --text: #e0e0e0;
+ --text-secondary: #666;
+ --text-muted: #555;
+ --text-dim: #888;
+ --icon: #666;
+ --badge-bg: #333;
+ --kbd-bg: #3a3a3a;
+ --kbd-text: #888;
+ --hover: #272727;
+ --selected: #1e3a5f;
+ --accent: #3b82f6;
+ --mark-bg: #854d0e;
+ --mark-text: #fef3c7;
+ --scrollbar: #444;
+ --scrollbar-hover: #555;
+ --badge-tabs-bg: #1e3a5f;
+ --badge-tabs-text: #60a5fa;
+ --badge-history-bg: #422006;
+ --badge-history-text: #fbbf24;
+ --badge-bookmarks-bg: #052e16;
+ --badge-bookmarks-text: #4ade80;
+ --badge-closed-bg: #2e1065;
+ --badge-closed-text: #c084fc;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+ background: transparent;
+ font-size: 14px;
+ -webkit-font-smoothing: antialiased;
+}
+
+#sciezka-root {
+ background: var(--bg);
+ border-radius: 0;
+ overflow: hidden;
+}
+
+/* Search bar */
+#search-bar {
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 10px;
+ border-bottom: 1px solid var(--border);
+}
+
+#search-icon {
+ flex-shrink: 0;
+ color: var(--icon);
+}
+
+#search-input {
+ flex: 1;
+ padding: 14px 0;
+ font-size: 17px;
+ border: none;
+ outline: none;
+ background: transparent;
+ color: var(--text);
+ font-family: inherit;
+}
+
+#search-input::placeholder {
+ color: var(--text-muted);
+}
+
+#method-badge {
+ font-size: 10px;
+ padding: 3px 8px;
+ border-radius: 0;
+ background: var(--badge-bg);
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+ flex-shrink: 0;
+ cursor: default;
+}
+
+/* Mode bar */
+#mode-bar {
+ display: flex;
+ gap: 2px;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-alt);
+}
+
+.mode-btn {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: var(--text-dim);
+ font-size: 12px;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+}
+
+.mode-btn kbd {
+ font-size: 10px;
+ padding: 0 4px;
+ border-radius: 0;
+ background: var(--kbd-bg);
+ color: var(--text-secondary);
+ font-family: inherit;
+ line-height: 16px;
+}
+
+.mode-btn:hover {
+ background: var(--hover);
+ color: var(--text);
+}
+
+.mode-btn.active {
+ background: var(--accent);
+ color: #fff;
+}
+
+.mode-btn.active kbd {
+ background: rgba(255, 255, 255, 0.25);
+ color: #fff;
+}
+
+/* Results */
+#results {
+ max-height: 360px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.result-row {
+ display: flex;
+ align-items: center;
+ padding: 7px 16px;
+ cursor: pointer;
+ gap: 10px;
+ border-left: 3px solid transparent;
+ transition: background 0.08s;
+}
+
+.result-row:hover {
+ background: var(--hover);
+}
+
+.result-row.selected {
+ background: var(--selected);
+ border-left-color: var(--accent);
+}
+
+.result-icon {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+}
+
+.result-icon img {
+ border-radius: 0;
+ width: 16px;
+ height: 16px;
+}
+
+.result-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.result-title {
+ font-size: 13px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-weight: 500;
+}
+
+.result-url {
+ font-size: 11px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.result-badge {
+ flex-shrink: 0;
+ font-size: 10px;
+ padding: 2px 7px;
+ border-radius: 0;
+ font-weight: 500;
+ letter-spacing: 0.3px;
+}
+
+.badge-tabs { background: var(--badge-tabs-bg); color: var(--badge-tabs-text); }
+.badge-history { background: var(--badge-history-bg); color: var(--badge-history-text); }
+.badge-bookmarks { background: var(--badge-bookmarks-bg); color: var(--badge-bookmarks-text); }
+.badge-closed { background: var(--badge-closed-bg); color: var(--badge-closed-text); }
+
+.no-results {
+ padding: 32px;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+mark {
+ background: var(--mark-bg);
+ color: var(--mark-text);
+ border-radius: 0;
+ padding: 0 1px;
+}
+
+/* Footer */
+#footer {
+ display: flex;
+ gap: 12px;
+ padding: 6px 16px;
+ border-top: 1px solid var(--border);
+ background: var(--bg-alt);
+ justify-content: center;
+}
+
+.hint {
+ font-size: 11px;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.hint kbd {
+ font-size: 10px;
+ padding: 1px 5px;
+ border-radius: 0;
+ background: var(--kbd-bg);
+ color: var(--kbd-text);
+ font-family: inherit;
+}
+
+/* Scrollbar */
+#results::-webkit-scrollbar {
+ width: 6px;
+}
+
+#results::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+#results::-webkit-scrollbar-thumb {
+ background: var(--scrollbar);
+ border-radius: 0;
+}
+
+#results::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-hover);
+}
diff --git a/sciezka.html b/sciezka.html
new file mode 100644
index 0000000..06bcd92
--- /dev/null
+++ b/sciezka.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/sciezka.ts b/src/sciezka.ts
new file mode 100644
index 0000000..0b6aed9
--- /dev/null
+++ b/src/sciezka.ts
@@ -0,0 +1,268 @@
+import { search } from "./search";
+import type { SearchItem, SearchMode, SearchMethod, SearchResult, Message } from "./types";
+
+const MODES: SearchMode[] = ["tabs", "history", "bookmarks", "closed", "all"];
+const MODE_LABELS: Record = {
+ tabs: "Tabs",
+ history: "History",
+ bookmarks: "Bookmarks",
+ closed: "Closed",
+ all: "All",
+};
+const METHODS: SearchMethod[] = ["fuzzy", "fulltext", "prefix"];
+
+let currentMode: SearchMode = "tabs";
+let currentMethod: SearchMethod = "fuzzy";
+let results: SearchResult[] = [];
+let selectedIndex = 0;
+let debounceTimer: ReturnType | null = null;
+
+const input = document.getElementById("search-input") as HTMLInputElement;
+const resultsContainer = document.getElementById("results") as HTMLDivElement;
+const modeBar = document.getElementById("mode-bar") as HTMLDivElement;
+const methodBadge = document.getElementById("method-badge") as HTMLSpanElement;
+const root = document.getElementById("sciezka-root") as HTMLDivElement;
+
+function notifyResize(): void {
+ const height = Math.min(root.scrollHeight, 520);
+ window.parent.postMessage({ type: "resize", height }, "*");
+}
+
+function sendMessage(msg: Message): Promise {
+ return new Promise((resolve) => {
+ window.parent.postMessage(msg, "*");
+ const handler = (event: MessageEvent) => {
+ if (event.source === window.parent) {
+ window.removeEventListener("message", handler);
+ resolve(event.data);
+ }
+ };
+ window.addEventListener("message", handler);
+ });
+}
+
+function renderModeBar(): void {
+ modeBar.innerHTML = "";
+ for (let i = 0; i < MODES.length; i++) {
+ const mode = MODES[i];
+ const btn = document.createElement("button");
+ btn.className = `mode-btn${mode === currentMode ? " active" : ""}`;
+ btn.innerHTML = `${MODE_LABELS[mode]}${i + 1}`;
+ btn.addEventListener("click", () => {
+ currentMode = mode;
+ renderModeBar();
+ doSearch();
+ });
+ modeBar.appendChild(btn);
+ }
+}
+
+function renderMethodBadge(): void {
+ methodBadge.textContent = currentMethod;
+}
+
+function highlightText(text: string, positions: number[], offset: number): string {
+ const chars = text.split("");
+ const posSet = new Set(positions.map((p) => p - offset).filter((p) => p >= 0 && p < text.length));
+ return chars
+ .map((c, i) => (posSet.has(i) ? `${escapeHtml(c)}` : escapeHtml(c)))
+ .join("");
+}
+
+function escapeHtml(s: string): string {
+ return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+function typeIcon(type: SearchMode): string {
+ switch (type) {
+ case "tabs": return "📄";
+ case "history": return "🕒";
+ case "bookmarks": return "⭐";
+ case "closed": return "🚪";
+ default: return "";
+ }
+}
+
+function renderResults(): void {
+ resultsContainer.innerHTML = "";
+ if (results.length === 0 && input.value) {
+ resultsContainer.innerHTML = `No results found
`;
+ notifyResize();
+ return;
+ }
+ if (results.length === 0 && !input.value) {
+ notifyResize();
+ return;
+ }
+
+ const visible = results.slice(0, 50);
+ for (let i = 0; i < visible.length; i++) {
+ const { item, positions } = visible[i];
+ const row = document.createElement("div");
+ row.className = `result-row${i === selectedIndex ? " selected" : ""}`;
+ row.dataset.index = String(i);
+
+ const titleHtml = highlightText(item.title, positions, 0);
+ const urlHtml = highlightText(item.url, positions, item.title.length + 1);
+
+ row.innerHTML = `
+ ${item.favIconUrl ? `
` : typeIcon(item.type)}
+
+ ${titleHtml}
+ ${urlHtml}
+
+ ${MODE_LABELS[item.type]}
+ `;
+
+ row.addEventListener("click", () => activateResult(i));
+ row.addEventListener("mouseenter", () => {
+ selectedIndex = i;
+ updateSelection();
+ });
+ resultsContainer.appendChild(row);
+ }
+
+ scrollToSelected();
+ notifyResize();
+}
+
+function updateSelection(): void {
+ const rows = resultsContainer.querySelectorAll(".result-row");
+ rows.forEach((row, i) => {
+ row.classList.toggle("selected", i === selectedIndex);
+ });
+}
+
+function scrollToSelected(): void {
+ const selected = resultsContainer.querySelector(".selected");
+ selected?.scrollIntoView({ block: "nearest" });
+}
+
+function activateResult(index: number): void {
+ const result = results[index];
+ if (!result) return;
+
+ const { item } = result;
+ let action: string;
+ if (item.type === "tabs") {
+ action = "switch";
+ } else if (item.type === "closed") {
+ action = "restore";
+ } else {
+ action = "open";
+ }
+
+ const msg: Message = item.type === "tabs" || item.type === "closed"
+ ? { type: "action", action: action as "switch" | "restore", id: item.id }
+ : { type: "action", action: "open", id: item.url };
+
+ sendMessage(msg);
+ window.parent.postMessage({ type: "closeSaka" }, "*");
+}
+
+async function doSearch(): Promise {
+ const query = input.value;
+ const response = await sendMessage({
+ type: "search",
+ query,
+ mode: currentMode,
+ method: currentMethod,
+ } satisfies Message);
+
+ const data = response as { type: string; results: SearchItem[] };
+ const items = data.results ?? [];
+ results = search(items, query, currentMethod);
+ selectedIndex = 0;
+ renderResults();
+}
+
+input.addEventListener("input", () => {
+ if (debounceTimer) clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(doSearch, 50);
+});
+
+document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ window.parent.postMessage({ type: "closeSaka" }, "*");
+ return;
+ }
+
+ if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "j")) {
+ e.preventDefault();
+ selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
+ updateSelection();
+ scrollToSelected();
+ return;
+ }
+
+ if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
+ e.preventDefault();
+ selectedIndex = Math.max(selectedIndex - 1, 0);
+ updateSelection();
+ scrollToSelected();
+ return;
+ }
+
+ if (e.key === "Enter") {
+ e.preventDefault();
+ if (e.ctrlKey && e.shiftKey) {
+ const result = results[selectedIndex];
+ if (result && result.item.type !== "tabs") {
+ sendMessage({ type: "action", action: "open", id: result.item.url, newTab: true });
+ window.parent.postMessage({ type: "closeSaka" }, "*");
+ }
+ } else {
+ activateResult(selectedIndex);
+ }
+ return;
+ }
+
+ if (e.key === "Tab") {
+ e.preventDefault();
+ const dir = e.shiftKey ? -1 : 1;
+ const idx = MODES.indexOf(currentMode);
+ currentMode = MODES[(idx + dir + MODES.length) % MODES.length];
+ renderModeBar();
+ doSearch();
+ return;
+ }
+
+ if (e.ctrlKey && e.key === "f") {
+ e.preventDefault();
+ const idx = METHODS.indexOf(currentMethod);
+ currentMethod = METHODS[(idx + 1) % METHODS.length];
+ renderMethodBadge();
+ doSearch();
+ return;
+ }
+
+ if (e.ctrlKey && e.key === "d") {
+ e.preventDefault();
+ const result = results[selectedIndex];
+ if (result && result.item.type === "tabs") {
+ sendMessage({ type: "action", action: "close", id: result.item.id });
+ results.splice(selectedIndex, 1);
+ if (selectedIndex >= results.length) selectedIndex = Math.max(results.length - 1, 0);
+ renderResults();
+ }
+ return;
+ }
+
+ if (e.ctrlKey && e.key >= "1" && e.key <= "5") {
+ e.preventDefault();
+ const modeIdx = parseInt(e.key, 10) - 1;
+ if (modeIdx < MODES.length) {
+ currentMode = MODES[modeIdx];
+ renderModeBar();
+ doSearch();
+ }
+ return;
+ }
+});
+
+document.addEventListener("DOMContentLoaded", () => {
+ renderModeBar();
+ renderMethodBadge();
+ input.focus();
+ doSearch();
+});
diff --git a/src/search.ts b/src/search.ts
new file mode 100644
index 0000000..623a4dd
--- /dev/null
+++ b/src/search.ts
@@ -0,0 +1,143 @@
+import type { SearchItem, SearchMethod, SearchResult } from "./types";
+
+const SCORE_GAP_LEADING = -0.005;
+const SCORE_GAP_TRAILING = -0.005;
+const SCORE_GAP_INNER = -0.01;
+const SCORE_MATCH_CONSECUTIVE = 1.0;
+const SCORE_MATCH_SLASH = 0.9;
+const SCORE_MATCH_WORD = 0.8;
+const SCORE_MATCH_CAPITAL = 0.7;
+const SCORE_MATCH_DOT = 0.6;
+const BONUS_FIRST_CHAR = 0.6;
+
+function isSlash(c: string): boolean {
+ return c === "/" || c === "\\";
+}
+
+function computeBonus(prev: string, curr: string): number {
+ if (isSlash(prev)) return SCORE_MATCH_SLASH;
+ if (prev === "_" || prev === "-" || prev === " ") return SCORE_MATCH_WORD;
+ if (prev === ".") return SCORE_MATCH_DOT;
+ if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
+ return 0;
+}
+
+function fuzzyMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
+ const n = needle.length;
+ const m = haystack.length;
+ if (n === 0) return { score: 0, positions: [] };
+ if (n > m) return null;
+
+ const needleLower = needle.toLowerCase();
+ const haystackLower = haystack.toLowerCase();
+
+ let ni = 0;
+ for (let hi = 0; hi < m; hi++) {
+ if (needleLower[ni] === haystackLower[hi]) ni++;
+ if (ni === n) break;
+ }
+ if (ni < n) return null;
+
+ const D: number[][] = Array.from({ length: n }, () => new Array(m).fill(0));
+ const M: number[][] = Array.from({ length: n }, () => new Array(m).fill(0));
+
+ for (let i = 0; i < n; i++) {
+ let prevScore = -Infinity;
+ let gapScore = i === n - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER;
+
+ for (let j = 0; j < m; j++) {
+ if (needleLower[i] === haystackLower[j]) {
+ let score = 0;
+ if (i === 0) {
+ score = j * SCORE_GAP_LEADING;
+ if (j === 0) score += BONUS_FIRST_CHAR;
+ else score += computeBonus(haystack[j - 1], haystack[j]);
+ } else if (j > 0) {
+ const bonus = computeBonus(haystack[j - 1], haystack[j]);
+ score = Math.max(
+ M[i - 1][j - 1] + bonus,
+ D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE
+ );
+ }
+ D[i][j] = score;
+ M[i][j] = Math.max(score, prevScore + gapScore);
+ } else {
+ D[i][j] = -Infinity;
+ M[i][j] = prevScore + gapScore;
+ }
+ prevScore = M[i][j];
+ }
+ }
+
+ let bestJ = 0;
+ for (let j = 1; j < m; j++) {
+ if (M[n - 1][j] > M[n - 1][bestJ]) bestJ = j;
+ }
+
+ const positions: number[] = new Array(n);
+ let i = n - 1;
+ let j = bestJ;
+ while (i >= 0 && j >= 0) {
+ if (D[i][j] !== -Infinity && (i === 0 || j === 0 || D[i][j] >= M[i][j])) {
+ positions[i] = j;
+ i--;
+ j--;
+ } else {
+ j--;
+ }
+ }
+
+ return { score: M[n - 1][bestJ], positions };
+}
+
+function fulltextMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
+ const idx = haystack.toLowerCase().indexOf(needle.toLowerCase());
+ if (idx === -1) return null;
+
+ const positions = Array.from({ length: needle.length }, (_, i) => idx + i);
+ let score = 1.0;
+ if (idx === 0) score += 0.5;
+ score -= idx * 0.01;
+ return { score, positions };
+}
+
+function prefixMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
+ const words = haystack.split(/[\s\-_/.:]+/);
+ const needleLower = needle.toLowerCase();
+ let offset = 0;
+
+ for (const word of words) {
+ if (word.toLowerCase().startsWith(needleLower)) {
+ const start = haystack.toLowerCase().indexOf(word.toLowerCase(), offset);
+ const positions = Array.from({ length: needle.length }, (_, i) => start + i);
+ let score = 1.0;
+ if (start === 0) score += 0.5;
+ return { score, positions };
+ }
+ offset += word.length + 1;
+ }
+ return null;
+}
+
+export function search(items: SearchItem[], query: string, method: SearchMethod): SearchResult[] {
+ if (!query) {
+ return items.map((item) => ({ item, score: 0, positions: [] }));
+ }
+
+ const matchFn = method === "fuzzy" ? fuzzyMatch
+ : method === "fulltext" ? fulltextMatch
+ : prefixMatch;
+
+ const results: SearchResult[] = [];
+
+ for (const item of items) {
+ const haystack = `${item.title} ${item.url}`;
+ const match = matchFn(query, haystack);
+ if (match) {
+ results.push({ item, score: match.score, positions: match.positions });
+ }
+ }
+
+ results.sort((a, b) => b.score - a.score);
+ return results;
+}