UNPKG

stylescape

Version:

Stylescape is a visual identity framework developed by Scape Agency.

1,684 lines (1,663 loc) 189 kB
// src/ts/state/StateManager.ts var StateManager = class { /** * Toggles a specified class on an element. * Logs a warning if the element is not found. * * @param element - The DOM element to toggle the class on * @param className - The CSS class to toggle (default: "active") */ toggleClass(element, className = "active") { if (!element) { console.warn(`Element: '${element}' not found`); return; } element.classList.toggle(className); } }; // src/ts/storage/LocalStorageManager.ts var LocalStorageManager = class _LocalStorageManager { /** * Get the singleton instance of LocalStorageManager. * Creates the instance if it doesn't exist. * * @returns The singleton LocalStorageManager instance */ static getInstance() { if (!_LocalStorageManager.instance) { _LocalStorageManager.instance = new _LocalStorageManager(); } return _LocalStorageManager.instance; } /** * Stores a value in localStorage or fallback storage. * @param key The storage key. * @param value The value to store. */ setValue(key, value) { try { if (localStorage) { localStorage.setItem(key, String(value)); } else { } } catch (error) { console.error("Error saving to localStorage:", error); } } /** * Retrieves a value from localStorage or fallback storage. * @param key The storage key. * @returns The retrieved value or null if not found. */ // getValue<T>(key: string): T | null { getValue(key) { try { if (localStorage) { return localStorage.getItem(key); } else { return null; } } catch (error) { console.error("Error reading from localStorage:", error); return null; } } /** * Removes a value from localStorage. * @param key The storage key. */ removeValue(key) { try { if (localStorage) { localStorage.removeItem(key); } else { } } catch (error) { console.error("Error removing from localStorage:", error); } } /** * Clears all values in localStorage. */ clearStorage() { try { if (localStorage) { localStorage.clear(); } else { } } catch (error) { console.error("Error clearing localStorage:", error); } } }; // src/ts/elements/AsideHandler.ts var AsideHandler = class _AsideHandler { /** * Creates a new AsideHandler instance. * * @param menuId - ID of the aside menu element * @param switchId - ID of the toggle button element */ constructor(menuId, switchId) { /** Reference to the aside menu element */ this.asideMenu = null; /** Reference to the toggle switch element */ this.asideSwitch = null; /** Current visibility state */ this.asideMenuActive = _AsideHandler.HIDDEN_STATE; this.localStorageManager = LocalStorageManager.getInstance(); this.stateManager = new StateManager(); this.menuId = menuId; this.switchId = switchId; this.assertMenu(); this.setupToggleListener(); this.updateStateMenu(); } static { /** CSS class applied when menu is visible */ this.VISIBLE_CLASS = "active"; } static { /** LocalStorage key suffix for visibility state */ this.VISIBLE_SUFFIX = "_visibility"; } static { /** State value for visible menu */ this.VISIBLE_STATE = "show"; } static { /** State value for hidden menu */ this.HIDDEN_STATE = "hide"; } /** * Queries and caches DOM references for menu and switch elements. */ assertMenu() { this.asideMenu = document.getElementById(this.menuId); this.asideSwitch = document.getElementById(this.switchId); } /** * Sets up click event listener on the toggle switch. */ setupToggleListener() { if (this.asideSwitch) { this.asideSwitch.addEventListener( "click", () => this.toggleMenu() ); } } /** * Toggles the menu between visible and hidden states. */ toggleMenu() { this.assertMenu(); if (this.asideMenu?.classList.contains(_AsideHandler.VISIBLE_CLASS)) { this.hideMenu(); } else { this.showMenu(); } } /** * Shows the menu and persists the state to localStorage. */ showMenu() { this.assertMenu(); this.localStorageManager.setValue( this.menuId + _AsideHandler.VISIBLE_SUFFIX, _AsideHandler.VISIBLE_STATE ); this.updateStateMenu(); } /** * Hides the menu and persists the state to localStorage. */ hideMenu() { this.assertMenu(); this.localStorageManager.setValue( this.menuId + _AsideHandler.VISIBLE_SUFFIX, _AsideHandler.HIDDEN_STATE ); this.updateStateMenu(); } /** * Updates the visual state of the menu based on stored preference. * Applies or removes the visible class on both menu and switch. */ updateStateMenu() { this.assertMenu(); if (!this.asideMenu) return; this.asideMenuActive = this.localStorageManager.getValue( this.menuId + _AsideHandler.VISIBLE_SUFFIX ) || this.asideMenuActive; const isVisible = this.asideMenuActive === _AsideHandler.VISIBLE_STATE; this.asideMenu.classList.toggle(_AsideHandler.VISIBLE_CLASS, isVisible); this.asideSwitch?.classList.toggle( _AsideHandler.VISIBLE_CLASS, isVisible ); } }; // src/ts/elements/CollapsibleTableHandler.ts var CollapsibleTableHandler = class { constructor(containerSelector = ".collapsible_table") { const tables = document.querySelectorAll(containerSelector); tables.forEach((table) => { const header = table.querySelector( ".collapsible_table--header" ); const content = table.querySelector( ".collapsible_table--content" ); const flipper = table.querySelector( ".flipper--down, .flipper--up" ); if (!header || !content) return; header.addEventListener("click", () => { content.classList.toggle("expanded"); if (flipper) { flipper.classList.toggle("flipper--up"); flipper.classList.toggle("flipper--down"); } }); }); } }; // src/ts/elements/DetailManager.ts var DetailManager = class { /** * Initializes the DetailManager. * Selects all <details> elements and attaches a single document-level listener. * Excludes sidebar accordions from the exclusive behavior. * * @param selector - CSS selector for <details> elements (default: "details:not(.sidebar__accordion)"). */ constructor(selector = "details:not(.sidebar__accordion)") { this.details = document.querySelectorAll(selector); this.boundHandler = this.handleClick.bind(this); document.addEventListener("click", this.boundHandler); } /** * Handles clicks: * - If clicking a <summary>: closes all others, keeps only that one open. * - If clicking outside any <details>: closes all. */ handleClick(event) { const target = event.target; const summary = target.closest("summary"); const parent = summary?.parentElement; if (parent && parent.tagName === "DETAILS") { this.details.forEach((detail) => { if (detail !== parent) detail.removeAttribute("open"); }); } else { this.details.forEach((detail) => detail.removeAttribute("open")); } } /** * Toggles a specific <details> element open or closed. */ toggle(detail, open) { if (open) detail.setAttribute("open", ""); else detail.removeAttribute("open"); } /** * Cleans up the event listener (useful for SPA cleanup). */ destroy() { document.removeEventListener("click", this.boundHandler); } }; // src/ts/elements/DropdownHandler.ts var DropdownHandler = class { constructor(containerSelector = ".select_dropdown") { this.dropdowns = document.querySelectorAll(containerSelector); if (this.dropdowns.length === 0) return; this.initializeDropdowns(); this.setupGlobalClickListener(); } initializeDropdowns() { this.dropdowns.forEach((dropdown) => { const header = dropdown.querySelector( ".select_dropdown--header" ); const menu = dropdown.querySelector( ".select_dropdown--menu.dropdown--collapse" ); const checkboxes = dropdown.querySelectorAll( 'input[type="checkbox"]' ); if (!header) return; header.addEventListener("click", () => { if (menu) { menu.classList.toggle("active"); } const flipper = header.querySelector( ".flipper--down, .flipper--up" ); if (flipper) { flipper.classList.toggle("flipper--down"); flipper.classList.toggle("flipper--up"); } }); checkboxes.forEach((checkbox) => { checkbox.addEventListener("change", () => { this.updateSelectionLabel(dropdown); }); }); this.updateSelectionLabel(dropdown); }); } updateSelectionLabel(dropdown) { const countSpan = dropdown.querySelector("#selected-count"); const checkboxes = dropdown.querySelectorAll( 'input[type="checkbox"]' ); if (!countSpan || checkboxes.length === 0) return; const selected = Array.from(checkboxes).filter( (cb) => cb.checked ).length; countSpan.textContent = `Selected: ${selected} option${selected !== 1 ? "s" : ""}`; } setupGlobalClickListener() { document.addEventListener("click", (e) => { this.dropdowns.forEach((dropdown) => { const menu = dropdown.querySelector( ".dropdown--collapse" ); if (menu && !dropdown.contains(e.target)) { menu.classList.remove("active"); } }); }); } }; // src/ts/elements/ExclusiveDetails.ts var ExclusiveDetails = class { constructor(selector) { this.detailsElements = document.querySelectorAll(selector); this.bindEvents(); } bindEvents() { this.detailsElements.forEach((details) => { details.addEventListener( "click", (e) => this.handleDetailsClick(e, details) ); }); document.addEventListener("click", (e) => this.handleClickOutside(e)); } handleDetailsClick(event, details) { event.stopPropagation(); this.detailsElements.forEach((otherDetails) => { if (otherDetails !== details) { otherDetails.removeAttribute("open"); } }); } handleClickOutside(event) { const target = event.target; const isClickInside = Array.from(this.detailsElements).some( (details) => details.contains(target) ); if (!isClickInside) { this.closeAll(); } } closeAll() { this.detailsElements.forEach((details) => { details.removeAttribute("open"); }); } }; // src/ts/elements/PasswordToggleManager.ts var PasswordToggleManager = class { /** * Creates a new PasswordToggleManager instance. * * @param selector - CSS selector for toggle buttons (default: "[data-password-toggle]") */ constructor(selector = "[data-password-toggle]") { this.selector = selector; this.init(); } /** * Initializes toggle functionality for all matching buttons. */ init() { document.querySelectorAll(this.selector).forEach((button) => { const inputId = button.dataset.passwordToggle; if (!inputId) return; const input = document.getElementById( inputId ); if (!input || input.type !== "password") return; button.addEventListener( "click", () => this.togglePasswordVisibility(input, button) ); }); } /** * Toggles password visibility for an input field. * * @param input - The password input element * @param button - The toggle button element */ togglePasswordVisibility(input, button) { const isText = input.type === "text"; input.type = isText ? "password" : "text"; button.classList.toggle("is-visible", !isText); button.setAttribute("aria-pressed", String(!isText)); } }; // src/ts/media/ImageCompareSlider.ts var ImageCompareSlider = class _ImageCompareSlider { /** * Creates a new ImageCompareSlider instance. * * @param container - The container element holding both images and slider */ constructor(container) { /** Whether the slider is currently being dragged */ this.isActive = false; this.container = container; this.slider = container.querySelector( ".image__compare--slider" ); this.overlay = container.querySelector( ".image__compare--overlay" ); this.baseImage = container.querySelector( "img.image__compare--image:not(.image__compare--overlay)" ); if (!this.container || !this.slider || !this.overlay || !this.baseImage) { console.warn( `ImageCompareSlider skipped: required elements not found in`, container ); return; } this.checkAndInject(this.baseImage); this.checkAndInject(this.overlay); this.initEvents(); this.slideMove(this.container.offsetWidth / 2); } /** * Checks image brightness and injects dark mode indicators if needed. * * @param image - The image element to check */ checkAndInject(image) { const side = image.dataset.darkSide; if (!side) return; const inject = () => { this.isImageBright(image).then((isBright) => { if (!isBright) return; const el = document.createElement("div"); el.className = `dark--${side}`; this.slider.appendChild(el); const arrow = this.slider.querySelector( `.arrow--${side}` ); if (arrow) { arrow.style.borderColor = "var(--color_text_primary)"; } }).catch((err) => { console.warn("Brightness check failed:", err); }); }; if (image.complete && image.naturalWidth > 0) { inject(); } else { image.onload = () => { if (image.naturalWidth > 0) inject(); }; } } /** * Analyzes image brightness using canvas pixel sampling. * * @param image - The image element to analyze * @returns Promise resolving to true if image is bright (avg > 160) */ isImageBright(image) { return new Promise((resolve) => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return resolve(false); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; ctx.drawImage(image, 0, 0); const data = ctx.getImageData( 0, 0, canvas.width, canvas.height ).data; let r = 0, g = 0, b = 0, count = 0; const step = 4 * 20; for (let i = 0; i < data.length; i += step) { r += data[i]; g += data[i + 1]; b += data[i + 2]; count++; } const avg = (r + g + b) / (3 * count); resolve(avg > 160); }); } /** * Initializes mouse and touch event listeners for drag interaction. */ initEvents() { this.slider.addEventListener( "mousedown", () => this.isActive = true ); window.addEventListener("mouseup", () => this.isActive = false); window.addEventListener("mousemove", (e) => { if (this.isActive) this.slideMove(e.clientX); }); this.slider.addEventListener( "touchstart", () => this.isActive = true ); window.addEventListener("touchend", () => this.isActive = false); window.addEventListener("touchmove", (e) => { if (this.isActive) this.slideMove(e.touches[0].clientX); }); } /** * Moves the slider and adjusts overlay width based on position. * * @param x - The x-coordinate (client position) to move to */ slideMove(x) { const bounds = this.container.getBoundingClientRect(); let pos = x - bounds.left; pos = Math.max(0, Math.min(pos, bounds.width)); this.overlay.style.width = `${pos}px`; this.slider.style.left = `${pos}px`; } /** * Static factory method to initialize all image compare sliders on the page. * * @param selector - CSS selector for container elements (default: ".image__compare") */ static initAll(selector = ".image__compare") { const containers = document.querySelectorAll(selector); containers.forEach((container) => { new _ImageCompareSlider(container); }); } }; // src/ts/content/ActiveLinkHighlighter.ts var ActiveLinkHighlighter = class { /** * Creates a new ActiveLinkHighlighter instance. * * @param activeClass - CSS class to apply to matching links (default: "active") */ constructor(activeClass = "active") { this.activeClass = activeClass; this.highlightAllLinks(); } /** * Normalizes a URL by extracting and cleaning the pathname and search. * * @param url - The URL to normalize * @returns The normalized path including query parameters */ normalizeUrl(url) { const a = document.createElement("a"); a.href = url; const pathname = a.pathname.replace(/\/+$/, ""); return pathname + a.search; } /** * Highlights all links on the page that match the current URL. * Skips links inside ribbon titles and links without href attributes. */ highlightAllLinks() { const currentPath = this.normalizeUrl(window.location.href); const links = document.querySelectorAll("a"); links.forEach((link) => { if (link.closest(".ribbon__title")) { return; } if (!link.hasAttribute("href") || !link.getAttribute("href")) { return; } const linkPath = this.normalizeUrl(link.href); if (linkPath === currentPath) { link.classList.add(this.activeClass); } }); } }; // src/ts/scroll/ScrollSpyManager.ts var ScrollSpyManager = class _ScrollSpyManager { constructor(options = {}) { this.sections = []; this.navLinks = []; this.ticking = false; this.currentActiveId = null; this.handleScroll = () => { if (!this.ticking) { window.requestAnimationFrame(() => { this.updateActiveLink(); this.ticking = false; }); this.ticking = true; } }; this.handleLinkClick = (event) => { const link = event.currentTarget; const href = link.getAttribute("href"); if (href?.startsWith("#")) { event.preventDefault(); const sectionId = href.slice(1); this.scrollTo(sectionId); if (this.options.updateHistory) { history.pushState(null, "", href); } } }; this.options = { navSelector: options.navSelector ?? "[data-ss-scrollspy-link], .scrollspy-link", containerId: options.containerId ?? "", threshold: options.threshold ?? 0.5, offset: options.offset ?? 0, activeClass: options.activeClass ?? "active", activeParentClass: options.activeParentClass ?? "active", smoothScroll: options.smoothScroll ?? true, onChange: options.onChange ?? (() => { }), updateHistory: options.updateHistory ?? false }; this.scrollContainer = this.options.containerId ? document.getElementById(this.options.containerId) ?? window : window; this.init(); } // For backwards compatibility with old constructor static fromElements(sections, navLinksSelector, containerId, thresholdOffset = 0.5) { const instance = new _ScrollSpyManager({ navSelector: navLinksSelector, containerId, threshold: thresholdOffset }); instance.sections = sections; instance.updateActiveLink(); return instance; } // ======================================================================== // Public Methods // ======================================================================== /** * Manually refresh sections and links */ refresh() { this.findSections(); this.updateActiveLink(); } /** * Scroll to a specific section */ scrollTo(sectionId) { const section = document.getElementById(sectionId); if (!section) return; const top = section.offsetTop - this.options.offset; if (this.scrollContainer instanceof Window) { window.scrollTo({ top, behavior: this.options.smoothScroll ? "smooth" : "auto" }); } else { this.scrollContainer.scrollTo({ top, behavior: this.options.smoothScroll ? "smooth" : "auto" }); } } /** * Get current active section ID */ getActive() { return this.currentActiveId; } /** * Destroy the scroll spy */ destroy() { const container = this.scrollContainer instanceof Window ? window : this.scrollContainer; container.removeEventListener("scroll", this.handleScroll); this.navLinks.forEach((link) => { link.removeEventListener("click", this.handleLinkClick); }); this.sections = []; this.navLinks = []; } // ======================================================================== // Static Factory // ======================================================================== /** * Initialize scroll spy from data-ss="scrollspy" */ static init() { const managers = []; document.querySelectorAll('[data-ss="scrollspy"]').forEach((el) => { const navSelector = el.dataset.ssScrollspyNav || `#${el.id} a`; const threshold = el.dataset.ssScrollspyThreshold; const smooth = el.dataset.ssScrollspySmooth !== "false"; const offset = el.dataset.ssScrollspyOffset; managers.push( new _ScrollSpyManager({ navSelector, threshold: threshold ? parseFloat(threshold) : void 0, smoothScroll: smooth, offset: offset ? parseInt(offset, 10) : void 0 }) ); }); return managers; } // ======================================================================== // Private Methods // ======================================================================== init() { this.findSections(); this.bindScrollListener(); this.bindLinkListeners(); this.updateActiveLink(); } findSections() { this.navLinks = Array.from( document.querySelectorAll(this.options.navSelector) ); this.sections = []; this.navLinks.forEach((link) => { const href = link.getAttribute("href"); if (href?.startsWith("#")) { const sectionId = href.slice(1); const section = document.getElementById(sectionId); if (section && !this.sections.includes(section)) { this.sections.push(section); } } }); } bindScrollListener() { const container = this.scrollContainer instanceof Window ? window : this.scrollContainer; container.addEventListener("scroll", this.handleScroll, { passive: true }); } bindLinkListeners() { this.navLinks.forEach((link) => { link.addEventListener("click", this.handleLinkClick); }); } updateActiveLink() { if (this.sections.length === 0 || this.navLinks.length === 0) return; const scrollY = this.scrollContainer instanceof Window ? window.scrollY : this.scrollContainer.scrollTop; let activeId = null; for (const section of this.sections) { const id = section.getAttribute("id"); if (!id) continue; const top = section.offsetTop - this.options.offset; const height = section.offsetHeight; const threshold = top - height * this.options.threshold; if (scrollY >= threshold) { activeId = id; } } if (activeId === this.currentActiveId) return; this.currentActiveId = activeId; let activeLink = null; this.navLinks.forEach((link) => { const targetId = link.getAttribute("href")?.replace("#", ""); const isActive = targetId === activeId; link.classList.toggle(this.options.activeClass, isActive); link.setAttribute("aria-current", isActive ? "true" : "false"); if (isActive) { activeLink = link; } this.updateParentClasses(link, isActive); }); this.options.onChange(activeId, activeLink); } updateParentClasses(link, isActive) { let parent = link.parentElement; while (parent && parent !== document.body) { if (parent.tagName === "LI") { parent.classList.toggle( this.options.activeParentClass, isActive ); } parent = parent.parentElement; } } }; // src/ts/content/TableOfContentsBuilder.ts var TableOfContentsBuilder = class { /** * Creates a new TableOfContentsBuilder instance. * * @param rootId - ID of the element containing content sections * @param tocContainerId - ID of the element to append the TOC to */ constructor(rootId, tocContainerId) { /** Set of generated IDs to ensure uniqueness */ this.idSet = /* @__PURE__ */ new Set(); /** Map linking TOC anchor elements to their target sections */ this.linkSectionMap = /* @__PURE__ */ new Map(); this.rootId = rootId; this.tocContainerId = tocContainerId; } /** * Generates a unique ID from a base string. * Handles collisions by appending a numeric suffix. * * @param baseId - The base string to generate an ID from * @returns A unique, URL-safe ID string */ generateUniqueId(baseId) { let id = baseId.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, ""); let count = 1; while (this.idSet.has(id)) { id = `${baseId}-${count++}`; } this.idSet.add(id); return id; } /** * Creates a TOC list item entry for a section element. * * @param element - The section element to create an entry for * @returns An HTMLLIElement containing the anchor link */ createTOCEntry(element) { const text = element.getAttribute("data-label") || "Untitled"; const id = this.generateUniqueId(text); element.id = id; const a = document.createElement("a"); a.href = `#${id}`; a.textContent = text; const li = document.createElement("li"); li.appendChild(a); this.linkSectionMap.set(a, element); return li; } /** * Recursively builds the TOC tree structure from nested elements. * * @param element - The parent element to traverse * @returns An HTMLUListElement containing the nested TOC structure */ buildTOCTree(element) { const ul = document.createElement("ul"); Array.from(element.children).forEach((child) => { if (!(child instanceof HTMLElement)) return; if (child.hasAttribute("data-label")) { const li = this.createTOCEntry(child); const nestedUL = this.buildTOCTree(child); if (nestedUL.children.length > 0) { li.appendChild(nestedUL); } ul.appendChild(li); } else { const nested = this.buildTOCTree(child); if (nested.children.length > 0) { ul.append(...Array.from(nested.children)); } } }); return ul; } /** * Builds the TOC tree and appends it to the container element. * Also initializes ScrollSpyManager for active link tracking. */ buildAndAppendTOC() { const root = document.getElementById(this.rootId); const tocContainer = document.getElementById(this.tocContainerId); if (!root || !tocContainer) return; const tocTree = this.buildTOCTree(root); tocContainer.innerHTML = ""; tocContainer.appendChild(tocTree); this.scrollSpyManager = ScrollSpyManager.fromElements( Array.from(this.linkSectionMap.values()), `#${this.tocContainerId} a`, this.rootId ); } /** * Returns the map of TOC links to their corresponding content sections. * Useful for custom scroll spy implementations or section tracking. * * @returns A Map with anchor elements as keys and section elements as values */ getLinkSectionMap() { return this.linkSectionMap; } }; // src/ts/utilities/ClipboardHelper.ts var ClipboardHelper = class _ClipboardHelper { /** * Copy code content from a sibling <code> element (original version). * * @param button - The button element that triggered the copy */ static copyCodeFromButton(button) { const nextElement = button.nextElementSibling; if (!(nextElement instanceof HTMLElement)) return; const code = nextElement.innerText; navigator.clipboard.writeText(code).then(() => { button.textContent = "Copied!"; setTimeout(() => { button.textContent = "Copy"; }, 1500); }); } /** * Attach event listeners to all copy buttons matching the selector. * Each button will copy content from its next sibling element. * * @param selector - CSS selector for copy buttons (default: '.copy-button') */ static attachToButtons(selector = ".copy-button") { const buttons = document.querySelectorAll(selector); buttons.forEach((button) => { button.addEventListener( "click", () => _ClipboardHelper.copyCodeFromButton(button) ); }); } /** * Copy content from an element by its ID and update the triggering button. * Finds the associated button using onclick attribute matching. * * @param codeId - The ID of the element containing text to copy */ static copyById(codeId) { const codeElement = document.getElementById(codeId); if (!(codeElement instanceof HTMLElement)) { console.warn(`Code element with ID "${codeId}" not found.`); return; } const text = codeElement.innerText.trim(); navigator.clipboard.writeText(text).then(() => { const button = document.querySelector( `button[onclick*="${codeId}"]` ); if (button) { const original = button.textContent; button.textContent = "Copied!"; setTimeout(() => { button.textContent = original; }, 1500); } }).catch((err) => { console.error("Failed to copy text:", err); }); } }; // src/ts/utilities/GridManager.ts var GridManager = class { constructor() { this.STORAGE_KEY = "unitgl:grid:visibility"; this.visibilityMap = {}; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => this.init()); } else { this.init(); } } init() { this.loadVisibility(); this.applyVisibilityState(); this.setupToggleButtons(); this.updateAllGridHeights(); window.addEventListener("resize", () => this.updateAllGridHeights()); window.addEventListener("scroll", () => this.updateAllGridHeights()); } loadVisibility() { try { this.visibilityMap = JSON.parse( localStorage.getItem(this.STORAGE_KEY) || "{}" ); } catch { this.visibilityMap = {}; } } saveVisibility() { localStorage.setItem( this.STORAGE_KEY, JSON.stringify(this.visibilityMap) ); } updateAllGridHeights() { const height = Math.max( document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight, document.body.offsetHeight ); document.querySelectorAll(".guide--layer").forEach((layer) => { if (layer.offsetHeight !== height) { layer.style.height = `${height}px`; } }); } applyVisibilityState() { document.querySelectorAll(".guide--layer").forEach((layer) => { const id = layer.dataset.grid; const isActive = !!this.visibilityMap[id]; layer.classList.toggle("active", isActive); }); document.querySelectorAll("button[data-toggle]").forEach((button) => { const id = button.dataset.toggle; const isActive = !!this.visibilityMap[id]; button.classList.toggle("active", isActive); }); } setupToggleButtons() { document.querySelectorAll("button[data-toggle]").forEach((button) => { const id = button.dataset.toggle; const layer = document.querySelector( `[data-grid="${id}"]` ); if (!layer) return; button.addEventListener("click", () => { const isNowActive = layer.classList.toggle("active"); button.classList.toggle("active", isNowActive); this.visibilityMap[id] = isNowActive; this.saveVisibility(); }); }); } setupEventListeners() { document.addEventListener("DOMContentLoaded", () => { this.updateAllGridHeights(); this.setupToggleButtons(); window.addEventListener( "resize", () => this.updateAllGridHeights() ); window.addEventListener( "scroll", () => this.updateAllGridHeights() ); }); } }; // src/ts/utilities/ThemeToggler.ts var ThemeToggler = class _ThemeToggler { static { this.THEME_ATTRIBUTE = "theme"; } static { this.DARK_THEME = "dark"; } static { this.LIGHT_THEME = "light"; } static { this.STORAGE_KEY = "preferredTheme"; } static { this.htmlElement = document.documentElement; } constructor() { } /** * Toggle between dark and light themes. * Updates both the DOM attribute and localStorage. */ static toggle() { const newTheme = _ThemeToggler.getCurrentTheme() === _ThemeToggler.DARK_THEME ? _ThemeToggler.LIGHT_THEME : _ThemeToggler.DARK_THEME; _ThemeToggler.setTheme(newTheme); } /** * Set theme explicitly to a specific value. * * @param theme - The theme to set ("dark" or "light") */ static setTheme(theme) { _ThemeToggler.htmlElement.dataset[_ThemeToggler.THEME_ATTRIBUTE] = theme; localStorage.setItem(_ThemeToggler.STORAGE_KEY, theme); } /** * Get the currently active theme. * Checks DOM attribute first, then localStorage, defaults to light. * * @returns The current theme ("dark" or "light") */ static getCurrentTheme() { return _ThemeToggler.htmlElement.dataset[_ThemeToggler.THEME_ATTRIBUTE] || localStorage.getItem(_ThemeToggler.STORAGE_KEY) || _ThemeToggler.LIGHT_THEME; } /** * Sync the toggle input checkbox state with the current theme. * * @param toggle - The checkbox input element to sync */ static syncToggleState(toggle) { const currentTheme = _ThemeToggler.getCurrentTheme(); toggle.checked = currentTheme === _ThemeToggler.DARK_THEME; } /** * Initialize a toggle switch (input[type=checkbox]) by ID or data attribute * @param toggleId The ID of the toggle (default: 'themeToggle') */ static initializeToggleSwitch(toggleId = "themeToggle") { let toggle = document.getElementById( toggleId ); if (!toggle) { toggle = document.querySelector( "[data-theme-toggle]" ); } if (!toggle) { return; } _ThemeToggler.syncToggleState(toggle); toggle.addEventListener("change", () => { _ThemeToggler.toggle(); }); } /** * Register initialization to occur after full page load * Recommended if HTML elements may load later */ static registerOnLoad(toggleId = "themeToggle") { window.addEventListener("load", () => { _ThemeToggler.initializeToggleSwitch(toggleId); }); } }; // src/ts/scroll/ScrollElementManager.ts var ScrollElementManager = class { constructor(contentSelector, storageKey = "scrollpos", debug = false) { this.contentElement = document.querySelector(contentSelector); this.storageKey = storageKey; this.debug = debug; if (!this.contentElement) { if (this.debug) console.warn( "ScrollElementManager: Element not found:", contentSelector ); return; } this.initialize(); } initialize() { window.addEventListener("load", this.loadScrollPosition.bind(this)); this.contentElement?.addEventListener( "scroll", this.updateScrollPosition.bind(this) ); if (this.debug) console.log("ScrollElementManager initialized."); } loadScrollPosition() { try { const scrollpos = sessionStorage.getItem(this.storageKey); if (scrollpos !== null && this.contentElement) { this.contentElement.scrollTop = parseInt(scrollpos, 10); sessionStorage.removeItem(this.storageKey); if (this.debug) console.log("Scroll position restored:", scrollpos); } } catch (err) { if (this.debug) console.error( "ScrollElementManager error loading scroll position:", err ); } } updateScrollPosition() { try { if (this.contentElement) { sessionStorage.setItem( this.storageKey, this.contentElement.scrollTop.toString() ); if (this.debug) console.log( "Scroll position saved:", this.contentElement.scrollTop ); } } catch (err) { if (this.debug) console.error( "ScrollElementManager error saving scroll position:", err ); } } }; // src/ts/scroll/ScrollPageManager.ts var ScrollPageManager = class { constructor() { this.key = "scrollpos"; this.debounceTimeout = null; this.initialize(); } /** * Sets up scroll position tracking and restoration. */ initialize() { window.addEventListener("load", () => this.loadScrollPosition()); window.addEventListener("scroll", () => this.debounceSaveScroll()); window.addEventListener( "beforeunload", () => this.saveScrollPosition() ); } /** * Restores scroll position from sessionStorage and clears it. */ loadScrollPosition() { const scrollpos = sessionStorage.getItem(this.key); if (scrollpos) { window.scrollTo(0, parseInt(scrollpos, 10)); sessionStorage.removeItem(this.key); } } /** * Saves scroll position to sessionStorage. */ saveScrollPosition() { sessionStorage.setItem(this.key, window.scrollY.toString()); } /** * Debounced scroll saving to reduce writes. */ debounceSaveScroll() { if (this.debounceTimeout !== null) { clearTimeout(this.debounceTimeout); } this.debounceTimeout = window.setTimeout(() => { this.saveScrollPosition(); }, 200); } }; // src/ts/storage/AccordionState.ts var AccordionState = class { /** * Creates an AccordionState manager * @param selector - CSS selector for the accordion elements (must be <details>) * @param storageKey - Key to use for localStorage (default: 'accordion-state') */ constructor(selector = "details.sidebar__accordion", storageKey = "accordion-state") { this.selector = selector; this.storageKey = storageKey; this.accordions = document.querySelectorAll( this.selector ); this.init(); } /** * Initialize the accordion state manager */ init() { this.restoreState(); this.attachListeners(); } /** * Generate a unique ID for an accordion based on its position and content */ getAccordionId(accordion, index) { const summary = accordion.querySelector("summary"); const heading = summary?.querySelector("h2, h3"); if (accordion.id) { return accordion.id; } if (heading?.textContent) { return heading.textContent.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } return `accordion-${index}`; } /** * Get the current state of all accordions */ getState() { const state = {}; this.accordions.forEach((accordion, index) => { const id = this.getAccordionId(accordion, index); state[id] = accordion.open; }); return state; } /** * Save the current state to localStorage */ saveState() { try { const state = this.getState(); localStorage.setItem(this.storageKey, JSON.stringify(state)); } catch (e) { console.warn("AccordionState: Could not save state", e); } } /** * Restore the state from localStorage */ restoreState() { try { const savedState = localStorage.getItem(this.storageKey); if (!savedState) { return; } const state = JSON.parse(savedState); this.accordions.forEach((accordion, index) => { const id = this.getAccordionId(accordion, index); if (id in state) { accordion.open = state[id]; } }); } catch (e) { console.warn("AccordionState: Could not restore state", e); } } /** * Attach toggle listeners to all accordions */ attachListeners() { this.accordions.forEach((accordion) => { accordion.addEventListener("toggle", () => { this.saveState(); }); }); } /** * Clear the saved state */ clearState() { try { localStorage.removeItem(this.storageKey); } catch (e) { console.warn("AccordionState: Could not clear state", e); } } /** * Expand all accordions */ expandAll() { this.accordions.forEach((accordion) => { accordion.open = true; }); this.saveState(); } /** * Collapse all accordions */ collapseAll() { this.accordions.forEach((accordion) => { accordion.open = false; }); this.saveState(); } }; // src/ts/utilities/FontPreview.ts var FontPreview = class { constructor(inputSelector, previewSelector) { const input = document.querySelector(inputSelector); if (!input) throw new Error(`Input element "${inputSelector}" not found`); this.inputElement = input; this.previewElements = Array.from( document.querySelectorAll(previewSelector) ); this.initialize(); } initialize() { this.inputElement.addEventListener( "input", () => this.updatePreviewText() ); this.updatePreviewText(); } updatePreviewText() { const value = this.inputElement.value || "The quick brown fox jumps over the lazy dog."; this.previewElements.forEach((el) => { el.textContent = value; }); } }; // src/ts/animations/ContentRevealer.ts var ContentRevealer = class { constructor(selectorOrElements, options = {}) { this.observer = null; if (typeof selectorOrElements === "string") { this.elements = Array.from( document.querySelectorAll(selectorOrElements) ); } else if (selectorOrElements instanceof HTMLElement) { this.elements = [selectorOrElements]; } else { this.elements = Array.from(selectorOrElements); } this.options = { delay: options.delay ?? 0, duration: options.duration ?? 500, easing: options.easing ?? "ease", initialOpacity: options.initialOpacity ?? 0, onScroll: options.onScroll ?? false, threshold: options.threshold ?? 0.1 }; this.init(); } // ======================================================================== // Public Methods // ======================================================================== /** * Manually reveal all elements */ revealAll() { this.elements.forEach((el) => this.reveal(el)); } /** * Reveal a specific element */ reveal(element) { element.style.transition = `opacity ${this.options.duration}ms ${this.options.easing}`; element.style.opacity = "1"; element.classList.add("reveal--visible"); element.setAttribute("data-ss-reveal-revealed", "true"); } /** * Reset element to hidden state */ hide(element) { element.style.opacity = String(this.options.initialOpacity); element.classList.remove("reveal--visible"); element.removeAttribute("data-ss-reveal-revealed"); } /** * Reset all elements to hidden state */ hideAll() { this.elements.forEach((el) => this.hide(el)); } /** * Destroy the revealer instance */ destroy() { if (this.observer) { this.observer.disconnect(); this.observer = null; } } // ======================================================================== // Private Methods // ======================================================================== init() { this.elements.forEach((el) => { el.style.opacity = String(this.options.initialOpacity); el.style.transition = `opacity ${this.options.duration}ms ${this.options.easing}`; }); if (this.options.onScroll) { this.initIntersectionObserver(); } else { this.initLoadReveal(); } } initLoadReveal() { const reveal = () => { setTimeout(() => this.revealAll(), this.options.delay); }; if (document.readyState === "complete") { reveal(); } else { window.addEventListener("load", reveal); } } initIntersectionObserver() { this.observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const el = entry.target; setTimeout(() => this.reveal(el), this.options.delay); this.observer?.unobserve(el); } }); }, { threshold: this.options.threshold } ); this.elements.forEach((el) => this.observer?.observe(el)); } }; // src/ts/animations/CountdownTimer.ts var CountdownTimer = class { constructor(selectorOrElement, options = {}) { this.intervalId = null; this.element = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement; this.options = { endTime: options.endTime ?? new Date(Date.now() + 36e5), // Default: 1 hour format: options.format ?? "HH:MM:SS", endText: options.endText ?? "Time's up!", interval: options.interval ?? 1e3, onComplete: options.onComplete ?? (() => { }), onTick: options.onTick ?? (() => { }), leadingZeros: options.leadingZeros !== false }; if (typeof this.options.endTime === "string") { this.endTime = new Date(this.options.endTime).getTime(); } else if (typeof this.options.endTime === "number") { this.endTime = this.options.endTime; } else { this.endTime = this.options.endTime.getTime(); } if (!this.element) { console.warn("[Stylescape] CountdownTimer element not found"); return; } this.start(); } // ======================================================================== // Public Methods // ======================================================================== /** * Start the countdown */ start() { if (this.intervalId) return; this.tick(); this.intervalId = window.setInterval( () => this.tick(), this.options.interval ); } /** * Stop the countdown */ stop() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } } /** * Reset with new end time */ reset(endTime) { this.stop(); if (typeof endTime === "string") { this.endTime = new Date(endTime).getTime(); } else if (typeof endTime === "number") { this.endTime = endTime; } else { this.endTime = endTime.getTime(); } this.start(); } /** * Get remaining time breakdown */ getRemaining() { const total = Math.max(0, this.endTime - Date.now()); return { total, days: Math.floor(total / (1e3 * 60 * 60 * 24)), hours: Math.floor(total / (1e3 * 60 * 60) % 24), minutes: Math.floor(total / (1e3 * 60) % 60), seconds: Math.floor(total / 1e3 % 60) }; } /** * Destroy the countdown */ destroy() { this.stop(); this.element = null; } // ======================================================================== // Private Methods // ======================================================================== tick() { const remaining = this.getRemaining(); this.options.onTick(remaining); if (remaining.total <= 0) { this.stop(); this.updateDisplay(this.options.endText); this.options.onComplete(); return; } this.updateDisplay(this.formatTime(remaining)); } formatTime(time) { const pad = (n) => this.options.leadingZeros ? n.toString().padStart(2, "0") : n.toString(); switch (this.options.format) { case "DD:HH:MM:SS": return `${pad(time.days)}:${pad(time.hours)}:${pad(time.minutes)}:${pad(time.seconds)}`; case "full": return `${time.days}d ${time.hours}h ${time.minutes}m ${time.seconds}s`; case "compact": if (time.days > 0) return `${time.days}d ${time.hours}h`; if (time.hours > 0) return `${time.hours}h ${time.minutes}m`; return `${time.minutes}m ${time.seconds}s`; case "HH:MM:SS": default: { const totalHours = time.days * 24 + time.hours; return `${pad(totalHours)}:${pad(time.minutes)}:${pad(time.seconds)}`; } } } updateDisplay(text) { if (this.element) { this.element.textContent = text; } } }; // src/ts/animations/Preloader.ts var Preloader = class { constructor(selectorOrElement, options = {}) { this.isHidden = false; this.element = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement; this.options = { timeout: options.timeout ?? 500, hiddenClass: options.hiddenClass ?? "preloader--hidden", minDisplayTime: options.minDisplayTime ?? 0, onHide: options.onHide ?? (() => { }) }; this.startTime = Date.now(); if (!this.element) { console.warn("[Stylescape] Preloader element not found"); return; } this.init(); } // ======================================================================== // Public Methods // ======================================================================== /** * Manually show the preloader */ show() { if (!this.element) return; this.isHidden = false; this.startTime = Date.now(); this.element.classList.remove(this.options.hiddenClass); this.element.setAttribute("aria-hidden", "false"); } /** * Manually hide the preloader */ hide() { if (!this.element || this.isHidden) return; const elapsed = Date.now() - this.startTime; const remaining = Math.max(0, this.options.minDisplayTime - elapsed); setTimeout(() => { if (!this.element) return; this.element.classList.add(this.options.