diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md
index 3b7cf6e..fbf8212 100644
--- a/.github/instructions/frontend.instructions.md
+++ b/.github/instructions/frontend.instructions.md
@@ -12,6 +12,11 @@ applyTo: "frontend/src/**"
- SSR safety: guard browser-only code with `typeof window !== "undefined"`.
- API behavior: PUT responses return only changed fields; filter by id uses `_id`; API requests reject non-2xx with `{ response, data }` and error payload in `error.data.error`.
+## Tailwind CSS
+
+- Always use canonical Tailwind utility classes instead of arbitrary values when a standard equivalent exists (e.g. `h-16.5` not `h-[66px]`, `min-h-3` not `min-h-[12px]`).
+- Only use arbitrary values (`[...]`) when no standard utility covers the needed value.
+
## i18n
- `svelte-i18n` is configured in `frontend/src/lib/i18n/index.ts` with lazy loading for locale files.
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 5eb43e1..63743fc 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -4,6 +4,8 @@
import { _, locale } from "./lib/i18n/index"
import LoadingBar from "./widgets/LoadingBar.svelte"
import ToastContainer from "./widgets/ToastContainer.svelte"
+ import DebugFooterInfo from "./widgets/DebugFooterInfo.svelte"
+ import { initScrollRestoration } from "./lib/navigation"
import {
SUPPORTED_LANGUAGES,
LANGUAGE_LABELS,
@@ -17,6 +19,8 @@
} from "./lib/i18n"
export let url = ""
+ initScrollRestoration()
+
if (url) {
// ssr
let l = url.split("?")
@@ -110,3 +114,7 @@
{$_("page.home.text")}
{/if}
+
+
diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts
new file mode 100644
index 0000000..0b645cb
--- /dev/null
+++ b/frontend/src/lib/navigation.ts
@@ -0,0 +1,195 @@
+/**
+ * SPA Navigation utilities
+ * Provides navigation functions that work with the history API
+ * and automatically update the location store.
+ */
+
+import { previousPath } from "./store"
+
+export type SpaNavigateOptions = {
+ /** Use replaceState instead of pushState (default: false) */
+ replace?: boolean
+ /** State object to pass to pushState/replaceState */
+ state?: Record | null
+ /** Skip scrolling to top (default: false) */
+ noScroll?: boolean
+}
+
+/**
+ * Initialize scroll restoration for SPA navigation.
+ * Call this once at app startup to enable automatic scroll position restoration
+ * when using browser back/forward buttons.
+ */
+export const initScrollRestoration = (): void => {
+ if (typeof window === "undefined") {
+ return // SSR guard
+ }
+
+ // Disable browser's automatic scroll restoration - we handle it manually
+ if ("scrollRestoration" in history) {
+ history.scrollRestoration = "manual"
+ }
+
+ // Restore scroll position on popstate (back/forward navigation)
+ window.addEventListener("popstate", (event) => {
+ const state = event.state
+ if (state?.scrollY !== undefined) {
+ // Use requestAnimationFrame to ensure DOM has updated
+ requestAnimationFrame(() => {
+ window.scrollTo(0, state.scrollY)
+ })
+ }
+ })
+}
+
+/**
+ * Navigate to a new URL within the SPA.
+ * Uses pushState by default (creates history entry).
+ * Set replace: true to use replaceState (no history entry).
+ * Automatically scrolls to top and saves scroll position for back navigation.
+ *
+ * @param url - The URL to navigate to (can be relative or absolute path)
+ * @param options - Navigation options
+ *
+ * @example
+ * // Navigate to /about (creates history entry)
+ * spaNavigate('/about')
+ *
+ * @example
+ * // Navigate with hash, replacing current entry
+ * spaNavigate('/search#q=test', { replace: true })
+ *
+ * @example
+ * // Navigate with state data
+ * spaNavigate('/product/123', { state: { from: 'search' } })
+ */
+export const spaNavigate = (url: string, options: SpaNavigateOptions = {}): void => {
+ if (typeof window === "undefined") {
+ return // SSR guard
+ }
+
+ const { replace = false, state = null, noScroll = false } = options
+
+ // Save current path to previousPath store before navigating
+ previousPath.set(window.location.pathname)
+
+ // Save current scroll position in current history entry before navigating
+ const currentScrollY = window.scrollY
+ const currentState = history.state || {}
+ history.replaceState({ ...currentState, scrollY: currentScrollY }, "")
+
+ // Merge user state with scroll position (for new page, start at top)
+ const newState = { ...state, scrollY: 0 }
+
+ // Normalize relative URLs to absolute paths
+ let finalUrl = url
+ if (
+ !url.startsWith("/") &&
+ !url.startsWith("http") &&
+ !url.startsWith("mailto:") &&
+ !url.startsWith("tel:") &&
+ !url.startsWith("#")
+ ) {
+ finalUrl = "/" + url
+ }
+
+ if (replace) {
+ window.history.replaceState(newState, "", finalUrl)
+ } else {
+ window.history.pushState(newState, "", finalUrl)
+ }
+
+ // Scroll to top for new navigation (unless noScroll is set)
+ if (!noScroll) {
+ window.scrollTo(0, 0)
+ }
+}
+
+/**
+ * Parse hash parameters from a URL hash string.
+ * @param hash - The hash string (with or without leading #)
+ * @returns URLSearchParams-like object for easy access
+ */
+export const parseHashParams = (hash: string): URLSearchParams => {
+ const cleanHash = hash.startsWith("#") ? hash.slice(1) : hash
+ return new URLSearchParams(cleanHash)
+}
+
+/**
+ * Build a hash string from key-value pairs.
+ * @param params - Object with parameter key-value pairs
+ * @returns Hash string with leading #, or empty string if no params
+ */
+export const buildHashString = (params: Record): string => {
+ const parts: string[] = []
+
+ for (const [key, value] of Object.entries(params)) {
+ if (value === null || value === undefined || value === "") {
+ continue
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length > 0) {
+ parts.push(`${encodeURIComponent(key)}=${value.map(encodeURIComponent).join(",")}`)
+ }
+ } else {
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
+ }
+ }
+
+ return parts.length > 0 ? `#${parts.join("&")}` : ""
+}
+
+/**
+ * Svelte action for SPA link navigation.
+ * Intercepts clicks on anchor elements and uses spaNavigate instead of full page reload.
+ *
+ * @param node - The anchor element to enhance
+ * @param options - Navigation options passed to spaNavigate
+ *
+ * @example
+ * Products
+ *
+ * @example
+ * // With options
+ * Search
+ */
+export const spaLink = (node: HTMLAnchorElement, options: SpaNavigateOptions = {}) => {
+ const handleClick = (event: MouseEvent) => {
+ // Allow normal behavior for modifier keys (open in new tab, etc.)
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
+ return
+ }
+
+ // Only handle left clicks
+ if (event.button !== 0) {
+ return
+ }
+
+ const href = node.getAttribute("href")
+
+ // Skip external links or special protocols
+ if (!href || href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("tel:")) {
+ return
+ }
+
+ // Skip if target is set (e.g., _blank)
+ if (node.target && node.target !== "_self") {
+ return
+ }
+
+ event.preventDefault()
+ spaNavigate(href, options)
+ }
+
+ node.addEventListener("click", handleClick)
+
+ return {
+ update(newOptions: SpaNavigateOptions) {
+ options = newOptions
+ },
+ destroy() {
+ node.removeEventListener("click", handleClick)
+ },
+ }
+}
diff --git a/frontend/src/widgets/Button.svelte b/frontend/src/widgets/Button.svelte
new file mode 100644
index 0000000..27e3a7a
--- /dev/null
+++ b/frontend/src/widgets/Button.svelte
@@ -0,0 +1,59 @@
+
+
+
diff --git a/frontend/src/widgets/Carousel.svelte b/frontend/src/widgets/Carousel.svelte
new file mode 100644
index 0000000..478e608
--- /dev/null
+++ b/frontend/src/widgets/Carousel.svelte
@@ -0,0 +1,114 @@
+
+
+