Files
ayushya.dev/noo/script.js
T
ayushyaandClaude Code 4b68720eab
Deploy noo (noo.ayushya.dev) / deploy (push) Successful in 16s
Noo site: match avatar menu to app, add external storage highlight
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 14:50:11 -04:00

790 lines
36 KiB
JavaScript

(() => {
"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 = "") =>
`<svg class="i ${cls}" width="${size}" height="${size}" aria-hidden="true"><use href="icons.svg#i-${name}"/></svg>`;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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) =>
`<span class="av${current ? " av-current" : ""}" style="--s:${size}px;background:${tint}">${esc(txt)}</span>`;
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 `<div class="fr"><span class="ft" style="background:var(--${bg});color:var(--${fg})">${ic(KIND_ICON[kind], 20)}</span>` +
`<span class="fm"><b>${esc(name)}</b><i>${esc(meta)}</i></span><span class="fb">${badges}</span></div>`;
};
/* ---------------------------------------------------------------------
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"
? `<div class="np-photos">${PHOTO_TINTS.concat(PHOTO_TINTS).slice(0, 12)
.map((c, i) => `<span style="background:${c};opacity:${0.65 + ((i * 7) % 4) * 0.1}"></span>`).join("")}</div>`
: `<div class="np-list">${(ROWS[s.active] || ROWS.files).map(fileRow).join("")}</div>`;
const searchLauncher = !s.searchInBar
? `<span class="np-search">${ic("search", 18)}<span>Search</span></span>`
: "";
const hamburger = s.nav === "drawer"
? `<button class="np-ib" data-act="menu" aria-label="Open menu">${ic("menu", 24)}</button>`
: "";
const barItem = (id) => {
const sel = id === s.active;
const d = TABS[id];
if (floating && !sel) {
return `<button class="nb nb-icon" data-tab="${id}" aria-label="${d.label}">${ic(d.icon, 25)}</button>`;
}
return `<button class="nb${sel ? " is-sel" : ""}" data-tab="${id}" aria-label="${d.label}">` +
`<span class="nb-pill">${ic(d.icon, 24)}</span><span class="nb-label">${d.label}</span></button>`;
};
const searchItem = !s.searchInBar ? "" : floating
? `<button class="nb-sat" data-act="search" aria-label="Search">${ic("search", 24)}</button>`
: "";
const attachedSearch = s.searchInBar && !floating
? `<button class="nb nb-search" data-act="search" aria-label="Search"><span class="nb-pill">${ic("search", 24)}</span><span class="nb-label" style="opacity:0">Search</span></button>`
: "";
const bar = `<footer class="np-bar ${floating ? "is-floating" : "is-attached"}">` +
`<div class="np-bar-row">${tabs.map(barItem).join("")}${attachedSearch}</div>${searchItem}</footer>`;
const fab = s.active === "files"
? `<button class="np-fab" style="bottom:${floating ? 88 : 96}px" aria-label="Create">${ic("plus", 24)}</button>`
: "";
let overlay = "";
if (s.overlay === "drawer") {
overlay = `<div class="np-scrim" data-act="close"></div><nav class="np-drawer" aria-label="Navigation">` +
`<div class="np-drawer-head"><span class="np-mark"></span><b>noo</b></div>` +
hidden.map((id) => `<button class="np-dr" data-tab="${id}">${ic(TABS[id].icon, 20)}<span>${TABS[id].label}</span></button>`).join("") +
`<div class="np-sep"></div><button class="np-dr">${ic("settings", 20)}<span>Settings</span></button></nav>`;
} else if (s.overlay === "avatar") {
const rows = hidden.map((id) => `<button class="np-dr" data-tab="${id}">${ic(TABS[id].icon, 20)}<span>${TABS[id].label}</span></button>`).join("");
overlay = `<div class="np-scrim np-scrim-clear" data-act="close"></div><div class="np-pop" role="menu">` +
`<button class="np-acct" data-act="close"><span><b>ayushya</b><i>cloud.example.com</i></span>${avatar("A", "#DA9AC7", 40, true)}</button>` +
(rows ? `<div class="np-sep"></div>${rows}` : "") +
`<div class="np-sep"></div><button class="np-dr" data-act="close">${ic("settings", 20)}<span>Settings</span></button></div>`;
}
host.innerHTML =
`<div class="np" style="${css}">` +
`<div class="np-status"><span>9:41</span><span class="np-status-i">${ic("signal", 14)}${ic("wifi", 14)}${ic("battery", 16)}</span></div>` +
`<header class="np-top">${hamburger}<h4>${t.label}</h4>${searchLauncher}` +
`<button class="np-avbtn" data-act="avatar" aria-label="${s.nav === "avatar" ? "Menu" : "Accounts"}">${avatar("A", "#DA9AC7", 36, true)}</button></header>` +
`<div class="np-body">${bodyRows}</div>${fab}${bar}${overlay}</div>`;
}
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) =>
`<button class="swatch-btn${a.id === S.accent ? " is-active" : ""}" data-accent="${a.id}" aria-label="${a.name}" title="${a.name}" style="--c:${a.c}">${a.wallpaper ? ic("wallpaper", 16) : ""}</button>`
).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) =>
`<li class="tab-row" data-id="${id}"><span class="grip" data-grip aria-label="Drag to reorder">${ic("grip", 18)}</span>` +
`<span class="tab-ic">${ic(TABS[id].icon, 20)}</span><span class="tab-label">${TABS[id].label}</span>` +
`<button class="toggle${S.pinned.has(id) ? " is-on" : ""}" role="switch" aria-checked="${S.pinned.has(id)}" aria-label="Pin ${TABS[id].label}" data-pin="${id}"><i></i></button></li>`
).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) =>
`<button class="srow" data-acct="${i}">${avatar(a.l, a.t, 36, i === current)}` +
`<span class="srow-main"><b>${a.u}</b><i>${a.h}</i></span>${i === current ? `<span class="pill-tag">Active</span>` : ""}</button>`
).join("") +
`<div class="srow srow-accent">${ic("userPlus", 20)}<span class="srow-main"><b>Add account</b></span></div>`;
};
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 `<div class="srow${off ? " is-disabled" : ""}"><span class="srow-ic">${ic(icon, 20)}</span>` +
`<span class="srow-main"><b>${label}</b><i>${sub}</i></span>` +
`<button class="toggle${L[k] ? " is-on" : ""}" role="switch" aria-checked="${L[k]}" aria-label="${label}" data-lock="${k}" ${off ? "disabled" : ""}><i></i></button></div>`;
}).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) => `<button class="toggle${on ? " is-on" : ""}" role="switch" aria-checked="${on}" data-sync="${k}" aria-label="${k}"><i></i></button>`;
syncHost.innerHTML =
`<div class="srow"><span class="srow-ic">${ic("cloud", 20)}</span><span class="srow-main"><b>Sync everything</b><i>Mirror the whole account instead of picking folders</i></span>${tog("all", Y.all)}</div>` +
`<div class="srow"><span class="srow-ic">${ic("offline", 20)}</span><span class="srow-main"><b>View offline files</b><i>${Y.all ? "Every folder in this account is synced" : "3 synced folders/files"}</i></span>${ic("chevR", 18, "b-muted")}</div>` +
`<div class="srow"><span class="srow-ic">${ic("bell", 20)}</span><span class="srow-main"><b>Background sync notifications</b><i>Show a notification when a background sync updates files. Conflicts and "Sync now" always notify.</i></span>${tog("notif", Y.notif)}</div>` +
`<div class="srow"><span class="srow-ic">${ic("signal", 20)}</span><span class="srow-main"><b>Sync on cellular</b><i>Off = background sync only runs on Wi-Fi</i></span>${tog("cell", Y.cell)}</div>` +
`<div class="srow"><span class="srow-ic">${ic("refresh", 20)}</span><span class="srow-main"><b>Sync now</b>${Y.done ? `<i>Up to date - just now</i>` : ""}</span>` +
(Y.busy ? `<span class="spinner" aria-label="Syncing"></span>` : `<button class="btn-tonal" data-sync="now">Sync now</button>`) + `</div>`;
};
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 =
`<span class="sheet-grab"></span><div class="sheet-title"><b>Q3 Planning.pdf</b><i>2.4 MB</i></div>` +
`<section class="sh-sec"><div class="sh-head"><h5>Share with people</h5></div>` +
`<label class="field">${ic("search", 18)}<input type="text" placeholder="Name, group or email" aria-label="Search people" value="${esc(SH.query)}"></label>` +
people.map((p, i) =>
`<div class="prow">${avatar(p.l, p.t, 36)}<span class="srow-main"><b>${p.n}</b><i>${p.sub}</i></span>` +
(p.owner ? `<span class="owner">Owner</span>` : `<span class="menu-wrap"><button class="pill" data-perm="${i}">${p.perm}${ic("chevD", 14)}</button>` +
(SH.menu === i ? `<span class="mini-menu">${["Can view", "Can edit"].map((o) => `<button data-set="${i}|${o}">${o}${o === p.perm ? ic("check", 14) : ""}</button>`).join("")}<button class="danger" data-set="${i}|remove">Remove access</button></span>` : "") + `</span>`) + `</div>`
).join("") + `</section>` +
`<section class="sh-sec"><div class="sh-head"><h5>Share link</h5><button class="toggle${SH.link ? " is-on" : ""}" role="switch" aria-checked="${SH.link}" data-link aria-label="Share link"><i></i></button></div>` +
(SH.link
? `<div class="prow"><span class="srow-ic">${ic("link", 20)}</span><span class="srow-main"><b>Public link</b><i>cloud.example.com/s/q3-plan</i></span><button class="pill" data-linkperm>${SH.linkPerm}${ic("chevD", 14)}</button></div>` +
`<button class="btn-primary-sm" data-copy>${ic("copy", 16)}${SH.copied ? "Copied" : "Copy link"}</button>`
: `<p class="sh-cap">Turn on to create a public link.</p>`) + `</section>` +
`<section class="sh-sec"><div class="sh-head"><h5>Send file directly</h5></div><button class="btn-tonal btn-wide" data-send>${ic("send", 16)}Send via another app</button>` +
`<p class="sh-cap">Link settings above don't apply - this sends the file's bytes directly.</p></section>`;
};
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 =
`<div class="selbar"><span class="selbar-x">${ic("x", 18)}</span><b>2 selected</b>` +
`<span class="selbar-acts">${inline.map((k) => `<button class="selbar-btn" aria-label="${ACTIONS[k][0]}" title="${ACTIONS[k][0]}">${ic(ACTIONS[k][1], 20)}</button>`).join("")}` +
`<button class="selbar-btn" data-more aria-label="More" aria-expanded="${more}">${ic("moreH", 20)}</button></span></div>` +
(more ? `<div class="sheet-mini" role="menu"><span class="sheet-grab"></span>${rest.map((k) => `<div class="srow">${`<span class="srow-ic">${ic(ACTIONS[k][1], 20)}</span>`}<span class="srow-main"><b>${ACTIONS[k][0]}</b></span></div>`).join("")}</div>` : "");
};
const drawList = () => {
orderList.innerHTML = order.map((k, i) =>
`<li class="tab-row" data-id="${k}"><span class="grip" data-grip aria-label="Drag to reorder">${ic("grip", 18)}</span>` +
`<span class="tab-ic">${ic(ACTIONS[k][1], 20)}</span><span class="tab-label">${ACTIONS[k][0]}</span>` +
`<span class="where ${i < INLINE ? "is-bar" : ""}">${i < INLINE ? "In bar" : "More"}</span></li>`
).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 `<div class="sw-block sw-${side}" style="background:var(--${m[2]})" data-act-side="${side}"><span class="sw-inner">${ic(m[1], 20)}<b>${f.fav && cfg[side] === "favorite" ? "Unfavorite" : m[0]}</b></span></div>`;
};
return `<div class="sw" data-i="${i}">${blk("right")}${blk("left")}<div class="sw-fg">${row}</div></div>`;
}).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"));
}
})();