Files
ayushya.dev/noo/script.js
T
ayushyaandClaude Code 19c82c5068
Deploy noo (noo.ayushya.dev) / deploy (push) Successful in 15s
Rebuild Noo site with interactive mockups and add privacy policy
Use the app's monochrome icon, add live phone mockups for accounts, locks,
sync, sharing and appearance options, a tab list, coming-soon platforms,
and a full privacy policy page.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 10:49:35 -04:00

791 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") {
overlay = `<div class="np-scrim" data-act="close"></div><div class="np-pop" role="menu">` +
`<div class="np-acct">${avatar("A", "#DA9AC7", 32, true)}<span><b>ayushya</b><i>cloud.example.com</i></span>${ic("check", 18, "b-sync")}</div>` +
`<div class="np-acct">${avatar("D", "#8DCDE2", 32)}<span><b>ayushya.dev</b><i>drive.ayushya.dev</i></span></div>` +
`<div class="np-sep"></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></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"));
}
})();