(() => { "use strict"; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); /* Lucide icons, drawn from icons.svg (generated from the app's own design kit) so every glyph in a mockup is the glyph the app ships. */ const ic = (name, size = 20, cls = "") => ``; const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); /* --------------------------------------------------------------------- Tokens - lib/theme/design_tokens.dart, light + dark, plus the accent presets from AppTheme.seedColors with the exact tonal values NooColors.fromSeed produces for each. --------------------------------------------------------------------- */ const PALETTE = { light: { bg: "#F3EFE6", sf: "#FFFFFF", sf2: "#F6F5F2", sf3: "#EAE8E2", ln: "#EAE8E2", fg1: "#0E0D0B", fg2: "#5A5852", fg3: "#7C7A72", dg: "#A8202A", dgs: "#FDE7E7", dgf: "#A8202A", ok: "#3E7A23", oks: "#EAF5DF", wn: "#8C5A0E", wns: "#FBEFD0", in: "#2940A8", ins: "#E6ECFE", scrim: "rgba(14,13,11,0.45)", }, dark: { bg: "#0E0D0B", sf: "#25241F", sf2: "#3D3C38", sf3: "#5A5852", ln: "#3D3C38", fg1: "#F3EFE6", fg2: "#BDBAB1", fg3: "#9E9B92", dg: "#F49E9E", dgs: "#560F18", dgf: "#A8202A", ok: "#B7DD9F", oks: "#1E3F10", wn: "#F0C26C", wns: "#4A3208", in: "#B6C3FB", ins: "#1C2B73", scrim: "rgba(0,0,0,0.6)", }, }; const ACCENTS = [ { id: "violet", name: "Default", c: "#8D0DE3", light: ["#8D0DE3", "#ECDFFF"], dark: ["#CBA5FD", "#2F0454"] }, { id: "blue", name: "Nextcloud Blue", c: "#0082C9", light: ["#0082C9", "#CEE5FF"], dark: ["#9BCBFB", "#0D4A73"] }, { id: "teal", name: "Ocean Teal", c: "#009688", light: ["#009688", "#9EF2E4"], dark: ["#82D5C8", "#005048"] }, { id: "purple", name: "Deep Purple", c: "#6750A4", light: ["#6750A4", "#E9DDFF"], dark: ["#CFBDFE", "#4D3D75"] }, { id: "amber", name: "Sunset Amber", c: "#E65100", light: ["#E65100", "#FFDBCF"], dark: ["#FFB59A", "#71361E"] }, { id: "green", name: "Emerald Green", c: "#2E7D32", light: ["#2E7D32", "#BCF0B4"], dark: ["#A1D39A", "#245024"] }, // Material You: follows the OS wallpaper, so this is only a sample. { id: "wallpaper", name: "Match wallpaper", c: "#8E4956", light: ["#8E4956", "#FFD9DE"], dark: ["#FFB1BD", "#72333F"], wallpaper: true }, ]; function resolveVars(s) { const mode = s.theme === "system" ? (darkQuery.matches ? "dark" : "light") : s.theme; const p = { ...PALETTE[mode] }; const a = ACCENTS.find((x) => x.id === s.accent) || ACCENTS[0]; const [text, soft] = a[mode]; if (mode === "dark" && s.amoled) p.bg = p.sf = p.sf2 = p.sf3 = "#000000"; const v = { ...p, ac: a.c, act: text, acs: soft }; return { mode, css: Object.entries(v).map(([k, val]) => `--${k}:${val}`).join(";") }; } /* --------------------------------------------------------------------- Mock content --------------------------------------------------------------------- */ const TABS = { files: { label: "Files", icon: "folder", blurb: "Browse, upload, sort and filter every folder." }, photos: { label: "Photos", icon: "photos", blurb: "Every image and video, grouped by month." }, favorites: { label: "Favorites", icon: "star", blurb: "Everything you've starred, in one list." }, activity: { label: "Activity", icon: "activity", blurb: "What changed and who changed it." }, trash: { label: "Trash", icon: "trash", blurb: "Restore or permanently delete removed files." }, shares: { label: "Shares", icon: "share", blurb: "What you've shared and what's shared with you." }, recent: { label: "Recent", icon: "clock", blurb: "Recently edited and opened, by day." }, offline: { label: "Offline", icon: "offline", blurb: "Synced folders and files, available without a connection." }, }; const TAB_ORDER_DEFAULT = ["files", "photos", "favorites", "offline", "shares", "recent", "activity", "trash"]; const PINNED_DEFAULT = ["files", "photos", "favorites", "offline", "shares"]; const KIND_ICON = { folder: "folder", pdf: "doc", doc: "doc", sheet: "sheet", video: "film", image: "image", zip: "zip" }; const KIND_VARS = { folder: ["acs", "act"], pdf: ["dgs", "dg"], doc: ["ins", "in"], image: ["ins", "in"], sheet: ["oks", "ok"], video: ["wns", "wn"], zip: ["sf3", "fg2"], }; // [name, kind, meta, flags] const ROWS = { files: [ ["Documents", "folder", "24 items · Sep 20"], ["Photos", "folder", "1,204 items · Today"], ["Projects", "folder", "8 items · Sep 19", "shared"], ["Q3 Planning.pdf", "pdf", "2.4 MB · Yesterday", "fav ok"], ["Budget 2026.xlsx", "sheet", "186 KB · Sep 18", "ok"], ["Offsite recap.mp4", "video", "412 MB · Sep 14"], ["Whiteboard.jpg", "image", "3.1 MB · Sep 12"], ["Brand brief.docx", "doc", "58 KB · Sep 10"], ], favorites: [ ["Q3 Planning.pdf", "pdf", "2.4 MB · Yesterday", "ok"], ["Projects", "folder", "8 items · Sep 19", "shared"], ["Passport scan.pdf", "pdf", "1.1 MB · Aug 30", "ok"], ["Family budget.xlsx", "sheet", "92 KB · Aug 21", "shared"], ["Wedding", "folder", "212 items · Jun 14"], ], offline: [ ["Q3 Planning.pdf", "pdf", "Up to date · 2.4 MB", "ok"], ["Offsite recap.mp4", "video", "Up to date · 412 MB", "ok"], ["Contracts", "folder", "Syncing 12 of 18 files", "sync"], ["Passport scan.pdf", "pdf", "Up to date · 1.1 MB", "ok"], ], shares: [ ["Projects", "folder", "Ana Souza · Can edit", "shared"], ["Offsite photos", "folder", "Leo Park · Can view", "shared"], ["Q3 Planning.pdf", "pdf", "Ana Souza · Can edit", "shared"], ], recent: [ ["Q3 Planning.pdf", "pdf", "Edited 10:24 · Documents", "fav"], ["Whiteboard.jpg", "image", "Uploaded 9:40 · Photos"], ["Brand brief.docx", "doc", "Opened 8:12 · Projects"], ["Budget 2026.xlsx", "sheet", "Edited yesterday · Documents"], ], trash: [ ["Old notes.txt", "doc", "Deleted today · /Documents"], ["Draft v1.docx", "doc", "Deleted yesterday · /Projects"], ["Screenshots", "folder", "Deleted Sep 12 · /Photos"], ], activity: [ ["Ana Souza shared Q3 Planning.pdf with you", "pdf", "10:24"], ["Leo Park edited Budget 2026.xlsx", "sheet", "9:02"], ["You uploaded Whiteboard.jpg", "image", "Yesterday"], ["Ana Souza commented on Projects", "folder", "Yesterday"], ], }; const PHOTO_TINTS = ["#8DCDE2", "#96B0FD", "#E5D8BD", "#EEEDB3", "#DA9AC7", "#A7D296", "#E9A6A7", "#85D1BD", "#CBA5FD"]; const avatar = (txt, tint, size = 36, current = false) => `${esc(txt)}`; const fileRow = (r) => { const [name, kind, meta, flags = ""] = r; const [bg, fg] = KIND_VARS[kind]; const f = flags.split(" "); const badges = (f.includes("fav") ? ic("star", 16, "b-fav") : "") + (f.includes("shared") ? ic("users", 16, "b-muted") : "") + (f.includes("ok") ? ic("ok", 16, "b-ok") : "") + (f.includes("sync") ? ic("sync", 16, "b-sync") : ""); return `
${ic(KIND_ICON[kind], 20)}` + `${esc(name)}${esc(meta)}${badges}
`; }; /* --------------------------------------------------------------------- The phone: top bar, body, bottom bar (attached / floating / search satellite), drawer + avatar menu. Mirrors NooTopBar, NooBottomBar, NooDrawer and the avatar menu in the Flutter app (Android layout). --------------------------------------------------------------------- */ function phoneState(over = {}) { return { theme: "light", accent: "violet", amoled: false, bar: "floating", searchInBar: false, nav: "drawer", order: [...TAB_ORDER_DEFAULT], pinned: new Set(PINNED_DEFAULT), active: "files", overlay: null, ...over, }; } function visibleTabs(s) { const max = s.searchInBar ? 4 : 5; return s.order.filter((t) => s.pinned.has(t)).slice(0, max); } function renderPhone(host, s) { const { css } = resolveVars(s); const tabs = visibleTabs(s); if (!tabs.includes(s.active)) s.active = tabs[0] || "files"; const hidden = s.order.filter((t) => !tabs.includes(t)); const t = TABS[s.active]; const floating = s.bar === "floating"; const bodyRows = s.active === "photos" ? `
${PHOTO_TINTS.concat(PHOTO_TINTS).slice(0, 12) .map((c, i) => ``).join("")}
` : `
${(ROWS[s.active] || ROWS.files).map(fileRow).join("")}
`; const searchLauncher = !s.searchInBar ? `${ic("search", 18)}Search` : ""; const hamburger = s.nav === "drawer" ? `` : ""; const barItem = (id) => { const sel = id === s.active; const d = TABS[id]; if (floating && !sel) { return ``; } return ``; }; const searchItem = !s.searchInBar ? "" : floating ? `` : ""; const attachedSearch = s.searchInBar && !floating ? `` : ""; const bar = ``; const fab = s.active === "files" ? `` : ""; let overlay = ""; if (s.overlay === "drawer") { overlay = `
`; } else if (s.overlay === "avatar") { overlay = `
`; } host.innerHTML = `
` + `
9:41${ic("signal", 14)}${ic("wifi", 14)}${ic("battery", 16)}
` + `
${hamburger}

${t.label}

${searchLauncher}` + `
` + `
${bodyRows}
${fab}${bar}${overlay}
`; } function mountPhone(host, state) { const rerender = () => renderPhone(host, state); host.addEventListener("click", (e) => { const tabBtn = e.target.closest("[data-tab]"); const act = e.target.closest("[data-act]"); if (tabBtn) { state.active = tabBtn.dataset.tab; state.overlay = null; rerender(); } else if (act) { const a = act.dataset.act; if (a === "menu") state.overlay = "drawer"; else if (a === "avatar") state.overlay = state.nav === "avatar" ? "avatar" : null; else if (a === "close") state.overlay = null; rerender(); } }); rerender(); return rerender; } /* --------------------------------------------------------------------- Hero phone --------------------------------------------------------------------- */ const heroPhone = $("#heroPhone"); if (heroPhone) { const heroState = phoneState({ theme: "system", bar: "floating" }); const r = mountPhone(heroPhone, heroState); darkQuery.addEventListener?.("change", r); } /* --------------------------------------------------------------------- Appearance lab --------------------------------------------------------------------- */ const lab = $("#labPhone"); if (lab) { const S = phoneState({ theme: "system" }); const rerender = mountPhone(lab, S); darkQuery.addEventListener?.("change", rerender); // Accent swatches const sw = $("#labAccents"); sw.innerHTML = ACCENTS.map((a) => `` ).join(""); const accName = $("#labAccentName"); const syncAccent = () => { $$(".swatch-btn", sw).forEach((b) => b.classList.toggle("is-active", b.dataset.accent === S.accent)); const a = ACCENTS.find((x) => x.id === S.accent); accName.textContent = a.wallpaper ? "Match wallpaper (sample colour shown)" : a.name; }; sw.addEventListener("click", (e) => { const b = e.target.closest("[data-accent]"); if (!b) return; S.accent = b.dataset.accent; syncAccent(); rerender(); }); syncAccent(); // Segmented controls: data-lab="theme|bar|nav" data-val="..." $$("[data-lab]").forEach((group) => { const key = group.dataset.lab; const sync = () => $$("[data-val]", group).forEach((b) => b.classList.toggle("is-active", String(S[key]) === b.dataset.val)); group.addEventListener("click", (e) => { const b = e.target.closest("[data-val]"); if (!b) return; S[key] = b.dataset.val; S.overlay = null; sync(); rerender(); updateAmoled(); tabsRender(); }); sync(); }); // Toggles: data-toggle="amoled|searchInBar" const amoledRow = $("#amoledRow"); const resolvedMode = () => resolveVars(S).mode; function updateAmoled() { const isDark = resolvedMode() === "dark"; amoledRow.classList.toggle("is-disabled", !isDark); $("[data-toggle=amoled]").disabled = !isDark; } $$("[data-toggle]").forEach((t) => { const key = t.dataset.toggle; t.setAttribute("role", "switch"); const sync = () => { t.classList.toggle("is-on", !!S[key]); t.setAttribute("aria-checked", String(!!S[key])); }; t.addEventListener("click", () => { S[key] = !S[key]; sync(); rerender(); tabsRender(); }); sync(); }); darkQuery.addEventListener?.("change", updateAmoled); updateAmoled(); // Tabs: reorder + pin const tabsList = $("#labTabs"); const tabsNote = $("#labTabsNote"); function tabsRender() { const max = S.searchInBar ? 4 : 5; tabsNote.textContent = `Drag to reorder, pin up to ${max} to the bottom bar` + (S.searchInBar ? " (Search takes the fifth slot)" : ""); tabsList.innerHTML = S.order.map((id) => `
  • ${ic("grip", 18)}` + `${ic(TABS[id].icon, 20)}${TABS[id].label}` + `
  • ` ).join(""); } tabsList.addEventListener("click", (e) => { const b = e.target.closest("[data-pin]"); if (!b) return; const id = b.dataset.pin; const max = S.searchInBar ? 4 : 5; if (S.pinned.has(id)) { if (S.pinned.size <= 1) return; S.pinned.delete(id); } else { if (S.pinned.size >= max) { tabsNote.textContent = `The bottom bar holds ${max} tabs - unpin one first`; return; } S.pinned.add(id); } S.overlay = null; tabsRender(); rerender(); }); sortable(tabsList, (ids) => { S.order = ids; S.overlay = null; rerender(); }); tabsRender(); } /* Pointer-driven list reorder: the dragged row moves through the DOM and the new order is read back on release. */ function sortable(list, onChange) { list.addEventListener("pointerdown", (e) => { const grip = e.target.closest("[data-grip]"); if (!grip) return; const row = grip.closest("[data-id]"); e.preventDefault(); row.classList.add("is-dragging"); grip.setPointerCapture(e.pointerId); const move = (ev) => { const siblings = $$("[data-id]", list).filter((r) => r !== row); const next = siblings.find((r) => { const b = r.getBoundingClientRect(); return ev.clientY < b.top + b.height / 2; }); if (next) list.insertBefore(row, next); else list.appendChild(row); }; const up = () => { grip.removeEventListener("pointermove", move); grip.removeEventListener("pointerup", up); grip.removeEventListener("pointercancel", up); row.classList.remove("is-dragging"); onChange($$("[data-id]", list).map((r) => r.dataset.id)); }; grip.addEventListener("pointermove", move); grip.addEventListener("pointerup", up); grip.addEventListener("pointercancel", up); }); } /* --------------------------------------------------------------------- Accounts --------------------------------------------------------------------- */ const acctHost = $("#acctDemo"); if (acctHost) { const accounts = [ { u: "ayushya", h: "cloud.example.com", t: "#DA9AC7", l: "A" }, { u: "ayushya.dev", h: "drive.ayushya.dev", t: "#8DCDE2", l: "D" }, { u: "family", h: "nc.home.lan", t: "#A7D296", l: "F" }, ]; let current = 0; const draw = () => { acctHost.innerHTML = accounts.map((a, i) => `` ).join("") + `
    ${ic("userPlus", 20)}Add account
    `; }; acctHost.addEventListener("click", (e) => { const b = e.target.closest("[data-acct]"); if (!b) return; current = Number(b.dataset.acct); draw(); const note = $("#acctNote"); if (note) note.textContent = `Now browsing ${accounts[current].h}. Each account keeps its own sync, cache and favorites.`; }); draw(); } /* --------------------------------------------------------------------- Locks --------------------------------------------------------------------- */ const lockHost = $("#lockDemo"); if (lockHost) { const L = { login: true, switching: true, hidden: false }; const rows = [ ["login", "lock", "Lock with fingerprint", "Require this device's PIN or biometric to open Noo", false], ["switching", "switch", "Lock account switching", "Unlock to switch between saved accounts", true], ["hidden", "hidden", "Lock hidden files", "Unlock to reveal hidden files and folders", true], ]; const draw = () => { lockHost.innerHTML = rows.map(([k, icon, label, sub, dep]) => { const off = dep && !L.login; return `
    ${ic(icon, 20)}` + `${label}${sub}` + `
    `; }).join(""); }; lockHost.addEventListener("click", (e) => { const b = e.target.closest("[data-lock]"); if (!b || b.disabled) return; L[b.dataset.lock] = !L[b.dataset.lock]; draw(); }); draw(); } /* --------------------------------------------------------------------- Sync --------------------------------------------------------------------- */ const syncHost = $("#syncDemo"); if (syncHost) { const Y = { all: false, notif: true, cell: false, busy: false, done: false }; const draw = () => { const tog = (k, on) => ``; syncHost.innerHTML = `
    ${ic("cloud", 20)}Sync everythingMirror the whole account instead of picking folders${tog("all", Y.all)}
    ` + `
    ${ic("offline", 20)}View offline files${Y.all ? "Every folder in this account is synced" : "3 synced folders/files"}${ic("chevR", 18, "b-muted")}
    ` + `
    ${ic("bell", 20)}Background sync notificationsShow a notification when a background sync updates files. Conflicts and "Sync now" always notify.${tog("notif", Y.notif)}
    ` + `
    ${ic("signal", 20)}Sync on cellularOff = background sync only runs on Wi-Fi${tog("cell", Y.cell)}
    ` + `
    ${ic("refresh", 20)}Sync now${Y.done ? `Up to date - just now` : ""}` + (Y.busy ? `` : ``) + `
    `; }; syncHost.addEventListener("click", (e) => { const b = e.target.closest("[data-sync]"); if (!b) return; const k = b.dataset.sync; if (k === "now") { Y.busy = true; Y.done = false; draw(); setTimeout(() => { Y.busy = false; Y.done = true; draw(); }, 1400); } else { Y[k] = !Y[k]; draw(); } }); draw(); } /* --------------------------------------------------------------------- Share sheet --------------------------------------------------------------------- */ const shareHost = $("#shareDemo"); if (shareHost) { const people = [ { n: "ayushya", sub: "cloud.example.com", t: "#DA9AC7", l: "A", owner: true }, { n: "Ana Souza", sub: "ana@example.com", t: "#8DCDE2", l: "AS", perm: "Can edit" }, { n: "Leo Park", sub: "Design team", t: "#A7D296", l: "LP", perm: "Can view" }, ]; const SH = { link: true, linkPerm: "Can view", copied: false, menu: null, query: "" }; const draw = () => { shareHost.innerHTML = `
    Q3 Planning.pdf2.4 MB
    ` + `
    Share with people
    ` + `` + people.map((p, i) => `
    ${avatar(p.l, p.t, 36)}${p.n}${p.sub}` + (p.owner ? `Owner` : `` + (SH.menu === i ? `${["Can view", "Can edit"].map((o) => ``).join("")}` : "") + ``) + `
    ` ).join("") + `
    ` + `
    Share link
    ` + (SH.link ? `
    ${ic("link", 20)}Public linkcloud.example.com/s/q3-plan
    ` + `` : `

    Turn on to create a public link.

    `) + `
    ` + `
    Send file directly
    ` + `

    Link settings above don't apply - this sends the file's bytes directly.

    `; }; shareHost.addEventListener("click", (e) => { const t = e.target; let b; if ((b = t.closest("[data-perm]"))) { const i = Number(b.dataset.perm); SH.menu = SH.menu === i ? null : i; } else if ((b = t.closest("[data-set]"))) { const [i, v] = b.dataset.set.split("|"); if (v === "remove") people.splice(Number(i), 1); else people[Number(i)].perm = v; SH.menu = null; } else if (t.closest("[data-link]")) { SH.link = !SH.link; SH.copied = false; } else if (t.closest("[data-linkperm]")) { SH.linkPerm = SH.linkPerm === "Can view" ? "Can edit" : "Can view"; } else if (t.closest("[data-copy]")) { SH.copied = true; setTimeout(() => { SH.copied = false; draw(); }, 1600); } else if (!t.closest(".mini-menu")) { if (SH.menu === null) return; SH.menu = null; } else return; draw(); }); shareHost.addEventListener("input", (e) => { SH.query = e.target.value; }); draw(); } /* --------------------------------------------------------------------- Action bar --------------------------------------------------------------------- */ const ACTIONS = { favorite: ["Favorite", "star"], share: ["Share", "share"], download: ["Download", "download"], delete: ["Delete", "trash"], copy: ["Copy", "copy"], move: ["Move", "move"], rename: ["Rename", "rename"], sync: ["Sync to device", "offline"], details: ["Details", "info"], }; const actionHost = $("#actionDemo"); if (actionHost) { let order = Object.keys(ACTIONS); let more = false; const INLINE = 3; const orderList = $("#actionOrder"); const bar = $("#actionBar"); const drawBar = () => { const inline = order.slice(0, INLINE); const rest = order.slice(INLINE); bar.innerHTML = `
    ${ic("x", 18)}2 selected` + `${inline.map((k) => ``).join("")}` + `
    ` + (more ? `` : ""); }; const drawList = () => { orderList.innerHTML = order.map((k, i) => `
  • ${ic("grip", 18)}` + `${ic(ACTIONS[k][1], 20)}${ACTIONS[k][0]}` + `${i < INLINE ? "In bar" : "More"}
  • ` ).join(""); }; sortable(orderList, (ids) => { order = ids; drawList(); drawBar(); }); bar.addEventListener("click", (e) => { if (e.target.closest("[data-more]")) { more = !more; drawBar(); } }); drawList(); drawBar(); } /* --------------------------------------------------------------------- Swiping --------------------------------------------------------------------- */ const swipeHost = $("#swipeDemo"); if (swipeHost) { const BLOCK = 96, THROUGH = 1.8; const cfg = { right: "favorite", left: "delete" }; const files = [ROWS.files[3], ROWS.files[4], ROWS.files[6], ROWS.files[7]].map((r) => ({ r, fav: r[3] && r[3].includes("fav") })); const toast = $("#swipeToast"); let toastTimer; const say = (msg) => { toast.textContent = msg; toast.classList.add("is-on"); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove("is-on"), 1800); }; const meta = { none: null, favorite: ["Favorite", "star", "ac"], delete: ["Delete", "trash", "dgf"], share: ["Share", "share", "ac"], }; const list = $("#swipeList"); const drawRows = () => { list.innerHTML = files.map((f, i) => { const row = fileRow([f.r[0], f.r[1], f.r[2], (f.fav ? "fav " : "") + (f.r[3] || "").replace("fav", "")]); const blk = (side) => { const m = meta[cfg[side]]; if (!m) return ""; return `
    ${ic(m[1], 20)}${f.fav && cfg[side] === "favorite" ? "Unfavorite" : m[0]}
    `; }; return `
    ${blk("right")}${blk("left")}
    ${row}
    `; }).join(""); }; const trigger = (i, side) => { const a = cfg[side]; const f = files[i]; if (a === "favorite") { f.fav = !f.fav; say(f.fav ? `Added ${f.r[0]} to favorites` : `Removed ${f.r[0]} from favorites`); drawRows(); } else if (a === "delete") { const el = $(`.sw[data-i="${i}"]`, list); el.classList.add("is-gone"); say(`Moved ${f.r[0]} to trash (demo - it comes back)`); setTimeout(drawRows, 1500); } else if (a === "share") { say(`Share sheet opens for ${f.r[0]}`); drawRows(); } }; let drag = null; list.addEventListener("pointerdown", (e) => { const sw = e.target.closest(".sw"); if (!sw) return; const blk = e.target.closest("[data-act-side]"); if (blk && sw.dataset.open === blk.dataset.actSide) { trigger(Number(sw.dataset.i), blk.dataset.actSide); return; } const fg = $(".sw-fg", sw); const base = Number(sw.dataset.dx || 0); drag = { sw, fg, x0: e.clientX, base, moved: false, id: e.pointerId }; sw.setPointerCapture?.(e.pointerId); }); list.addEventListener("pointermove", (e) => { if (!drag || e.pointerId !== drag.id) return; let dx = drag.base + (e.clientX - drag.x0); if (Math.abs(e.clientX - drag.x0) > 4) drag.moved = true; const maxRight = cfg.right === "none" ? 0 : BLOCK * 2.2; const maxLeft = cfg.left === "none" ? 0 : BLOCK * 2.2; dx = Math.max(-maxLeft, Math.min(maxRight, dx)); drag.dx = dx; drag.sw.dataset.dx = dx; drag.sw.classList.add("is-dragging"); drag.sw.classList.toggle("show-right", dx > 0); drag.sw.classList.toggle("show-left", dx < 0); drag.sw.style.setProperty("--dx", dx + "px"); drag.sw.classList.toggle("is-through", Math.abs(dx) >= BLOCK * THROUGH); }); const end = (e) => { if (!drag || e.pointerId !== drag.id) return; const { sw } = drag; const dx = drag.dx ?? drag.base; const side = dx > 0 ? "right" : "left"; sw.classList.remove("is-dragging", "is-through"); let target = 0; if (Math.abs(dx) >= BLOCK * THROUGH) { trigger(Number(sw.dataset.i), side); drag = null; return; } if (Math.abs(dx) > BLOCK / 2) target = Math.sign(dx) * BLOCK; sw.dataset.dx = target; sw.dataset.open = target === 0 ? "" : side; sw.style.setProperty("--dx", target + "px"); if (target === 0) sw.classList.remove("show-right", "show-left"); drag = null; }; list.addEventListener("pointerup", end); list.addEventListener("pointercancel", end); // Pickers $$("[data-swipe]", swipeHost).forEach((group) => { const side = group.dataset.swipe; const sync = () => $$("[data-val]", group).forEach((b) => b.classList.toggle("is-active", b.dataset.val === cfg[side])); group.addEventListener("click", (e) => { const b = e.target.closest("[data-val]"); if (!b) return; cfg[side] = b.dataset.val; sync(); drawRows(); }); sync(); }); drawRows(); } /* --------------------------------------------------------------------- Page chrome: mobile nav, hero parallax, scroll reveal --------------------------------------------------------------------- */ const navToggle = $("#navToggle"); const mobileNav = $("#mobileNav"); if (navToggle && mobileNav) { navToggle.addEventListener("click", () => { const open = mobileNav.classList.toggle("is-open"); navToggle.setAttribute("aria-expanded", String(open)); navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu"); }); $$("a", mobileNav).forEach((a) => a.addEventListener("click", () => { mobileNav.classList.remove("is-open"); navToggle.setAttribute("aria-expanded", "false"); }) ); } const heroVisual = $("#heroVisual"); if (heroVisual && !reduceMotion) { const layers = $$("[data-depth]", heroVisual).map((el) => ({ el, depth: parseFloat(el.dataset.depth) || 0.3 })); let scrollOffset = 0; let pointer = { x: 0, y: 0 }; let ticking = false; const apply = () => { const rect = heroVisual.getBoundingClientRect(); if (rect.bottom > 0 && rect.top < window.innerHeight) { for (const l of layers) { l.el.style.transform = `translate3d(${pointer.x * l.depth * 14}px, ${scrollOffset * l.depth * 40 + pointer.y * l.depth * 14}px, 0)`; } } ticking = false; }; const tick = () => { if (!ticking) { ticking = true; requestAnimationFrame(apply); } }; const onScroll = () => { const rect = heroVisual.getBoundingClientRect(); scrollOffset = (window.innerHeight / 2 - (rect.top + rect.height / 2)) / window.innerHeight; tick(); }; window.addEventListener("scroll", onScroll, { passive: true }); heroVisual.addEventListener("pointermove", (e) => { const rect = heroVisual.getBoundingClientRect(); pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = ((e.clientY - rect.top) / rect.height) * 2 - 1; tick(); }); heroVisual.addEventListener("pointerleave", () => { pointer = { x: 0, y: 0 }; tick(); }); onScroll(); } const revealTargets = $$(".card, .showcase-copy, .showcase-demo, .tab-chip, .privacy-points li"); revealTargets.forEach((el) => el.classList.add("reveal")); if ("IntersectionObserver" in window && !reduceMotion) { const io = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { entry.target.classList.add("is-visible"); io.unobserve(entry.target); } } }, { threshold: 0.12, rootMargin: "0px 0px -40px 0px" } ); revealTargets.forEach((el) => io.observe(el)); } else { revealTargets.forEach((el) => el.classList.add("is-visible")); } })();